diff --git a/.chronus/changes/copilot-add-support-for-exports-2026-6-28-13-56-41.md b/.chronus/changes/copilot-add-support-for-exports-2026-6-28-13-56-41.md new file mode 100644 index 00000000000..fddb2fb1e9d --- /dev/null +++ b/.chronus/changes/copilot-add-support-for-exports-2026-6-28-13-56-41.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +`tsp compile .` now resolves the entrypoint from `exports["."]["typespec"]` in package.json, taking precedence over the legacy `tspMain` field \ No newline at end of file diff --git a/packages/compiler/src/core/entrypoint-resolution.ts b/packages/compiler/src/core/entrypoint-resolution.ts index 6c50cc96a47..8e69cf3d9e9 100644 --- a/packages/compiler/src/core/entrypoint-resolution.ts +++ b/packages/compiler/src/core/entrypoint-resolution.ts @@ -1,4 +1,7 @@ import { loadTypeSpecConfigForPath } from "../config/config-loader.js"; +import { resolvePackageExports } from "../module-resolver/esm/resolve-package-exports.js"; +import { NoMatchingConditionsError } from "../module-resolver/esm/utils.js"; +import { fileURLToPath, pathToFileURL } from "../module-resolver/utils.js"; import { doIO, loadFile } from "../utils/io.js"; import { resolveTspMain } from "../utils/misc.js"; import { DiagnosticHandler } from "./diagnostics.js"; @@ -39,12 +42,40 @@ export async function resolveTypeSpecEntrypointForDir( return resolvePath(dir, config.entrypoint); } - // Otherwise fall back to the `tspMain` declared in package.json. A `tspconfig.yaml` - // without an explicit `entrypoint` must not override `tspMain`. const pkgJsonPath = resolvePath(dir, "package.json"); const [pkg] = await loadFile(host, pkgJsonPath, JSON.parse, reportDiagnostic, { allowFileNotFound: true, }); + + // Try exports["."]["typespec"] first using the existing ESM package exports resolver. + if (pkg?.exports) { + try { + const match = await resolvePackageExports( + { + packageUrl: pathToFileURL(dir), + specifier: ".", + moduleDirs: ["node_modules"], + conditions: ["typespec"], + ignoreDefaultCondition: true, + resolveId: () => { + throw new Error("not supported"); + }, + }, + ".", + pkg.exports, + ); + if (match) { + return fileURLToPath(match); + } + } catch (e) { + if (!(e instanceof NoMatchingConditionsError)) { + throw e; + } + // No matching typespec condition — fall through to tspMain / main.tsp + } + } + + // Fall back to the legacy `tspMain` declared in package.json. const tspMain = resolveTspMain(pkg); if (tspMain !== undefined) { return resolvePath(dir, tspMain); diff --git a/packages/compiler/src/utils/misc.ts b/packages/compiler/src/utils/misc.ts index eb4aebb7ba9..49b1e7879a9 100644 --- a/packages/compiler/src/utils/misc.ts +++ b/packages/compiler/src/utils/misc.ts @@ -219,9 +219,8 @@ export function omitUndefined>(data: T): T { } /** - * Extract package.json's tspMain entry point in a given path. - * @param path Path that contains package.json - * @param reportDiagnostic optional diagnostic handler. + * Extract package.json's tspMain entry point. + * @param packageJson Parsed package.json object. */ export function resolveTspMain(packageJson: any): string | undefined { if (packageJson?.tspMain !== undefined) { diff --git a/packages/compiler/test/core/entrypoint-resolution.test.ts b/packages/compiler/test/core/entrypoint-resolution.test.ts index 79f11c42882..be6d8686e36 100644 --- a/packages/compiler/test/core/entrypoint-resolution.test.ts +++ b/packages/compiler/test/core/entrypoint-resolution.test.ts @@ -43,3 +43,41 @@ it("defaults to main.tsp when neither tspconfig entrypoint nor package.json tspM const entrypoint = await resolveTypeSpecEntrypointForDir(host, dir, () => {}); expect(entrypoint).toBe(resolveVirtualPath("my-lib/main.tsp")); }); + +it("uses package.json exports['.']['typespec'] when there is no tspMain", async () => { + fs.add( + "my-lib/package.json", + JSON.stringify({ + name: "my-lib", + exports: { ".": { typespec: "./lib/main.tsp", default: "./dist/index.js" } }, + }), + ); + const entrypoint = await resolveTypeSpecEntrypointForDir(host, dir, () => {}); + expect(entrypoint).toBe(resolveVirtualPath("my-lib/lib/main.tsp")); +}); + +it("exports['.']['typespec'] takes precedence over tspMain", async () => { + fs.add( + "my-lib/package.json", + JSON.stringify({ + name: "my-lib", + tspMain: "lib/main.tsp", + exports: { ".": { typespec: "./lib/other.tsp", default: "./dist/index.js" } }, + }), + ); + const entrypoint = await resolveTypeSpecEntrypointForDir(host, dir, () => {}); + expect(entrypoint).toBe(resolveVirtualPath("my-lib/lib/other.tsp")); +}); + +it("an explicit entrypoint in tspconfig.yaml takes precedence over exports['.']['typespec']", async () => { + fs.add( + "my-lib/package.json", + JSON.stringify({ + name: "my-lib", + exports: { ".": { typespec: "./lib/main.tsp", default: "./dist/index.js" } }, + }), + ); + fs.add("my-lib/tspconfig.yaml", `kind: project\nentrypoint: other.tsp`); + const entrypoint = await resolveTypeSpecEntrypointForDir(host, dir, () => {}); + expect(entrypoint).toBe(resolveVirtualPath("my-lib/other.tsp")); +});