Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .chronus/changes/tspd-subpath-signatures-2026-7-5-0-0-0.md
Original file line number Diff line number Diff line change
@@ -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"];
```
15 changes: 15 additions & 0 deletions packages/tspd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Namespace>.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 = (
<TspdContext.Provider value={context}>
Expand All @@ -121,17 +134,17 @@ export function generateSignatures(
dollarFunctionsRefKey={$functionsRef}
/>
</ts.SourceFile>
{!base.includes(".Private") && (
{emitTests && !base.includes(".Private") && (
<ts.SourceFile
path={`${base}.ts-test.ts`}
headerComment="An error in the imports would mean that the decorator is not exported or doesn't have the right name."
>
<EntitySignatureTests
namespaceName={namespaceName}
entities={entities}
dollarDecoratorRefKey={userLib.$decorators}
dollarDecoratorRefKey={userLibExports.$decorators}
dollarDecoratorsTypeRefKey={$decoratorsRef}
dollarFunctionsRefKey={userLib.$functions}
dollarFunctionsRefKey={userLibExports.$functions}
dollarFunctionsTypeRefKey={$functionsRef}
/>
</ts.SourceFile>
Expand Down
199 changes: 169 additions & 30 deletions packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
createDiagnosticCollector,
createSourceFile,
getLocationContext,
getSourceLocation,
getTypeName,
joinPaths,
navigateProgram,
Expand All @@ -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<string>[] {
const claimed = new Set<string>();
return sourceFilesPerExport.map((sourceFiles) => {
const owned = new Set<string>();
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,
Expand All @@ -45,14 +124,7 @@ export async function generateExternSignatures(
];
}

const exportsMap: Record<string, string> = {};
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 {
Expand All @@ -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<string, string>();
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];
}
Expand All @@ -102,23 +216,49 @@ async function readPackageJson(host: CompilerHost, libraryPath: string): Promise
return JSON.parse(file.text);
}

async function tryRealpath(host: CompilerHost, path: string): Promise<string> {
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 `<Namespace>.ts-test.ts` typecheck files. Defaults to `true`. */
readonly emitTests?: boolean;
}
export async function generateExternDecorators(
program: Program,
packageName: string,
options?: GenerateExternDecoratorOptions,
): Promise<Record<string, string>> {
const entities = new Map<string, EntitySignature[]>();

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);
Expand All @@ -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);
Expand Down Expand Up @@ -174,7 +310,10 @@ export async function generateExternDecorators(

const files: Record<string, string> = {};
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: () => {},
Expand Down
6 changes: 6 additions & 0 deletions packages/tspd/src/ref-doc/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading
Loading