diff --git a/.chronus/changes/fix-subpath-emitter-options-2026-8-22.md b/.chronus/changes/fix-subpath-emitter-options-2026-8-22.md new file mode 100644 index 00000000000..e9771b99ef3 --- /dev/null +++ b/.chronus/changes/fix-subpath-emitter-options-2026-8-22.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Resolve tspconfig emitter options for packages exposed as subpath exports, and default their output directories to the emit specifier. diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index b5608ea673e..ae66e56df1c 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -627,10 +627,25 @@ async function createProgram( const emitFunction = entrypoint.esmExports.$onEmit; const libDefinition = library.definition; + // Prefer the emit specifier so subpath exports get matching options. + // Fall back to the library name ($lib.name for file emitters, package.json + // name for module emitters) for older configs and file-based emitters. + const libraryName = metadata.name; + let emitterOptionsKey = emitterNameOrPath; + if ( + !Object.hasOwn(emittersOptions, emitterNameOrPath) && + libraryName !== undefined && + Object.hasOwn(emittersOptions, libraryName) + ) { + emitterOptionsKey = libraryName; + } let { "emitter-output-dir": emitterOutputDir, ...emitterOptions } = - emittersOptions[metadata.name ?? emitterNameOrPath] ?? {}; + emittersOptions[emitterOptionsKey] ?? {}; if (emitterOutputDir === undefined) { - emitterOutputDir = [options.outputDir, metadata.name].filter(isDefined).join("/"); + // Module emitters use the emit specifier so multiple subpath exports from + // the same package do not share one default output directory. + const defaultDirName = metadata.type === "module" ? emitterNameOrPath : libraryName; + emitterOutputDir = [options.outputDir, defaultDirName].filter(isDefined).join("/"); } if (libDefinition?.requireImports) { for (const lib of libDefinition.requireImports) { @@ -644,7 +659,7 @@ async function createProgram( options.configFile?.file ? { kind: "path-target", - path: ["options", emitterNameOrPath], + path: ["options", emitterOptionsKey], script: options.configFile.file, } : NoTarget, diff --git a/packages/compiler/test/core/emitter-options.test.ts b/packages/compiler/test/core/emitter-options.test.ts index 014546ef973..d6e0b1457c3 100644 --- a/packages/compiler/test/core/emitter-options.test.ts +++ b/packages/compiler/test/core/emitter-options.test.ts @@ -1,9 +1,11 @@ import { ok, strictEqual } from "assert"; import { describe, it } from "vitest"; -import type { Diagnostic, EmitContext } from "../../src/index.js"; +import { getSourceLocation } from "../../src/core/diagnostics.js"; +import type { CompilerOptions, Diagnostic, EmitContext } from "../../src/index.js"; import { createTypeSpecLibrary } from "../../src/index.js"; import { expectDiagnosticEmpty, expectDiagnostics } from "../../src/testing/expect.js"; import { mockFile } from "../../src/testing/fs.js"; +import { parseYaml } from "../../src/yaml/parser.js"; import { Tester } from "../tester.js"; const fakeEmitter = createTypeSpecLibrary({ @@ -72,6 +74,142 @@ it("pass options", async () => { strictEqual(context.options["max-files"], 10); }); +describe("subpath export emitters", () => { + const subpathLib = createTypeSpecLibrary({ + name: "@org/fake-emitter/typescript", + diagnostics: {}, + emitter: { + options: { + type: "object", + properties: { + "asset-dir": { type: "string", format: "absolute-path", nullable: true }, + "max-files": { type: "number", nullable: true }, + }, + additionalProperties: false, + }, + }, + }); + + async function runSubpathEmitter( + options: Record>, + extraCompilerOptions: CompilerOptions = {}, + ) { + let emitContext: EmitContext | undefined; + const diagnostics = await Tester.files({ + "node_modules/@org/fake-emitter/package.json": JSON.stringify({ + name: "@org/fake-emitter", + exports: { + ".": "./index.js", + "./typescript": "./typescript/index.js", + }, + }), + "node_modules/@org/fake-emitter/index.js": mockFile.js({ + $lib: createTypeSpecLibrary({ name: "@org/fake-emitter", diagnostics: {} }), + }), + "node_modules/@org/fake-emitter/typescript/index.js": mockFile.js({ + $lib: subpathLib, + $onEmit: (ctx: EmitContext) => { + emitContext = ctx; + }, + }), + }).diagnose("", { + compilerOptions: { + emit: ["@org/fake-emitter/typescript"], + options, + ...extraCompilerOptions, + }, + }); + return [emitContext, diagnostics] as const; + } + + it("resolves options keyed by the subpath specifier", async () => { + const [context, diagnostics] = await runSubpathEmitter({ + "@org/fake-emitter/typescript": { + "emitter-output-dir": "/out", + "asset-dir": "/assets", + "max-files": 10, + }, + }); + expectDiagnosticEmpty(diagnostics); + ok(context, "Emit context should have been set."); + strictEqual(context.emitterOutputDir, "/out"); + strictEqual(context.options["asset-dir"], "/assets"); + strictEqual(context.options["max-files"], 10); + }); + + it("falls back to package.json name when the subpath key is missing", async () => { + const [context, diagnostics] = await runSubpathEmitter({ + "@org/fake-emitter": { + "emitter-output-dir": "/from-pkg", + "asset-dir": "/pkg-assets", + }, + }); + expectDiagnosticEmpty(diagnostics); + ok(context, "Emit context should have been set."); + strictEqual(context.emitterOutputDir, "/from-pkg"); + strictEqual(context.options["asset-dir"], "/pkg-assets"); + }); + + it("targets the package-name options key when validating fallback options", async () => { + const [, diagnostics] = await runSubpathEmitter({ + "@org/fake-emitter": { + "invalid-option": "abc", + }, + }); + expectDiagnostics(diagnostics, { + code: "invalid-schema", + message: [ + "Schema violation: must NOT have additional properties (/)", + " additionalProperty: invalid-option", + ].join("\n"), + }); + }); + + it("defaults emitter-output-dir using the subpath specifier", async () => { + const [context, diagnostics] = await runSubpathEmitter({}, { outputDir: "/out" }); + expectDiagnosticEmpty(diagnostics); + ok(context, "Emit context should have been set."); + strictEqual(context.emitterOutputDir, "/out/@org/fake-emitter/typescript"); + }); + + it("reports schema diagnostics against the options key that supplied them", async () => { + const yaml = [ + "options:", + ' "@org/fake-emitter":', + ' max-files: "not a number"', + "", + ].join("\n"); + const [script] = parseYaml(yaml); + const [_, diagnostics] = await runSubpathEmitter( + { + "@org/fake-emitter": { + "max-files": "not a number", + }, + }, + { + configFile: { + projectRoot: ".", + diagnostics: [], + outputDir: "tsp-output", + file: script, + }, + }, + ); + expectDiagnostics(diagnostics, { + code: "invalid-schema", + message: "Schema violation: must be number (/max-files)", + }); + const loc = getSourceLocation(diagnostics[0].target); + ok(loc, "Diagnostic should have a source location."); + ok(loc.pos > 0, "Diagnostic should point at the package-name options key, not pos 0."); + const snippet = loc.file.text.slice(loc.pos, loc.end); + ok( + snippet.includes("max-files"), + `Expected diagnostic to target max-files under @org/fake-emitter, got ${JSON.stringify(snippet)}`, + ); + }); +}); + it("emit diagnostic if passing unknown option", async () => { const diagnostics = await diagnoseEmitterOptions({ "invalid-option": "abc",