From 4265215c3e093c2122bfe546a59b05597953fc0a Mon Sep 17 00:00:00 2001 From: Tanvir Alam Date: Sat, 22 Aug 2026 18:35:27 +0000 Subject: [PATCH 1/3] 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/3] 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 () => { From f3b62bd715806fa63e577fb6c8de9f25bffda93d Mon Sep 17 00:00:00 2001 From: Md Tanvir Alam Date: Sun, 23 Aug 2026 19:31:03 +0000 Subject: [PATCH 3/3] fix(compiler): unique default output dirs for subpath emitters Use the emit specifier for module-emitter defaults so sibling subpath exports do not collide, and point option schema diagnostics at the config key that actually supplied the values. --- .../fix-subpath-emitter-options-2026-8-22.md | 2 +- packages/compiler/src/core/program.ts | 30 +++++----- .../test/core/emitter-options.test.ts | 58 +++++++++++++++++-- 3 files changed, 70 insertions(+), 20 deletions(-) diff --git a/.chronus/changes/fix-subpath-emitter-options-2026-8-22.md b/.chronus/changes/fix-subpath-emitter-options-2026-8-22.md index 7d2fdac8dbf..e9771b99ef3 100644 --- a/.chronus/changes/fix-subpath-emitter-options-2026-8-22.md +++ b/.chronus/changes/fix-subpath-emitter-options-2026-8-22.md @@ -4,4 +4,4 @@ packages: - "@typespec/compiler" --- -Resolve tspconfig emitter options for packages exposed as subpath exports. +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 b9c28cc40e5..ae66e56df1c 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -627,21 +627,25 @@ 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. - 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; + // 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 } = - optionsFromSpecifier ?? optionsFromPackageName ?? {}; + 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) { diff --git a/packages/compiler/test/core/emitter-options.test.ts b/packages/compiler/test/core/emitter-options.test.ts index 74006aaeec2..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({ @@ -88,7 +90,10 @@ describe("subpath export emitters", () => { }, }); - async function runSubpathEmitter(options: Record>) { + 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({ @@ -111,6 +116,7 @@ describe("subpath export emitters", () => { compilerOptions: { emit: ["@org/fake-emitter/typescript"], options, + ...extraCompilerOptions, }, }); return [emitContext, diagnostics] as const; @@ -157,10 +163,50 @@ describe("subpath export emitters", () => { " 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("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)}`, + ); }); });