diff --git a/.chronus/changes/tspd-subpath-signatures-2026-7-5-0-0-0.md b/.chronus/changes/tspd-subpath-signatures-2026-7-5-0-0-0.md new file mode 100644 index 00000000000..15fd8ff45fc --- /dev/null +++ b/.chronus/changes/tspd-subpath-signatures-2026-7-5-0-0-0.md @@ -0,0 +1,15 @@ +--- +changeKind: fix +packages: + - "@typespec/tspd" +--- + +`gen-extern-signature` now generates signatures for sub path exports. Each export with a `typespec` condition is compiled on its own, entities are attributed to the export that first reaches their source file, and the generated files are written under a directory matching the sub path with `$decorators` imported from that same sub path. + +```ts +// generated-defs/streams/MyLib.Streams.ts-test.ts +import { $decorators } from "my-lib/streams"; +import type { MyLibStreamsDecorators } from "./MyLib.Streams.js"; + +const _decs: MyLibStreamsDecorators = $decorators["MyLib.Streams"]; +``` diff --git a/packages/tspd/README.md b/packages/tspd/README.md index d659df986a6..7ee0ad0655a 100644 --- a/packages/tspd/README.md +++ b/packages/tspd/README.md @@ -16,3 +16,18 @@ tspd --enable-experimental gen-extern-signature ```bash tspd --enable-experimental doc . --output-dir ./docs/ ``` + +## Sub path exports + +`gen-extern-signature` generates signatures for every entry of the `exports` field in `package.json` that defines a `typespec` condition. + +Each export is compiled on its own and a decorator or function is attributed to the first export that reaches the file declaring it. This means entities declared in the root entrypoint stay on the root even when a sub path entrypoint imports it back. + +Signatures for the root export(`.`) are written to `generated-defs/`, signatures for a sub path are written to a matching directory and import `$decorators`/`$functions` from that same sub path: + +``` +generated-defs/MyLib.ts <- from `.`, imports from "my-lib" +generated-defs/streams/MyLib.Streams.ts <- from `./streams`, imports from "my-lib/streams" +``` + +For this to work the sub path must expose its own JS module (an `import` or `default` condition) exporting the `$decorators` of the decorators it declares, and its TypeSpec entrypoint must import that JS module. diff --git a/packages/tspd/src/gen-extern-signatures/components/entity-signatures.tsx b/packages/tspd/src/gen-extern-signatures/components/entity-signatures.tsx index 8dca97a1c3d..56d31f26486 100644 --- a/packages/tspd/src/gen-extern-signatures/components/entity-signatures.tsx +++ b/packages/tspd/src/gen-extern-signatures/components/entity-signatures.tsx @@ -90,25 +90,38 @@ export function LocalTypes() { ); } +export interface GenerateSignaturesOptions { + /** Package export subpath the signatures belong to. Defaults to the root(`.`). */ + readonly subpath?: string; + /** Whether to emit the `.ts-test.ts` typecheck file. Defaults to `true`. */ + readonly emitTests?: boolean; +} + export function generateSignatures( program: Program, entities: EntitySignature[], libraryName: string, namespaceName: string, + options?: GenerateSignaturesOptions, ): OutputDirectory { const context = createTspdContext(program); const base = namespaceName === "" ? "__global__" : namespaceName; + const subpath = options?.subpath ?? "."; + const emitTests = options?.emitTests ?? true; const $decoratorsRef = refkey(); const $functionsRef = refkey(); const userLib = ts.createPackage({ name: libraryName, version: "0.0.0", descriptor: { - ".": { + [subpath]: { named: ["$decorators", "$functions"], }, }, }); + // createPackage hoists the root export members but keeps sub exports nested under their subpath key. + const userLibExports: { $decorators: Refkey; $functions: Refkey } = + subpath === "." ? (userLib as any) : (userLib as any)[subpath]; const jsxContent = ( @@ -121,7 +134,7 @@ export function generateSignatures( dollarFunctionsRefKey={$functionsRef} /> - {!base.includes(".Private") && ( + {emitTests && !base.includes(".Private") && ( diff --git a/packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts b/packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts index 93da6ed9b8f..e02ab4b8036 100644 --- a/packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts +++ b/packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts @@ -16,6 +16,7 @@ import { createDiagnosticCollector, createSourceFile, getLocationContext, + getSourceLocation, getTypeName, joinPaths, navigateProgram, @@ -30,6 +31,84 @@ import type { DecoratorSignature, EntitySignature, FunctionSignature } from "./t function createSourceLocation(path: string): SourceLocation { return { file: createSourceFile("", path), pos: 0, end: 0 }; } + +/** The root export subpath. */ +const ROOT_EXPORT = "."; + +/** JS conditions that could resolve to the module exporting `$decorators`/`$functions`. */ +const JS_EXPORT_CONDITIONS = ["import", "default", "types"]; + +/** A `package.json` export entry that defines a TypeSpec entrypoint. */ +export interface TypeSpecExportEntry { + /** Subpath as defined in the `exports` field. (@example `.` or `./streams`) */ + readonly subpath: string; + /** Absolute path to the TypeSpec entrypoint for this subpath. */ + readonly typespecEntrypoint: string; + /** Whether this subpath also resolves to a JS module(where `$decorators` would be exported from). */ + readonly hasJsEntrypoint: boolean; +} + +/** + * Resolve the list of `exports` entries defining a `typespec` condition. + * The root export(`.`) is always first, the remaining ones keep their `package.json` declaration order. + */ +export function resolveTypeSpecExports( + libraryPath: string, + pkgJson: PackageJson, +): TypeSpecExportEntry[] { + const exports = pkgJson.exports; + if (typeof exports !== "object" || exports === null || Array.isArray(exports)) { + return []; + } + + const entries: TypeSpecExportEntry[] = []; + for (const [subpath, value] of Object.entries(exports)) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + continue; + } + if (!("typespec" in value) || typeof value.typespec !== "string") { + continue; + } + entries.push({ + subpath, + typespecEntrypoint: resolvePath(libraryPath, value.typespec), + hasJsEntrypoint: JS_EXPORT_CONDITIONS.some((condition) => condition in value), + }); + } + + return entries.sort((a, b) => { + if (a.subpath === b.subpath) return 0; + if (a.subpath === ROOT_EXPORT) return -1; + if (b.subpath === ROOT_EXPORT) return 1; + return 0; + }); +} + +/** + * Resolve which export owns each source file. + * + * Exports are processed in order and the first one reaching a file claims it. This means a file + * shared between the root and a subpath(the common `import "../main.tsp";` case) is owned by the + * root, and a file shared between 2 sibling subpaths is owned by the first one declared. + * + * @param sourceFilesPerExport Source files reachable from each export, in resolution order. + * @returns For each export, in the same order, the set of source files it owns. + */ +export function resolveSourceFileOwnership( + sourceFilesPerExport: readonly (readonly string[])[], +): Set[] { + const claimed = new Set(); + return sourceFilesPerExport.map((sourceFiles) => { + const owned = new Set(); + for (const sourceFile of sourceFiles) { + if (claimed.has(sourceFile)) continue; + claimed.add(sourceFile); + owned.add(sourceFile); + } + return owned; + }); +} + export async function generateExternSignatures( host: CompilerHost, libraryPath: string, @@ -45,14 +124,7 @@ export async function generateExternSignatures( ]; } - const exportsMap: Record = {}; - for (const [key, value] of Object.entries(pkgJson.exports)) { - if (typeof value === "object" && "typespec" in value && typeof value.typespec === "string") { - exportsMap[key] = resolvePath(libraryPath, value.typespec); - } - } - - const exports = Object.values(exportsMap); + const exports = resolveTypeSpecExports(libraryPath, pkgJson); if (exports.length > 0) { diagnostics.pipe(await generateExternSignatureForExports(host, libraryPath, pkgJson, exports)); } else { @@ -72,27 +144,69 @@ export async function generateExternSignatureForExports( host: CompilerHost, libraryPath: string, pkgJson: PackageJson, - exports: string[], + exports: readonly TypeSpecExportEntry[], ): Promise<[undefined, readonly Diagnostic[]]> { - const [main] = exports; const diagnostics = createDiagnosticCollector(); - const program = await compile(host, main, { - // additionalImports, See: github.com/microsoft/typespec/issues/8913 -- additional imports are disabled pending further design discussion. - parseOptions: { comments: true, docs: true }, - }); const prettierConfig = await prettier.resolveConfig(libraryPath); + const programs: Program[] = []; + for (const entry of exports) { + programs.push( + await compile(host, entry.typespecEntrypoint, { + parseOptions: { comments: true, docs: true }, + }), + ); + } + + // The same file can be resolved under different paths in different programs when it is reached + // through a symlink(e.g. a pnpm workspace link), so compare real paths instead. + const realPaths = new Map(); + for (const program of programs) { + for (const path of program.sourceFiles.keys()) { + if (realPaths.has(path)) continue; + realPaths.set(path, await tryRealpath(host, path)); + } + } + const ownership = resolveSourceFileOwnership( + programs.map((program) => [...program.sourceFiles.keys()].map((x) => realPaths.get(x)!)), + ); + const outDir = resolvePath(libraryPath, "generated-defs"); try { await host.rm(outDir, { recursive: true }); } catch (e) {} - await host.mkdirp(outDir); - const files = await generateExternDecorators(program, pkgJson.name, { - prettierConfig: prettierConfig ?? undefined, - }); - for (const [name, content] of Object.entries(files)) { - await host.writeFile(resolvePath(outDir, name), content); + for (const [index, entry] of exports.entries()) { + const ownedSourceFiles = ownership[index]; + const files = await generateExternDecorators(programs[index], pkgJson.name, { + prettierConfig: prettierConfig ?? undefined, + subpath: entry.subpath, + sourceFilter: (path) => ownedSourceFiles.has(realPaths.get(path) ?? path), + // Without a JS entrypoint there is no module to import `$decorators` from so the typecheck file cannot be generated. + emitTests: entry.hasJsEntrypoint, + }); + + const entries = Object.entries(files); + if (entries.length === 0) { + continue; + } + + if (!entry.hasJsEntrypoint) { + diagnostics.add( + createDiagnostic({ + code: "sub-export-missing-js", + format: { subpath: entry.subpath }, + target: createSourceLocation(resolvePath(libraryPath, "package.json")), + }), + ); + } + + const exportOutDir = + entry.subpath === ROOT_EXPORT ? outDir : resolvePath(outDir, entry.subpath.slice(2)); + await host.mkdirp(exportOutDir); + for (const [name, content] of entries) { + await host.writeFile(resolvePath(exportOutDir, name), content); + } } return [undefined, diagnostics.diagnostics]; } @@ -102,10 +216,27 @@ async function readPackageJson(host: CompilerHost, libraryPath: string): Promise return JSON.parse(file.text); } +async function tryRealpath(host: CompilerHost, path: string): Promise { + try { + return await host.realpath(path); + } catch { + return path; + } +} + export interface GenerateExternDecoratorOptions { /** Render those namespaces only(exclude sub namespaces as well). By default it will include all namespaces. */ readonly namespaces?: Namespace[]; readonly prettierConfig?: prettier.Options; + /** + * Package export subpath the generated signatures belong to. + * Used to resolve where `$decorators`/`$functions` are imported from. Defaults to the root(`.`). + */ + readonly subpath?: string; + /** Only include entities declared in a source file for which this returns true. */ + readonly sourceFilter?: (sourcePath: string) => boolean; + /** Whether to emit the `.ts-test.ts` typecheck files. Defaults to `true`. */ + readonly emitTests?: boolean; } export async function generateExternDecorators( program: Program, @@ -113,12 +244,21 @@ export async function generateExternDecorators( options?: GenerateExternDecoratorOptions, ): Promise> { const entities = new Map(); + + function isIncluded(type: Decorator | FunctionValue): boolean { + if ( + packageName !== "@typespec/compiler" && + getLocationContext(program, type).type !== "project" + ) + return false; + if (options?.sourceFilter === undefined) return true; + const sourcePath = getSourceLocation(type, { locateId: true })?.file.path; + return sourcePath !== undefined && options.sourceFilter(sourcePath); + } + const listener: SemanticNodeListener = { decorator(dec) { - if ( - packageName !== "@typespec/compiler" && - getLocationContext(program, dec).type !== "project" - ) { + if (!isIncluded(dec)) { return; } const namespaceName = getTypeName(dec.namespace); @@ -130,11 +270,7 @@ export async function generateExternDecorators( entitiesForNamespace.push(resolveDecoratorSignature(dec)); }, function(func) { - if ( - (packageName !== "@typespec/compiler" && - getLocationContext(program, func).type !== "project") || - func.namespace === undefined - ) { + if (!isIncluded(func) || func.namespace === undefined) { return; } const namespaceName = getTypeName(func.namespace); @@ -174,7 +310,10 @@ export async function generateExternDecorators( const files: Record = {}; for (const [ns, nsEntities] of entities.entries()) { - const output = generateSignatures(program, nsEntities, packageName, ns); + const output = generateSignatures(program, nsEntities, packageName, ns, { + subpath: options?.subpath, + emitTests: options?.emitTests, + }); const rawFiles: OutputFile[] = []; await traverseOutput(output, { visitDirectory: () => {}, diff --git a/packages/tspd/src/ref-doc/lib.ts b/packages/tspd/src/ref-doc/lib.ts index 2bab7b16011..dab6aa958ee 100644 --- a/packages/tspd/src/ref-doc/lib.ts +++ b/packages/tspd/src/ref-doc/lib.ts @@ -10,6 +10,12 @@ export const libDef = { missingCondition: `exports field is missing one export with the typespec condition`, }, }, + "sub-export-missing-js": { + severity: "warning", + messages: { + default: paramMessage`Sub export "${"subpath"}" declares decorators or functions but has no JS export condition("import" or "default") in package.json. Skipping generation of the signature typecheck file.`, + }, + }, "documentation-missing": { severity: "warning", messages: { diff --git a/packages/tspd/test/gen-extern-signature/subpath-exports.test.ts b/packages/tspd/test/gen-extern-signature/subpath-exports.test.ts new file mode 100644 index 00000000000..b7740b22f86 --- /dev/null +++ b/packages/tspd/test/gen-extern-signature/subpath-exports.test.ts @@ -0,0 +1,215 @@ +import { createTestHost, resolveVirtualPath } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + generateExternSignatures, + resolveSourceFileOwnership, + resolveTypeSpecExports, +} from "../../src/gen-extern-signatures/gen-extern-signatures.js"; + +describe("resolveTypeSpecExports", () => { + it("only keeps exports with a typespec condition and puts the root first", () => { + const result = resolveTypeSpecExports("/lib", { + name: "test-lib", + exports: { + "./streams": { typespec: "./lib/streams/main.tsp", default: "./dist/src/streams/index.js" }, + "./testing": { default: "./dist/src/testing/index.js" }, + ".": { typespec: "./lib/main.tsp", default: "./dist/src/index.js" }, + }, + } as any); + + expect(result).toEqual([ + { + subpath: ".", + typespecEntrypoint: "/lib/lib/main.tsp", + hasJsEntrypoint: true, + }, + { + subpath: "./streams", + typespecEntrypoint: "/lib/lib/streams/main.tsp", + hasJsEntrypoint: true, + }, + ]); + }); + + it("marks exports without a js condition", () => { + const result = resolveTypeSpecExports("/lib", { + name: "test-lib", + exports: { + ".": { typespec: "./lib/main.tsp" }, + }, + } as any); + + expect(result[0].hasJsEntrypoint).toBe(false); + }); +}); + +describe("resolveSourceFileOwnership", () => { + it("attributes a file shared with the root to the root", () => { + const [root, sub] = resolveSourceFileOwnership([ + ["main.tsp", "decorators.tsp"], + ["streams.tsp", "main.tsp", "decorators.tsp"], + ]); + + expect([...root]).toEqual(["main.tsp", "decorators.tsp"]); + expect([...sub]).toEqual(["streams.tsp"]); + }); + + it("attributes a file shared between sibling exports to the first one declared", () => { + const [a, b] = resolveSourceFileOwnership([ + ["a.tsp", "shared.tsp"], + ["b.tsp", "shared.tsp"], + ]); + + expect([...a]).toEqual(["a.tsp", "shared.tsp"]); + expect([...b]).toEqual(["b.tsp"]); + }); +}); + +describe("generateExternSignatures with sub exports", () => { + let host: Awaited>; + + beforeEach(async () => { + host = await createTestHost(); + }); + + function addPackageJson(exports: Record>) { + host.addTypeSpecFile("package.json", JSON.stringify({ name: "test-lib", exports })); + } + + async function generate() { + const diagnostics = await generateExternSignatures(host.compilerHost, resolveVirtualPath(".")); + const files: Record = {}; + const prefix = resolveVirtualPath("generated-defs") + "/"; + for (const [path, content] of host.fs.entries()) { + if (path.startsWith(prefix)) { + files[path.slice(prefix.length)] = content; + } + } + return { files, diagnostics }; + } + + it("generates sub export signatures in a directory matching the subpath", async () => { + addPackageJson({ + ".": { typespec: "./main.tsp", default: "./dist/index.js" }, + "./streams": { typespec: "./streams/main.tsp", default: "./dist/streams/index.js" }, + }); + host.addTypeSpecFile( + "main.tsp", + ` + namespace TestLib; + extern dec rootDec(target: unknown); + `, + ); + host.addTypeSpecFile( + "streams/main.tsp", + ` + import "../main.tsp"; + namespace TestLib.Streams; + extern dec streamDec(target: unknown); + `, + ); + + const { files } = await generate(); + + expect(Object.keys(files).sort()).toEqual([ + "TestLib.ts", + "TestLib.ts-test.ts", + "streams/TestLib.Streams.ts", + "streams/TestLib.Streams.ts-test.ts", + ]); + }); + + it("imports $decorators from the matching package sub export", async () => { + addPackageJson({ + ".": { typespec: "./main.tsp", default: "./dist/index.js" }, + "./streams": { typespec: "./streams/main.tsp", default: "./dist/streams/index.js" }, + }); + host.addTypeSpecFile("main.tsp", `namespace TestLib;`); + host.addTypeSpecFile( + "streams/main.tsp", + ` + import "../main.tsp"; + namespace TestLib.Streams; + extern dec streamDec(target: unknown); + `, + ); + + const { files } = await generate(); + + expect(files["streams/TestLib.Streams.ts-test.ts"]).toContain( + `import { $decorators } from "test-lib/streams";`, + ); + }); + + it("keeps decorators declared in files shared with the root on the root export", async () => { + addPackageJson({ + ".": { typespec: "./main.tsp", default: "./dist/index.js" }, + "./streams": { typespec: "./streams/main.tsp", default: "./dist/streams/index.js" }, + }); + host.addTypeSpecFile( + "main.tsp", + ` + namespace TestLib; + extern dec rootDec(target: unknown); + `, + ); + host.addTypeSpecFile("streams/main.tsp", `import "../main.tsp";`); + + const { files } = await generate(); + + expect(Object.keys(files).sort()).toEqual(["TestLib.ts", "TestLib.ts-test.ts"]); + expect(files["TestLib.ts-test.ts"]).toContain(`import { $decorators } from "test-lib";`); + }); + + it("warns and skips the typecheck file when a sub export has no js condition", async () => { + addPackageJson({ + ".": { typespec: "./main.tsp", default: "./dist/index.js" }, + "./streams": { typespec: "./streams/main.tsp" }, + }); + host.addTypeSpecFile("main.tsp", `namespace TestLib;`); + host.addTypeSpecFile( + "streams/main.tsp", + ` + import "../main.tsp"; + namespace TestLib.Streams; + extern dec streamDec(target: unknown); + `, + ); + + const { files, diagnostics } = await generate(); + + expect(Object.keys(files)).toEqual(["streams/TestLib.Streams.ts"]); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].code).toBe("@typespec/tspd/sub-export-missing-js"); + expect(diagnostics[0].severity).toBe("warning"); + }); + + it("resolves symlinked source files to the same file", async () => { + addPackageJson({ + ".": { typespec: "./main.tsp", default: "./dist/index.js" }, + "./streams": { typespec: "./streams/main.tsp", default: "./dist/streams/index.js" }, + }); + const rootContent = ` + namespace TestLib; + extern dec rootDec(target: unknown); + `; + host.addTypeSpecFile("main.tsp", rootContent); + // Same file reachable under a different path, as if it went through a symlink. + host.addTypeSpecFile("linked/main.tsp", rootContent); + host.addTypeSpecFile("streams/main.tsp", `import "../linked/main.tsp";`); + + const linked = resolveVirtualPath("linked/main.tsp"); + const compilerHost = { + ...host.compilerHost, + realpath: async (path: string) => (path === linked ? resolveVirtualPath("main.tsp") : path), + }; + await generateExternSignatures(compilerHost, resolveVirtualPath(".")); + + const prefix = resolveVirtualPath("generated-defs") + "/"; + const generated = [...host.fs.keys()] + .filter((x) => x.startsWith(prefix)) + .map((x) => x.slice(prefix.length)) + .sort(); + expect(generated).toEqual(["TestLib.ts", "TestLib.ts-test.ts"]); + }); +});