From 4265215c3e093c2122bfe546a59b05597953fc0a Mon Sep 17 00:00:00 2001 From: Tanvir Alam Date: Sat, 22 Aug 2026 18:35:27 +0000 Subject: [PATCH 1/2] fix(compiler): resolve emitter options for subpath exports Prefer the tspconfig emitter specifier when looking up options so packages exposed as subpath exports receive their configured options. Fixes #10200 --- .../fix-subpath-emitter-options-2026-8-22.md | 7 ++ packages/compiler/src/core/program.ts | 6 +- .../test/core/emitter-options.test.ts | 73 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 .chronus/changes/fix-subpath-emitter-options-2026-8-22.md 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..7d2fdac8dbf --- /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. diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index b5608ea673e..ee1bc83689d 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -627,8 +627,12 @@ async function createProgram( const emitFunction = entrypoint.esmExports.$onEmit; const libDefinition = library.definition; + // Prefer the specifier from tspconfig so subpath exports get matching options. + // Fall back to package.json name for file emitters and older configs. let { "emitter-output-dir": emitterOutputDir, ...emitterOptions } = - emittersOptions[metadata.name ?? emitterNameOrPath] ?? {}; + emittersOptions[emitterNameOrPath] ?? + (metadata.name !== undefined ? emittersOptions[metadata.name] : undefined) ?? + {}; if (emitterOutputDir === undefined) { emitterOutputDir = [options.outputDir, metadata.name].filter(isDefined).join("/"); } diff --git a/packages/compiler/test/core/emitter-options.test.ts b/packages/compiler/test/core/emitter-options.test.ts index 014546ef973..9c551dee181 100644 --- a/packages/compiler/test/core/emitter-options.test.ts +++ b/packages/compiler/test/core/emitter-options.test.ts @@ -72,6 +72,79 @@ 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>) { + 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, + }, + }); + 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("emit diagnostic if passing unknown option", async () => { const diagnostics = await diagnoseEmitterOptions({ "invalid-option": "abc", From cf4523e9e7ed82b4772230e3ffad16015ee5d153 Mon Sep 17 00:00:00 2001 From: Md Tanvir Alam Date: Sat, 22 Aug 2026 21:45:45 +0000 Subject: [PATCH 2/2] Point emitter option diagnostics at the config key that was used. --- packages/compiler/src/core/program.ts | 15 +++++++++++---- .../test/core/emitter-options.test.ts | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index ee1bc83689d..b9c28cc40e5 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -629,10 +629,17 @@ async function createProgram( // Prefer the specifier from tspconfig so subpath exports get matching options. // Fall back to package.json name for file emitters and older configs. + const optionsFromSpecifier = emittersOptions[emitterNameOrPath]; + const optionsFromPackageName = + metadata.name !== undefined ? emittersOptions[metadata.name] : undefined; + const emitterOptionsKey = + optionsFromSpecifier !== undefined + ? emitterNameOrPath + : optionsFromPackageName !== undefined && metadata.name !== undefined + ? metadata.name + : emitterNameOrPath; let { "emitter-output-dir": emitterOutputDir, ...emitterOptions } = - emittersOptions[emitterNameOrPath] ?? - (metadata.name !== undefined ? emittersOptions[metadata.name] : undefined) ?? - {}; + optionsFromSpecifier ?? optionsFromPackageName ?? {}; if (emitterOutputDir === undefined) { emitterOutputDir = [options.outputDir, metadata.name].filter(isDefined).join("/"); } @@ -648,7 +655,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 9c551dee181..74006aaeec2 100644 --- a/packages/compiler/test/core/emitter-options.test.ts +++ b/packages/compiler/test/core/emitter-options.test.ts @@ -143,6 +143,25 @@ describe("subpath export emitters", () => { 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"), + }); + const target = diagnostics[0]?.target; + if (target && typeof target === "object" && "path" in target) { + strictEqual((target as { path: string[] }).path.join("."), "options.@org/fake-emitter"); + } + }); }); it("emit diagnostic if passing unknown option", async () => {