From c8a524c368ace54c88a4f9720ffad486e61f75f6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 19:59:04 -0500 Subject: [PATCH 1/4] Keep the .d.ts-only types of a package compiled from its JavaScript --- .../frontend/npm-static-type-exports-host.ts | 164 +++++++++++++++ .../frontend/npm-static-type-exports.test.ts | 76 +++++++ .../src/frontend/npm-static-type-exports.ts | 188 ++++++++++++++++++ packages/compiler/src/frontend/npm-static.ts | 28 +++ packages/compiler/src/frontend/program.ts | 6 + .../7001-package-declared-types/main.ts.json | 6 + .../7001-package-declared-types/main.ts | 19 ++ .../node_modules/issue-list/lib/index.d.ts | 17 ++ .../node_modules/issue-list/lib/index.js | 24 +++ .../node_modules/issue-list/lib/paging.d.ts | 4 + .../node_modules/issue-list/lib/paging.js | 2 + .../node_modules/issue-list/package.json | 1 + .../7001-package-declared-types/main.ts.json | 1 + 13 files changed, 536 insertions(+) create mode 100644 packages/compiler/src/frontend/npm-static-type-exports-host.ts create mode 100644 packages/compiler/src/frontend/npm-static-type-exports.test.ts create mode 100644 packages/compiler/src/frontend/npm-static-type-exports.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7001-package-declared-types/main.ts.json create mode 100644 tests/corpus/7001-package-declared-types/main.ts create mode 100644 tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.d.ts create mode 100644 tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.js create mode 100644 tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.d.ts create mode 100644 tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.js create mode 100644 tests/corpus/7001-package-declared-types/node_modules/issue-list/package.json create mode 100644 tests/harness/corpus-costs/7001-package-declared-types/main.ts.json diff --git a/packages/compiler/src/frontend/npm-static-type-exports-host.ts b/packages/compiler/src/frontend/npm-static-type-exports-host.ts new file mode 100644 index 000000000..809464763 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-type-exports-host.ts @@ -0,0 +1,164 @@ +/** Filesystem side of npm-static-type-exports.ts: finds each opted-in + * package's declaration entry and runtime entry, copies the declaration + * files the entry reaches, and builds the runtime entry's typedefs. */ +import { dirname, relative, resolve as resolvePath } from "node:path"; +import { trackedFileExists, trackedReadFile } from "./input-tracker.js"; +import { resolveBareModule } from "./resolve.js"; +import { isJsSourceFileName } from "./tsc-codes.js"; +import type { FrontendServices } from "./services.js"; +import * as ts from "./ts7/syntax.js"; +import { + NPM_STATIC_TYPES_SUFFIX, + npmStaticDeclarationCopy, + npmStaticDeclarationExports, + npmStaticDeclarationSpecifiers, + npmStaticTypedefBlock, + type NpmStaticDeclarationExports, + type NpmStaticTypeDeclaration, +} from "./npm-static-type-exports.js"; + +export interface NpmStaticTypeExportPlan { + /** Copies of the declaration files, by copy path. */ + copies: Map; + /** The typedef block to append, by runtime entry (forward slashes). */ + typedefs: Map; +} + +/** The copy path of a declaration file. */ +function copyPathOf(declarationFile: string): string { + return declarationFile.replace(/\.d\.ts$/, NPM_STATIC_TYPES_SUFFIX); +} + +/** The declaration file a relative specifier names from `fromFile`, or null. */ +function declarationTargetOf(fromFile: string, specifier: string): string | null { + const base = resolvePath(dirname(fromFile), specifier); + const candidates = [ + `${base}.d.ts`, + `${base.replace(/\.(c|m)?js$/, "")}.d.ts`, + resolvePath(base, "index.d.ts"), + ]; + for (const candidate of candidates) if (trackedFileExists(candidate)) return candidate; + return null; +} + +/** A specifier for `target` from `fromFile`'s directory, without `.d.ts`. */ +function specifierFor(fromFile: string, target: string): string { + const rel = relative(dirname(fromFile), target) + .split("\\") + .join("/") + .replace(/\.d\.ts$/, ""); + return rel.startsWith(".") ? rel : `./${rel}`; +} + +/** The names the runtime JavaScript declares as types itself: classes, + * JSDoc typedefs and callbacks, and `@enum` constants. */ +function runtimeTypeNames(services: FrontendServices, file: string, text: string): Set { + const names = new Set(); + for (const m of text.matchAll(/@(?:typedef|callback)\s+(?:\{[^}]*\}\s*)?([A-Za-z_$][\w$]*)/g)) + names.add(m[1]!); + // `/** @enum {number} */ const Kind = { ... }`: the constant names a type too. + for (const m of text.matchAll( + /@enum\b[^\n]*\n(?:\s*\*[^\n]*\n)*?\s*(?:\*\/\s*)?(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g, + )) + names.add(m[1]!); + const sf = services.parse(file, text, "js"); + for (const stmt of sf.statements.nodes) + if (ts.isClassDeclaration(stmt) && stmt.name !== undefined) names.add(stmt.name.text); + return names; +} + +export function npmStaticTypeExportPlan( + services: FrontendServices, + entryPath: string, + packages: readonly string[], +): NpmStaticTypeExportPlan { + const copies = new Map(); + const typedefs = new Map(); + const parsed = new Map(); + const parse = (file: string): ts.SourceFile | null => { + let sf = parsed.get(file); + if (sf === undefined) { + const text = trackedReadFile(file); + sf = text === null ? null : services.parse(file, text, "ts"); + parsed.set(file, sf); + } + return sf; + }; + // Copies every declaration file reachable from `file` through relative + // specifiers, each specifier respelled to its target's copy. + const copy = (file: string): void => { + const copyPath = copyPathOf(file); + if (copies.has(copyPath)) return; + const sf = parse(file); + if (sf === null) return; + copies.set(copyPath, ""); + const targets = new Map(); + for (const specifier of npmStaticDeclarationSpecifiers(sf)) { + const target = declarationTargetOf(file, specifier); + if (target !== null) targets.set(specifier, target); + } + copies.set( + copyPath, + npmStaticDeclarationCopy(sf, (specifier) => { + const target = targets.get(specifier); + return target === undefined ? null : specifierFor(copyPath, copyPathOf(target)); + }), + ); + for (const target of targets.values()) copy(target); + }; + // The exported type-only declarations of `file`, through its re-exports. + const typesOf = ( + file: string, + seen: Set, + ): { types: Map; values: Set } => { + const result = { + types: new Map(), + values: new Set(), + }; + if (seen.has(file)) return result; + seen.add(file); + const sf = parse(file); + if (sf === null) return result; + const own: NpmStaticDeclarationExports = npmStaticDeclarationExports(sf); + for (const t of own.types) result.types.set(t.name, t); + for (const v of own.values) result.values.add(v); + for (const n of own.named) { + const target = n.specifier === null ? null : declarationTargetOf(file, n.specifier); + if (target === null) continue; + const inner = typesOf(target, new Set(seen)); + const t = inner.types.get(n.local); + if (t !== undefined) result.types.set(n.exported, { ...t, name: n.exported }); + else if (inner.values.has(n.local)) result.values.add(n.exported); + } + for (const specifier of own.stars) { + const target = declarationTargetOf(file, specifier); + if (target === null) continue; + const inner = typesOf(target, new Set(seen)); + for (const [name, t] of inner.types) if (!result.types.has(name)) result.types.set(name, t); + for (const v of inner.values) result.values.add(v); + } + return result; + }; + for (const pkg of packages) { + const declared = resolveBareModule(entryPath, pkg, "types-only"); + if (declared === null || declared.packageName !== pkg || !declared.typesFile.endsWith(".d.ts")) + continue; + const runtime = resolveBareModule(entryPath, pkg, "js-only"); + if (runtime === null || !isJsSourceFileName(runtime.typesFile)) continue; + const runtimeText = trackedReadFile(runtime.typesFile); + if (runtimeText === null) continue; + const { types, values } = typesOf(declared.typesFile, new Set()); + const declaredByRuntime = runtimeTypeNames(services, runtime.typesFile, runtimeText); + const exported = [...types.values()].filter( + (t) => !values.has(t.name) && !declaredByRuntime.has(t.name), + ); + if (exported.length === 0) continue; + copy(declared.typesFile); + const specifier = specifierFor(runtime.typesFile, copyPathOf(declared.typesFile)); + typedefs.set( + runtime.typesFile.split("\\").join("/"), + npmStaticTypedefBlock(exported, specifier), + ); + } + return { copies, typedefs }; +} diff --git a/packages/compiler/src/frontend/npm-static-type-exports.test.ts b/packages/compiler/src/frontend/npm-static-type-exports.test.ts new file mode 100644 index 000000000..fa95c0fe0 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-type-exports.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "vitest"; +import { nodeFrontendServices } from "./services-node.js"; +import { + npmStaticDeclarationCopy, + npmStaticDeclarationExports, + npmStaticTypedefBlock, +} from "./npm-static-type-exports.js"; + +function parse(text: string) { + return nodeFrontendServices().parse("/pkg/lib/index.d.ts", text, "ts"); +} + +test("type-only exports are told from values and re-exports", () => { + const exports = npmStaticDeclarationExports( + parse(`import type { Page } from "./paging"; +export * from "./paging"; +export { Entry as Item, Kind } from "./entries"; +export interface Options { value: T; label: U } +export type Mode = "a" | "b"; +export declare const enum Level { Low = 1 } +export declare class Client { url: string } +export declare function make(options: Options): Client; +export declare const version: string; +interface Hidden { x: number } +export { Hidden as Visible }; +declare function helper(): void; +export { helper };`), + ); + expect(exports.types.map((t) => [t.name, t.params, t.templates])).toEqual([ + ["Options", ["T", "U"], ["{object} [T={}]", "[U=string]"]], + ["Mode", [], []], + ["Level", [], []], + ["Visible", [], []], + ]); + expect(exports.values).toEqual(["Client", "make", "version", "helper"]); + expect(exports.named).toEqual([ + { exported: "Item", local: "Entry", specifier: "./entries" }, + { exported: "Kind", local: "Kind", specifier: "./entries" }, + ]); + expect(exports.stars).toEqual(["./paging"]); +}); + +test("a copy respells the relative specifiers it is told", () => { + const sf = parse(`import type { Page } from "./paging"; +export * from './entries'; +export type Box = import("./paging").Page; +export { readFile } from "fs"; +`); + const copy = npmStaticDeclarationCopy(sf, (s) => + s === "./paging" ? "./paging.scriptc-types" : null, + ); + expect(copy).toBe(`import type { Page } from "./paging.scriptc-types"; +export * from './entries'; +export type Box = import("./paging.scriptc-types").Page; +export { readFile } from "fs"; +`); +}); + +test("the typedef block re-exports each type with its parameters", () => { + expect( + npmStaticTypedefBlock( + [ + { name: "Mode", params: [], templates: [] }, + { name: "Options", params: ["T", "U"], templates: ["{object} [T={}]", "[U=string]"] }, + ], + "./index.scriptc-types", + ), + ).toBe(`/** + * @typedef {import("./index.scriptc-types").Mode} Mode + */ +/** + * @template {object} [T={}] + * @template [U=string] + * @typedef {import("./index.scriptc-types").Options} Options + */`); +}); diff --git a/packages/compiler/src/frontend/npm-static-type-exports.ts b/packages/compiler/src/frontend/npm-static-type-exports.ts new file mode 100644 index 000000000..844afa590 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-type-exports.ts @@ -0,0 +1,188 @@ +/* The type-only exports of an opted-in package's declaration files. + * + * An opted-in package compiles from its JavaScript, and the checker types + * the package from it: the package's shipped .d.ts is hidden from + * resolution (npmStaticFsShadow). Values keep their JavaScript types, but a + * type that exists only in the .d.ts (an interface, a type alias, an enum + * used as a type) has no declaration in the JavaScript, so a program's + * `import type { Options } from "pkg"` resolved to `any`. TypeScript and + * Node see the .d.ts types. + * + * The package's declaration files stay available as copies under another + * name (`.scriptc-types.d.ts`, relative specifiers respelled to the + * copies), and the runtime entry gains a JSDoc typedef per type-only export + * that names the declaration in the copy: + * + * / ** @template T @typedef {import("./main.scriptc-types").Box} Box * / + * + * Classes and values keep the JavaScript's own declarations. */ +import * as ts from "./ts7/syntax.js"; +import { tokenStartOfNode } from "./ts7/node-functions.js"; + +/** The suffix that replaces `.d.ts` in a copy's name. */ +export const NPM_STATIC_TYPES_SUFFIX = ".scriptc-types.d.ts"; + +/** A type-only declaration: its name, its type parameters' names and their + * JSDoc @template tag bodies. */ +export interface NpmStaticTypeDeclaration { + name: string; + params: readonly string[]; + templates: readonly string[]; +} + +/** What a declaration file declares and re-exports, by exported name. */ +export interface NpmStaticDeclarationExports { + /** Exported interfaces, type aliases and enums, with their parameters. */ + types: readonly NpmStaticTypeDeclaration[]; + /** Exported classes, functions, variables and namespaces: names the + * JavaScript declares itself, or that are not type-only. */ + values: readonly string[]; + /** `export { local as exported } from "specifier"` and local + * `export { a, b }` (specifier null). */ + named: readonly { exported: string; local: string; specifier: string | null }[]; + /** `export * from "specifier"`. */ + stars: readonly string[]; +} + +function isExported(node: ts.Node): boolean { + const modifiers = (node as { modifiers?: { nodes: readonly ts.Node[] } }).modifiers?.nodes ?? []; + return modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword); +} + +function templatesOf(sf: ts.SourceFile, params: readonly ts.TypeParameterDeclaration[]): string[] { + const text = (n: ts.Node): string => sf.text.slice(tokenStartOfNode(n), n.end); + return params.map((p) => { + const name = p.name.text; + const constraint = p.constraint === undefined ? "" : `{${text(p.constraint)}} `; + return p.defaultType === undefined + ? `${constraint}${name}` + : `${constraint}[${name}=${text(p.defaultType)}]`; + }); +} + +/** The exports of one declaration file. */ +export function npmStaticDeclarationExports(sf: ts.SourceFile): NpmStaticDeclarationExports { + const types: NpmStaticTypeDeclaration[] = []; + const values: string[] = []; + const named: { exported: string; local: string; specifier: string | null }[] = []; + const stars: string[] = []; + const localTypes = new Map(); + const localValues = new Set(); + for (const stmt of sf.statements.nodes) { + const exported = isExported(stmt); + if (ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) { + const params = stmt.typeParameters?.nodes ?? []; + const decl = { + name: stmt.name.text, + params: params.map((p) => p.name.text), + templates: templatesOf(sf, params), + }; + localTypes.set(decl.name, decl); + if (exported) types.push(decl); + } else if (ts.isEnumDeclaration(stmt)) { + const decl = { name: stmt.name.text, params: [], templates: [] }; + localTypes.set(decl.name, decl); + if (exported) types.push(decl); + } else if ( + ts.isClassDeclaration(stmt) || + ts.isFunctionDeclaration(stmt) || + ts.isModuleDeclaration(stmt) + ) { + const name = stmt.name !== undefined && ts.isIdentifier(stmt.name) ? stmt.name.text : null; + if (name !== null) { + localValues.add(name); + if (exported) values.push(name); + } + } else if (ts.isVariableStatement(stmt)) { + for (const d of stmt.declarationList.declarations.nodes) { + if (!ts.isIdentifier(d.name)) continue; + localValues.add(d.name.text); + if (exported) values.push(d.name.text); + } + } else if (ts.isExportDeclaration(stmt)) { + const specifier = + stmt.moduleSpecifier !== undefined && ts.isStringLiteral(stmt.moduleSpecifier) + ? stmt.moduleSpecifier.text + : null; + const clause = stmt.exportClause; + if (clause === undefined) { + if (specifier !== null) stars.push(specifier); + } else if (ts.isNamedExports(clause)) { + for (const e of clause.elements.nodes) { + const exportedName = e.name.text; + const local = (e.propertyName ?? e.name).text; + named.push({ exported: exportedName, local, specifier }); + } + } + } + } + // Local `export { a as b }` of the file's own declarations. + const resolvedNamed: typeof named = []; + for (const n of named) { + if (n.specifier !== null) { + resolvedNamed.push(n); + continue; + } + const local = localTypes.get(n.local); + if (local !== undefined) types.push({ ...local, name: n.exported }); + else if (localValues.has(n.local)) values.push(n.exported); + } + return { types, values, named: resolvedNamed, stars }; +} + +/** The relative module specifiers of a declaration file's imports, exports + * and `import("...")` types, with their spans. */ +function relativeSpecifiers(sf: ts.SourceFile): { start: number; end: number; text: string }[] { + const found: { start: number; end: number; text: string }[] = []; + const add = (literal: ts.Node | undefined): void => { + if (literal === undefined || !ts.isStringLiteral(literal)) return; + if (!literal.text.startsWith("./") && !literal.text.startsWith("../")) return; + found.push({ start: tokenStartOfNode(literal), end: literal.end, text: literal.text }); + }; + ts.walkPreorder(sf, (n) => { + if (ts.isImportDeclaration(n) || ts.isExportDeclaration(n)) add(n.moduleSpecifier); + else if (ts.isImportTypeNode(n) && ts.isLiteralTypeNode(n.argument)) add(n.argument.literal); + return undefined; + }); + return found; +} + +/** The relative specifiers a declaration file names. */ +export function npmStaticDeclarationSpecifiers(sf: ts.SourceFile): string[] { + return relativeSpecifiers(sf).map((s) => s.text); +} + +/** The declaration file's text with each relative specifier `rename` + * answers respelled (quoted with the original's quote). */ +export function npmStaticDeclarationCopy( + sf: ts.SourceFile, + rename: (specifier: string) => string | null, +): string { + const spans = relativeSpecifiers(sf).sort((a, b) => a.start - b.start); + let text = ""; + let at = 0; + for (const span of spans) { + const renamed = rename(span.text); + if (renamed === null) continue; + const quote = sf.text[span.start] ?? '"'; + text += sf.text.slice(at, span.start) + quote + renamed + quote; + at = span.end; + } + return text + sf.text.slice(at); +} + +/** The JSDoc typedefs that re-export `types` from the copy at `specifier`. */ +export function npmStaticTypedefBlock( + types: readonly NpmStaticTypeDeclaration[], + specifier: string, +): string { + const lines: string[] = []; + for (const t of types) { + const args = t.params.length === 0 ? "" : `<${t.params.join(", ")}>`; + lines.push("/**"); + for (const tag of t.templates) lines.push(` * @template ${tag}`); + lines.push(` * @typedef {import(${JSON.stringify(specifier)}).${t.name}${args}} ${t.name}`); + lines.push(" */"); + } + return lines.join("\n"); +} diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index 56edafc48..33ff54cfd 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -16,12 +16,14 @@ import { } from "./workspace-registry.js"; import { clearVirtualSources, + isVirtualSource, setVirtualSource, trackedExists, trackedReadFile, trackedRealpath, } from "./input-tracker.js"; import { npmBuiltinAliasOf } from "./builtin-modules.js"; +import { NPM_STATIC_TYPES_SUFFIX } from "./npm-static-type-exports.js"; let activePackages: ReadonlySet = new Set(); let declarationOverloads: ReadonlyMap = new Map(); @@ -53,10 +55,27 @@ export function npmStaticLexerVisibleNames(path: string): readonly string[] { return lexerNames.get(path.split("\\").join("/")) ?? []; } +/** Per runtime entry (forward slashes), the JSDoc typedefs that re-export + * its package's type-only declarations (npm-static-type-exports.ts). */ +let typeExportTypedefs = new Map(); + +/** Registers the copies of the opted-in packages' declaration files and the + * typedefs their runtime entries gain (after setNpmStaticPackages, which + * clears virtual sources). */ +export function setNpmStaticTypeExports( + copies: ReadonlyMap, + typedefs: ReadonlyMap, +): void { + for (const [path, text] of copies) setVirtualSource(path, text); + typeExportTypedefs = new Map(typedefs); + rewriteCache.clear(); +} + export function setNpmStaticPackages>(packages: T): void { // npm packages served as builtins (graceful-fs as fs) never compile // their JavaScript, even when named. activePackages = new Set([...packages].filter((name) => npmBuiltinAliasOf(name) === null)); + typeExportTypedefs = new Map(); declarationOverloads = new Map(); declarationProperties = new Map(); offenders.clear(); @@ -524,12 +543,21 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow } catch { rewritten = null; // unreadable/unparseable: fall through untouched } + // The runtime entry re-exports the type-only declarations of the + // package's .d.ts, after every respelling, so positions stay. + const typedefs = typeExportTypedefs.get(path.split("\\").join("/")); + if (typedefs !== undefined) { + const text = rewritten ?? trackedReadFile(path); + if (text !== null) rewritten = `${text}\n${typedefs}\n`; + } rewriteCache.set(path, rewritten); return rewritten ?? undefined; } return undefined; }, hideFile: (path) => { + // The copies of the package's declaration files the typedefs name. + if (path.endsWith(NPM_STATIC_TYPES_SUFFIX) && isVirtualSource(path)) return false; const target = shadowTargetOf(path); if (!target) return false; if (isBundledShimForwarder(path)) return false; diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index db43b7749..e398e59df 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -76,8 +76,10 @@ import { reportNpmStaticOffender, setNpmStaticDeclarationOverloads, setNpmStaticPackages, + setNpmStaticTypeExports, } from "./npm-static.js"; import { isPrunedNpmReexport, planNpmStaticReexports } from "./npm-static-prune.js"; +import { npmStaticTypeExportPlan } from "./npm-static-type-exports-host.js"; import { isNpmStaticSubclassArgument } from "./npm-static-subtyping.js"; import { npmStaticDeclarationReexports, @@ -1032,6 +1034,10 @@ export function loadProgram( } setNpmStaticPackages(npmStaticPackages); setNpmStaticDeclarationOverloads(declarationOverloads, declarationProperties); + // Types only the packages' .d.ts declares keep their declarations + // (npm-static-type-exports.ts). + const typeExports = npmStaticTypeExportPlan(services, entryPath, npmStaticPackages); + setNpmStaticTypeExports(typeExports.copies, typeExports.typedefs); // Workspace-package registrations reset per load (same discipline as the // npm-static set), then the opted-in names are probed UP FRONT: a // workspace-linked opt-in resolves to files whose realpaths carry no diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7001-package-declared-types/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7001-package-declared-types/main.ts.json new file mode 100644 index 000000000..c5aa8bb42 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7001-package-declared-types/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7001-package-declared-types/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7001-package-declared-types/main.ts b/tests/corpus/7001-package-declared-types/main.ts new file mode 100644 index 000000000..c5194d33d --- /dev/null +++ b/tests/corpus/7001-package-declared-types/main.ts @@ -0,0 +1,19 @@ +// A package that compiles from its JavaScript, whose interfaces, type +// aliases and enums exist only in its .d.ts. A program that imports its +// values compiles the JavaScript, and the type-only names it imports keep +// the declarations TypeScript reads from the .d.ts. +import { Severity, collect, firstOf, label, type Issue, type Page, type Report } from "issue-list"; + +function report(text: string): Report { + const issues: Issue[] = []; + const count = collect(text, issues); + return { count, issues }; +} + +const result = report("a?b!c?"); +console.log(result.count, result.issues.length); +const first = result.issues[0]!; +console.log(label(first.severity), first.offset, first.severity === Severity.Warning); +const page: Page = { items: result.issues.slice(1), next: null }; +console.log(page.items.map((issue) => `${label(issue.severity)}@${issue.offset}`).join(","), page.next); +console.log(firstOf(page)?.offset); diff --git a/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.d.ts b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.d.ts new file mode 100644 index 000000000..5ba9d04ac --- /dev/null +++ b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.d.ts @@ -0,0 +1,17 @@ +import type { Page } from "./paging"; +export * from "./paging"; +export declare enum Severity { + Warning = 1, + Error = 2 +} +export interface Issue { + severity: Severity; + offset: number; +} +export type Report = { + count: number; + issues: Issue[]; +}; +export declare function collect(text: string, issues: Issue[]): number; +export declare function label(severity: Severity): string; +export declare function firstOf(page: Page): T | undefined; diff --git a/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.js b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.js new file mode 100644 index 000000000..516d2fa30 --- /dev/null +++ b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.js @@ -0,0 +1,24 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Severity = void 0; +exports.collect = collect; +exports.label = label; +exports.firstOf = firstOf; +var Severity; +(function (Severity) { + Severity[Severity["Warning"] = 1] = "Warning"; + Severity[Severity["Error"] = 2] = "Error"; +})(Severity || (exports.Severity = Severity = {})); +function collect(text, issues) { + for (let i = 0; i < text.length; i++) { + if (text[i] === "?") issues.push({ severity: Severity.Warning, offset: i }); + else if (text[i] === "!") issues.push({ severity: Severity.Error, offset: i }); + } + return issues.length; +} +function label(severity) { + return severity === Severity.Warning ? "warning" : "error"; +} +function firstOf(page) { + return page.items[0]; +} diff --git a/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.d.ts b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.d.ts new file mode 100644 index 000000000..c427d5ce0 --- /dev/null +++ b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.d.ts @@ -0,0 +1,4 @@ +export interface Page { + items: T[]; + next: string | null; +} diff --git a/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.js b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.js new file mode 100644 index 000000000..c8ad2e549 --- /dev/null +++ b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/tests/corpus/7001-package-declared-types/node_modules/issue-list/package.json b/tests/corpus/7001-package-declared-types/node_modules/issue-list/package.json new file mode 100644 index 000000000..dcb8f20c3 --- /dev/null +++ b/tests/corpus/7001-package-declared-types/node_modules/issue-list/package.json @@ -0,0 +1 @@ +{ "name": "issue-list", "version": "1.0.0", "main": "./lib/index.js", "types": "./lib/index.d.ts" } diff --git a/tests/harness/corpus-costs/7001-package-declared-types/main.ts.json b/tests/harness/corpus-costs/7001-package-declared-types/main.ts.json new file mode 100644 index 000000000..d34ce5c69 --- /dev/null +++ b/tests/harness/corpus-costs/7001-package-declared-types/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2683, "san": 1176 } From 462f93d50802826e771341ec81d101b43c45bc1d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 20:42:16 -0500 Subject: [PATCH 2/4] Type the enum entry of a package's declaration exports explicitly The compiler compiles itself natively, and an empty array in an object literal took number[] there, so the enum entry did not match the declaration record. The coverage snapshot of native-npm-imports now counts one dynamic-tier statement instead of three: the program's `const point: Vector` takes the interface from the package's .d.ts instead of any. --- packages/compiler/src/frontend/npm-static-type-exports.ts | 2 +- tests/harness/__snapshots__/coverage-npm-packages.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/npm-static-type-exports.ts b/packages/compiler/src/frontend/npm-static-type-exports.ts index 844afa590..bdea8eb03 100644 --- a/packages/compiler/src/frontend/npm-static-type-exports.ts +++ b/packages/compiler/src/frontend/npm-static-type-exports.ts @@ -80,7 +80,7 @@ export function npmStaticDeclarationExports(sf: ts.SourceFile): NpmStaticDeclara localTypes.set(decl.name, decl); if (exported) types.push(decl); } else if (ts.isEnumDeclaration(stmt)) { - const decl = { name: stmt.name.text, params: [], templates: [] }; + const decl: NpmStaticTypeDeclaration = { name: stmt.name.text, params: [], templates: [] }; localTypes.set(decl.name, decl); if (exported) types.push(decl); } else if ( diff --git a/tests/harness/__snapshots__/coverage-npm-packages.txt b/tests/harness/__snapshots__/coverage-npm-packages.txt index 475b10e45..6150b08c2 100644 --- a/tests/harness/__snapshots__/coverage-npm-packages.txt +++ b/tests/harness/__snapshots__/coverage-npm-packages.txt @@ -2,7 +2,7 @@ scriptc coverage tests/corpus/native-npm-imports/main.ts statements analyzed 14 compile statically 14 (100%) - in dynamic tier 3 (operate on values typed only at runtime; --explain-dynamic lists them) + in dynamic tier 1 (operate on values typed only at runtime; --explain-dynamic lists them) npm package compilation: mathkit static From c36c414065bdf07d6fb3f7a245943a5d8f1ac4f9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 22:23:04 -0500 Subject: [PATCH 3/4] Re-export only data shapes from package declarations, through imports and .mjs entries - A local `export { Kind }` of a name the declaration file imports (`import type { Kind } from "./kinds"`) now resolves like `export { Kind } from "./kinds"`; it was dropped. - The typedefs now reach a runtime entry outside the .js/.cjs rewrite path (an .mjs file). - Only data shapes are re-exported: an interface, type alias or enum with no method, call or construct signature, function type or other computed type, whose referenced types are data shapes too. An interface with methods describes the package's JavaScript objects and stays as the JavaScript types it (@opentelemetry/api's Counter). - NPM_STATIC_TYPES_SUFFIX moves to npm-static.ts, so that module does not import the syntax modules (the self-hosted frontend tests compile it natively). --- .../frontend/npm-static-type-exports-host.ts | 79 ++++++++--- .../frontend/npm-static-type-exports.test.ts | 53 +++++++- .../src/frontend/npm-static-type-exports.ts | 127 ++++++++++++++++-- packages/compiler/src/frontend/npm-static.ts | 29 ++-- .../main.ts.json | 6 + .../main.ts | 19 +++ .../node_modules/row-module/lib/index.d.ts | 5 + .../node_modules/row-module/lib/index.mjs | 7 + .../node_modules/row-module/package.json | 1 + .../node_modules/shape-kinds/lib/index.d.ts | 4 + .../node_modules/shape-kinds/lib/index.js | 8 ++ .../node_modules/shape-kinds/lib/kinds.d.ts | 5 + .../node_modules/shape-kinds/lib/kinds.js | 2 + .../node_modules/shape-kinds/package.json | 1 + .../main.ts.json | 1 + tests/harness/coverage.test.ts | 13 ++ 16 files changed, 320 insertions(+), 40 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7004-package-declared-type-reexports/main.ts.json create mode 100644 tests/corpus/7004-package-declared-type-reexports/main.ts create mode 100644 tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.d.ts create mode 100644 tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.mjs create mode 100644 tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/package.json create mode 100644 tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.d.ts create mode 100644 tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.js create mode 100644 tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.d.ts create mode 100644 tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.js create mode 100644 tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/package.json create mode 100644 tests/harness/corpus-costs/7004-package-declared-type-reexports/main.ts.json diff --git a/packages/compiler/src/frontend/npm-static-type-exports-host.ts b/packages/compiler/src/frontend/npm-static-type-exports-host.ts index 809464763..3c196e6a7 100644 --- a/packages/compiler/src/frontend/npm-static-type-exports-host.ts +++ b/packages/compiler/src/frontend/npm-static-type-exports-host.ts @@ -5,10 +5,10 @@ import { dirname, relative, resolve as resolvePath } from "node:path"; import { trackedFileExists, trackedReadFile } from "./input-tracker.js"; import { resolveBareModule } from "./resolve.js"; import { isJsSourceFileName } from "./tsc-codes.js"; +import { NPM_STATIC_TYPES_SUFFIX } from "./npm-static.js"; import type { FrontendServices } from "./services.js"; import * as ts from "./ts7/syntax.js"; import { - NPM_STATIC_TYPES_SUFFIX, npmStaticDeclarationCopy, npmStaticDeclarationExports, npmStaticDeclarationSpecifiers, @@ -106,28 +106,37 @@ export function npmStaticTypeExportPlan( ); for (const target of targets.values()) copy(target); }; - // The exported type-only declarations of `file`, through its re-exports. + const exportsOf = new Map(); + const declarationExports = (file: string): NpmStaticDeclarationExports | null => { + let ex = exportsOf.get(file); + if (ex === undefined) { + const sf = parse(file); + ex = sf === null ? null : npmStaticDeclarationExports(sf); + exportsOf.set(file, ex); + } + return ex; + }; + // The exported type-only declarations of `file`, through its re-exports, + // each with the file that declares it. + type Found = { decl: NpmStaticTypeDeclaration; file: string }; const typesOf = ( file: string, seen: Set, - ): { types: Map; values: Set } => { - const result = { - types: new Map(), - values: new Set(), - }; + ): { types: Map; values: Set } => { + const result = { types: new Map(), values: new Set() }; if (seen.has(file)) return result; seen.add(file); - const sf = parse(file); - if (sf === null) return result; - const own: NpmStaticDeclarationExports = npmStaticDeclarationExports(sf); - for (const t of own.types) result.types.set(t.name, t); + const own = declarationExports(file); + if (own === null) return result; + for (const t of own.types) result.types.set(t.name, { decl: t, file }); for (const v of own.values) result.values.add(v); for (const n of own.named) { const target = n.specifier === null ? null : declarationTargetOf(file, n.specifier); if (target === null) continue; const inner = typesOf(target, new Set(seen)); const t = inner.types.get(n.local); - if (t !== undefined) result.types.set(n.exported, { ...t, name: n.exported }); + if (t !== undefined) + result.types.set(n.exported, { ...t, decl: { ...t.decl, name: n.exported } }); else if (inner.values.has(n.local)) result.values.add(n.exported); } for (const specifier of own.stars) { @@ -139,6 +148,39 @@ export function npmStaticTypeExportPlan( } return result; }; + // Whether `name`, as `file` sees it, is a data shape: an interface, type + // alias or enum whose syntax describes no behavior (npm-static-type- + // exports.ts) and whose referenced types are data shapes too. A name the + // package's declaration files do not declare is a global (`Array`, + // `Record`, `Date`); a class, a value or an import from another package + // is not a data shape. + const dataMemo = new Map(); + const isData = (file: string, name: string, visiting: Set): boolean => { + const key = `${file}\0${name}`; + const memo = dataMemo.get(key); + if (memo !== undefined) return memo; + if (visiting.has(key)) return true; // a recursive shape + visiting.add(key); + const answer = ((): boolean => { + const ex = declarationExports(file); + if (ex === null) return false; + const local = ex.locals.get(name); + if (local !== undefined) + return !local.behavior && local.references.every((r) => isData(file, r, visiting)); + if (ex.valueNames.has(name)) return false; + const from = ex.imports.get(name); + if (from !== undefined) { + const target = declarationTargetOf(file, from.specifier); + return target !== null && isData(target, from.local, visiting); + } + const reexport = typesOf(file, new Set()).types.get(name); + if (reexport !== undefined) return isData(reexport.file, reexport.decl.declared, visiting); + return true; + })(); + visiting.delete(key); + dataMemo.set(key, answer); + return answer; + }; for (const pkg of packages) { const declared = resolveBareModule(entryPath, pkg, "types-only"); if (declared === null || declared.packageName !== pkg || !declared.typesFile.endsWith(".d.ts")) @@ -149,9 +191,16 @@ export function npmStaticTypeExportPlan( if (runtimeText === null) continue; const { types, values } = typesOf(declared.typesFile, new Set()); const declaredByRuntime = runtimeTypeNames(services, runtime.typesFile, runtimeText); - const exported = [...types.values()].filter( - (t) => !values.has(t.name) && !declaredByRuntime.has(t.name), - ); + // Only data shapes: a type with methods describes the package's + // JavaScript objects, which stay as the JavaScript types them. + const exported = [...types.values()] + .filter( + ({ decl, file }) => + !values.has(decl.name) && + !declaredByRuntime.has(decl.name) && + isData(file, decl.declared, new Set()), + ) + .map(({ decl }) => decl); if (exported.length === 0) continue; copy(declared.typesFile); const specifier = specifierFor(runtime.typesFile, copyPathOf(declared.typesFile)); diff --git a/packages/compiler/src/frontend/npm-static-type-exports.test.ts b/packages/compiler/src/frontend/npm-static-type-exports.test.ts index fa95c0fe0..89adf5bbc 100644 --- a/packages/compiler/src/frontend/npm-static-type-exports.test.ts +++ b/packages/compiler/src/frontend/npm-static-type-exports.test.ts @@ -40,6 +40,41 @@ export { helper };`), expect(exports.stars).toEqual(["./paging"]); }); +test("a local export of an imported name resolves like a re-export", () => { + const exports = npmStaticDeclarationExports( + parse(`import type { Kind } from "./kinds"; +import { type Shape as Form, helper } from "./kinds"; +export { Kind, Form as Outline, helper };`), + ); + expect(exports.types).toEqual([]); + expect(exports.named).toEqual([ + { exported: "Kind", local: "Kind", specifier: "./kinds" }, + { exported: "Outline", local: "Shape", specifier: "./kinds" }, + { exported: "helper", local: "helper", specifier: "./kinds" }, + ]); +}); + +test("each declaration records whether it describes behavior and what it references", () => { + const { locals } = npmStaticDeclarationExports( + parse(`interface Row { key: string; value: T; next?: Row; tags: Tag[] } +type Tag = "a" | "b"; +interface Counter { add(value: number): void } +type Callback = (row: Row) => void; +type Keys = keyof Row; +interface Named extends Base { label: Label }`), + ); + const shape = (name: string) => { + const d = locals.get(name)!; + return [d.behavior, [...d.references].sort()]; + }; + expect(shape("Row")).toEqual([false, ["Row", "Tag"]]); + expect(shape("Tag")).toEqual([false, []]); + expect(shape("Counter")).toEqual([true, []]); + expect(shape("Callback")[0]).toBe(true); + expect(shape("Keys")[0]).toBe(true); + expect(shape("Named")).toEqual([false, ["Base", "Label"]]); +}); + test("a copy respells the relative specifiers it is told", () => { const sf = parse(`import type { Page } from "./paging"; export * from './entries'; @@ -60,8 +95,22 @@ test("the typedef block re-exports each type with its parameters", () => { expect( npmStaticTypedefBlock( [ - { name: "Mode", params: [], templates: [] }, - { name: "Options", params: ["T", "U"], templates: ["{object} [T={}]", "[U=string]"] }, + { + name: "Mode", + declared: "Mode", + params: [], + templates: [], + behavior: false, + references: [], + }, + { + name: "Options", + declared: "Options", + params: ["T", "U"], + templates: ["{object} [T={}]", "[U=string]"], + behavior: false, + references: [], + }, ], "./index.scriptc-types", ), diff --git a/packages/compiler/src/frontend/npm-static-type-exports.ts b/packages/compiler/src/frontend/npm-static-type-exports.ts index bdea8eb03..3b74b8aef 100644 --- a/packages/compiler/src/frontend/npm-static-type-exports.ts +++ b/packages/compiler/src/frontend/npm-static-type-exports.ts @@ -19,15 +19,21 @@ import * as ts from "./ts7/syntax.js"; import { tokenStartOfNode } from "./ts7/node-functions.js"; -/** The suffix that replaces `.d.ts` in a copy's name. */ -export const NPM_STATIC_TYPES_SUFFIX = ".scriptc-types.d.ts"; - -/** A type-only declaration: its name, its type parameters' names and their - * JSDoc @template tag bodies. */ +/** A type-only declaration: its exported name, its name where it is + * declared, its type parameters' names and their JSDoc @template tag + * bodies, and what npmStaticDataShape needs to know. */ export interface NpmStaticTypeDeclaration { name: string; + declared: string; params: readonly string[]; templates: readonly string[]; + /** The declaration's own syntax describes behavior or a computed type: + * a method, call or construct signature, a function or constructor + * type, an accessor, `typeof`, `import("...")`, a conditional, mapped, + * indexed-access or `keyof` type, or a qualified type name. */ + behavior: boolean; + /** The type names it refers to, its own type parameters excepted. */ + references: readonly string[]; } /** What a declaration file declares and re-exports, by exported name. */ @@ -37,11 +43,17 @@ export interface NpmStaticDeclarationExports { /** Exported classes, functions, variables and namespaces: names the * JavaScript declares itself, or that are not type-only. */ values: readonly string[]; - /** `export { local as exported } from "specifier"` and local - * `export { a, b }` (specifier null). */ + /** `export { local as exported } from "specifier"`, including a local + * `export { a }` of an imported name. */ named: readonly { exported: string; local: string; specifier: string | null }[]; /** `export * from "specifier"`. */ stars: readonly string[]; + /** Every interface, type alias and enum the file declares, by name. */ + locals: ReadonlyMap; + /** The file's named imports: local name to imported name and specifier. */ + imports: ReadonlyMap; + /** Every class, function, namespace and variable the file declares. */ + valueNames: ReadonlySet; } function isExported(node: ts.Node): boolean { @@ -60,6 +72,49 @@ function templatesOf(sf: ts.SourceFile, params: readonly ts.TypeParameterDeclara }); } +/** Syntax kinds of a type that describes behavior or is computed. */ +const BEHAVIOR_KINDS: ReadonlySet = new Set([ + ts.SyntaxKind.MethodSignature, + ts.SyntaxKind.CallSignature, + ts.SyntaxKind.ConstructSignature, + ts.SyntaxKind.FunctionType, + ts.SyntaxKind.ConstructorType, + ts.SyntaxKind.GetAccessor, + ts.SyntaxKind.SetAccessor, + ts.SyntaxKind.TypeQuery, + ts.SyntaxKind.ImportType, + ts.SyntaxKind.ConditionalType, + ts.SyntaxKind.MappedType, + ts.SyntaxKind.InferType, + ts.SyntaxKind.IndexedAccessType, + ts.SyntaxKind.ThisType, + ts.SyntaxKind.TypePredicate, + ts.SyntaxKind.TemplateLiteralType, +]); + +/** The behavior flag and type references of an interface or type alias. */ +function shapeOf( + decl: ts.InterfaceDeclaration | ts.TypeAliasDeclaration, + params: readonly string[], +): { behavior: boolean; references: string[] } { + let behavior = false; + const references = new Set(); + const refer = (name: ts.Node): void => { + if (ts.isIdentifier(name)) { + if (!params.includes(name.text)) references.add(name.text); + } else behavior = true; // a qualified name: a namespace member + }; + ts.walkPreorder(decl, (n) => { + if (behavior) return "stop"; + if (BEHAVIOR_KINDS.has(n.kind)) behavior = true; + else if (ts.isTypeOperatorNode(n) && n.operator === ts.SyntaxKind.KeyOfKeyword) behavior = true; + else if (ts.isTypeReferenceNode(n)) refer(n.typeName); + else if (ts.isExpressionWithTypeArguments(n)) refer(n.expression); + return undefined; + }); + return { behavior, references: [...references] }; +} + /** The exports of one declaration file. */ export function npmStaticDeclarationExports(sf: ts.SourceFile): NpmStaticDeclarationExports { const types: NpmStaticTypeDeclaration[] = []; @@ -68,19 +123,44 @@ export function npmStaticDeclarationExports(sf: ts.SourceFile): NpmStaticDeclara const stars: string[] = []; const localTypes = new Map(); const localValues = new Set(); + // `import { A as B } from "./x"`: local name B is A of "./x". + const imported = new Map(); for (const stmt of sf.statements.nodes) { const exported = isExported(stmt); - if (ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) { + if (ts.isImportDeclaration(stmt)) { + const bindings = stmt.importClause?.namedBindings; + if (ts.isStringLiteral(stmt.moduleSpecifier) && bindings && ts.isNamedImports(bindings)) { + for (const e of bindings.elements.nodes) { + imported.set(e.name.text, { + local: (e.propertyName ?? e.name).text, + specifier: stmt.moduleSpecifier.text, + }); + } + } + } else if (ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) { const params = stmt.typeParameters?.nodes ?? []; - const decl = { + const paramNames = params.map((p) => p.name.text); + const shape = shapeOf(stmt, paramNames); + const merged = localTypes.get(stmt.name.text); // interface merging + const decl: NpmStaticTypeDeclaration = { name: stmt.name.text, - params: params.map((p) => p.name.text), + declared: stmt.name.text, + params: paramNames, templates: templatesOf(sf, params), + behavior: shape.behavior || merged?.behavior === true, + references: [...shape.references, ...(merged?.references ?? [])], }; localTypes.set(decl.name, decl); - if (exported) types.push(decl); + if (exported && merged === undefined) types.push(decl); } else if (ts.isEnumDeclaration(stmt)) { - const decl: NpmStaticTypeDeclaration = { name: stmt.name.text, params: [], templates: [] }; + const decl: NpmStaticTypeDeclaration = { + name: stmt.name.text, + declared: stmt.name.text, + params: [], + templates: [], + behavior: false, + references: [], + }; localTypes.set(decl.name, decl); if (exported) types.push(decl); } else if ( @@ -116,7 +196,9 @@ export function npmStaticDeclarationExports(sf: ts.SourceFile): NpmStaticDeclara } } } - // Local `export { a as b }` of the file's own declarations. + // Local `export { a as b }`: of the file's own declarations, or of a + // name it imports (`import type { A } from "./a"; export { A }`), which + // resolves like `export { A } from "./a"`. const resolvedNamed: typeof named = []; for (const n of named) { if (n.specifier !== null) { @@ -124,10 +206,27 @@ export function npmStaticDeclarationExports(sf: ts.SourceFile): NpmStaticDeclara continue; } const local = localTypes.get(n.local); + const from = imported.get(n.local); if (local !== undefined) types.push({ ...local, name: n.exported }); else if (localValues.has(n.local)) values.push(n.exported); + else if (from !== undefined) resolvedNamed.push({ exported: n.exported, ...from }); } - return { types, values, named: resolvedNamed, stars }; + // A merged interface's exported entry takes the merged flags. + const exportedTypes: NpmStaticTypeDeclaration[] = []; + for (const t of types) { + const local = localTypes.get(t.declared); + if (local === undefined) exportedTypes.push(t); + else exportedTypes.push({ ...local, name: t.name }); + } + return { + types: exportedTypes, + values, + named: resolvedNamed, + stars, + locals: localTypes, + imports: imported, + valueNames: localValues, + }; } /** The relative module specifiers of a declaration file's imports, exports diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index 33ff54cfd..800d02eff 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -23,7 +23,6 @@ import { trackedRealpath, } from "./input-tracker.js"; import { npmBuiltinAliasOf } from "./builtin-modules.js"; -import { NPM_STATIC_TYPES_SUFFIX } from "./npm-static-type-exports.js"; let activePackages: ReadonlySet = new Set(); let declarationOverloads: ReadonlyMap = new Map(); @@ -55,6 +54,11 @@ export function npmStaticLexerVisibleNames(path: string): readonly string[] { return lexerNames.get(path.split("\\").join("/")) ?? []; } +/** The suffix that replaces `.d.ts` in the name of a copy of an opted-in + * package's declaration file (npm-static-type-exports.ts). Defined here so + * this module does not depend on the syntax modules. */ +export const NPM_STATIC_TYPES_SUFFIX = ".scriptc-types.d.ts"; + /** Per runtime entry (forward slashes), the JSDoc typedefs that re-export * its package's type-only declarations (npm-static-type-exports.ts). */ let typeExportTypedefs = new Map(); @@ -71,6 +75,17 @@ export function setNpmStaticTypeExports( rewriteCache.clear(); } +/** `text` (or the file's own text when null) with the typedefs of a + * package runtime entry appended: the entry re-exports the type-only + * declarations of the package's .d.ts, after every respelling, so + * positions stay. Null when `path` is no runtime entry and `text` is null. */ +function withTypeExportTypedefs(path: string, text: string | null): string | null { + const typedefs = typeExportTypedefs.get(path.split("\\").join("/")); + if (typedefs === undefined) return text; + const source = text ?? trackedReadFile(path); + return source === null ? text : `${source}\n${typedefs}\n`; +} + export function setNpmStaticPackages>(packages: T): void { // npm packages served as builtins (graceful-fs as fs) never compile // their JavaScript, even when named. @@ -543,17 +558,13 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow } catch { rewritten = null; // unreadable/unparseable: fall through untouched } - // The runtime entry re-exports the type-only declarations of the - // package's .d.ts, after every respelling, so positions stay. - const typedefs = typeExportTypedefs.get(path.split("\\").join("/")); - if (typedefs !== undefined) { - const text = rewritten ?? trackedReadFile(path); - if (text !== null) rewritten = `${text}\n${typedefs}\n`; - } + rewritten = withTypeExportTypedefs(path, rewritten); rewriteCache.set(path, rewritten); return rewritten ?? undefined; } - return undefined; + // Other runtime entries (`.mjs`) keep their text and gain only the + // typedefs. + return withTypeExportTypedefs(path, null) ?? undefined; }, hideFile: (path) => { // The copies of the package's declaration files the typedefs name. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7004-package-declared-type-reexports/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7004-package-declared-type-reexports/main.ts.json new file mode 100644 index 000000000..927b902d9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7004-package-declared-type-reexports/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7004-package-declared-type-reexports/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7004-package-declared-type-reexports/main.ts b/tests/corpus/7004-package-declared-type-reexports/main.ts new file mode 100644 index 000000000..52e45e7d4 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/main.ts @@ -0,0 +1,19 @@ +// Two packages that compile from their JavaScript, with types only their +// .d.ts declares: one re-exports types its entry imports +// (`import type { Kind } from "./kinds"; export { Kind }`), the other's +// runtime entry is an ES module (`.mjs`). Declarations using those types +// keep TypeScript's types: the empty arrays the packages fill hold the +// declared rows. +import { addShapes, type Form, type Kind } from "shape-kinds"; +import { readRows, type Row } from "row-module"; + +const forms: Form[] = []; +addShapes("c2 s3", forms); +const kinds: Kind[] = forms.map((form) => form.kind); +const first = forms[0]!; +console.log(kinds.join(","), first.size + forms[1]!.size, first.kind === "circle"); + +const rows: Row[] = []; +const count = readRows("a=1;b=22", rows); +const last = rows[count - 1]!; +console.log(count, last.key, last.value.length); diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.d.ts b/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.d.ts new file mode 100644 index 000000000..1f732f996 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.d.ts @@ -0,0 +1,5 @@ +export interface Row { + key: string; + value: string; +} +export declare function readRows(text: string, into: Row[]): number; diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.mjs b/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.mjs new file mode 100644 index 000000000..33d110065 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.mjs @@ -0,0 +1,7 @@ +export function readRows(text, into) { + for (const part of text.split(";")) { + const at = part.indexOf("="); + into.push({ key: part.slice(0, at), value: part.slice(at + 1) }); + } + return into.length; +} diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/package.json b/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/package.json new file mode 100644 index 000000000..240a3f816 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/package.json @@ -0,0 +1 @@ +{ "name": "row-module", "version": "1.0.0", "main": "./lib/index.mjs", "types": "./lib/index.d.ts" } diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.d.ts b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.d.ts new file mode 100644 index 000000000..ada1748d8 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.d.ts @@ -0,0 +1,4 @@ +import type { Kind } from "./kinds"; +import { type Shape as Form } from "./kinds"; +export { Kind, Form }; +export declare function addShapes(text: string, into: Form[]): void; diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.js b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.js new file mode 100644 index 000000000..d6165c4b9 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.js @@ -0,0 +1,8 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.addShapes = addShapes; +function addShapes(text, into) { + for (const part of text.split(" ")) { + into.push({ kind: part.charAt(0) === "c" ? "circle" : "square", size: Number(part.slice(1)) }); + } +} diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.d.ts b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.d.ts new file mode 100644 index 000000000..79c90322c --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.d.ts @@ -0,0 +1,5 @@ +export type Kind = "circle" | "square"; +export interface Shape { + kind: Kind; + size: number; +} diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.js b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.js new file mode 100644 index 000000000..c8ad2e549 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/package.json b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/package.json new file mode 100644 index 000000000..e428fc315 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/package.json @@ -0,0 +1 @@ +{ "name": "shape-kinds", "version": "1.0.0", "main": "./lib/index.js", "types": "./lib/index.d.ts" } diff --git a/tests/harness/corpus-costs/7004-package-declared-type-reexports/main.ts.json b/tests/harness/corpus-costs/7004-package-declared-type-reexports/main.ts.json new file mode 100644 index 000000000..e9e0bf6d2 --- /dev/null +++ b/tests/harness/corpus-costs/7004-package-declared-type-reexports/main.ts.json @@ -0,0 +1 @@ +{ "plain": 1015, "san": 2516 } diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index ca62352b1..1f790c225 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -488,6 +488,19 @@ test("JavaScript builtin aliases keep their deferred value fence", async () => { ); }); +test("types only a package's .d.ts declares stay static through re-exports and .mjs entries", () => { + // shape-kinds re-exports types its .d.ts entry imports; row-module's + // runtime entry is an .mjs file. Without their declared types the + // program's uses of Form, Kind and Row run in the dynamic tier (12 + // statements); with them only the packages' own JavaScript and one + // arithmetic on a JavaScript return value do (4). + const { coverage } = analyze( + join(repoRoot, "tests/corpus/7004-package-declared-type-reexports/main.ts"), + ); + expect(coverage.diagnostics).toEqual([]); + expect(coverage.stats.statementsDynamic).toBe(4); +}); + test("any-typed checked-dynamic operators compile natively", () => { const file = join(repoRoot, "tests/corpus/2856-dynamic-any-local-operators.ts"); const staticCoverage = analyze(file).coverage; From 47ba594c3f687bee589c2137fd6995678f10eb52 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 00:34:51 -0500 Subject: [PATCH 4/4] Check every type a package type reaches before re-exporting it as a data shape The data-shape check answered a type that refers back to a type still being checked as a data shape and recorded that answer before the outer check finished. With `Branch { child: Tree; handle: Handle }` and `Tree { parent: Branch | null }`, checking Branch first recorded Tree as a data shape, and Tree was re-exported although Branch reaches an interface with a method. The check now walks every type reachable from the queried one before it records any of them as a data shape, and records the types on the path to a non-data type as non-data. --- .../npm-static-type-exports-host.test.ts | 59 +++++++++++++++ .../frontend/npm-static-type-exports-host.ts | 75 ++++++++++++------- 2 files changed, 109 insertions(+), 25 deletions(-) create mode 100644 packages/compiler/src/frontend/npm-static-type-exports-host.test.ts diff --git a/packages/compiler/src/frontend/npm-static-type-exports-host.test.ts b/packages/compiler/src/frontend/npm-static-type-exports-host.test.ts new file mode 100644 index 000000000..e37283fe6 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-type-exports-host.test.ts @@ -0,0 +1,59 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, expect, test } from "vitest"; +import { nodeFrontendServices } from "./services-node.js"; +import { npmStaticTypeExportPlan } from "./npm-static-type-exports-host.js"; + +const dirs: string[] = []; +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +/** A program directory with one package whose .d.ts is `declarations`; + * returns the program's entry path. */ +function fixture(declarations: string): string { + const dir = mkdtempSync(join(tmpdir(), "scriptc-type-exports-")); + dirs.push(dir); + const lib = join(dir, "node_modules", "pkg", "lib"); + mkdirSync(lib, { recursive: true }); + writeFileSync( + join(dir, "node_modules", "pkg", "package.json"), + JSON.stringify({ name: "pkg", main: "./lib/index.js", types: "./lib/index.d.ts" }), + ); + writeFileSync(join(lib, "index.d.ts"), declarations); + writeFileSync(join(lib, "index.js"), "exports.grow = function grow(into) {};\n"); + writeFileSync(join(dir, "main.ts"), 'import { grow } from "pkg";\n'); + return join(dir, "main.ts"); +} + +/** The type names the runtime entry's typedefs re-export. */ +function reexported(entry: string): string[] { + const plan = npmStaticTypeExportPlan(nodeFrontendServices(), entry, ["pkg"]); + const block = [...plan.typedefs.values()].join("\n"); + return [...block.matchAll(/@typedef \{[^}]*\} (\w+)/g)].map((m) => m[1]!); +} + +test("only data shapes are re-exported", () => { + const entry = fixture(`export interface Point { x: number; y: number } +export interface Handle { close(): void } +export type Label = { text: string; at: Point }; +export type Callback = (point: Point) => void; +export interface Holder { handle: Handle } +export declare function grow(into: Point[]): void; +`); + expect(reexported(entry)).toEqual(["Point", "Label"]); +}); + +test("mutually recursive types are data shapes only when every type they reach is", () => { + // Branch is checked first and reaches Tree, which refers back to Branch: + // Tree is a data shape only if Branch is, and Branch holds a Handle. + const entry = fixture(`export interface Branch { child: Tree; handle: Handle } +export interface Tree { name: string; parent: Branch | null } +export interface Handle { close(): void } +export interface Node { value: number; next: List } +export interface List { head: Node | null } +export declare function grow(into: Tree[]): void; +`); + expect(reexported(entry)).toEqual(["Node", "List"]); +}); diff --git a/packages/compiler/src/frontend/npm-static-type-exports-host.ts b/packages/compiler/src/frontend/npm-static-type-exports-host.ts index 3c196e6a7..8a61ed62b 100644 --- a/packages/compiler/src/frontend/npm-static-type-exports-host.ts +++ b/packages/compiler/src/frontend/npm-static-type-exports-host.ts @@ -154,32 +154,57 @@ export function npmStaticTypeExportPlan( // package's declaration files do not declare is a global (`Array`, // `Record`, `Date`); a class, a value or an import from another package // is not a data shape. + // + // `referencesOf` answers one name: null when it is not a data shape by + // itself, otherwise the names it refers to. + type Ref = { file: string; name: string }; + const referencesOf = (file: string, name: string): Ref[] | null => { + const ex = declarationExports(file); + if (ex === null) return null; + const local = ex.locals.get(name); + if (local !== undefined) + return local.behavior ? null : local.references.map((r) => ({ file, name: r })); + if (ex.valueNames.has(name)) return null; + const from = ex.imports.get(name); + if (from !== undefined) { + const target = declarationTargetOf(file, from.specifier); + return target === null ? null : [{ file: target, name: from.local }]; + } + const reexport = typesOf(file, new Set()).types.get(name); + if (reexport !== undefined) return [{ file: reexport.file, name: reexport.decl.declared }]; + return []; + }; + // A name is a data shape when every name reachable from it is one by + // itself. `isData` walks everything reachable before it records any name + // as a data shape, so a type in a reference cycle is not recorded while + // another type in the cycle is still unchecked. const dataMemo = new Map(); - const isData = (file: string, name: string, visiting: Set): boolean => { - const key = `${file}\0${name}`; - const memo = dataMemo.get(key); - if (memo !== undefined) return memo; - if (visiting.has(key)) return true; // a recursive shape - visiting.add(key); - const answer = ((): boolean => { - const ex = declarationExports(file); - if (ex === null) return false; - const local = ex.locals.get(name); - if (local !== undefined) - return !local.behavior && local.references.every((r) => isData(file, r, visiting)); - if (ex.valueNames.has(name)) return false; - const from = ex.imports.get(name); - if (from !== undefined) { - const target = declarationTargetOf(file, from.specifier); - return target !== null && isData(target, from.local, visiting); + const isData = (file: string, name: string): boolean => { + const keyOf = (ref: Ref): string => `${ref.file}\0${ref.name}`; + // Each reached name, by key, with the key of the name it was reached from. + const reachedFrom = new Map([[keyOf({ file, name }), null]]); + const queue: Ref[] = [{ file, name }]; + for (let i = 0; i < queue.length; i++) { + const ref = queue[i]!; + const key = keyOf(ref); + const memo = dataMemo.get(key); + if (memo === true) continue; + const next = memo === false ? null : referencesOf(ref.file, ref.name); + if (next === null) { + // Every name on the path to this one reaches a non-data type. + for (let k: string | null = key; k !== null; k = reachedFrom.get(k) ?? null) + dataMemo.set(k, false); + return false; } - const reexport = typesOf(file, new Set()).types.get(name); - if (reexport !== undefined) return isData(reexport.file, reexport.decl.declared, visiting); - return true; - })(); - visiting.delete(key); - dataMemo.set(key, answer); - return answer; + for (const target of next) { + const targetKey = keyOf(target); + if (reachedFrom.has(targetKey)) continue; + reachedFrom.set(targetKey, key); + queue.push(target); + } + } + for (const key of reachedFrom.keys()) dataMemo.set(key, true); + return true; }; for (const pkg of packages) { const declared = resolveBareModule(entryPath, pkg, "types-only"); @@ -198,7 +223,7 @@ export function npmStaticTypeExportPlan( ({ decl, file }) => !values.has(decl.name) && !declaredByRuntime.has(decl.name) && - isData(file, decl.declared, new Set()), + isData(file, decl.declared), ) .map(({ decl }) => decl); if (exported.length === 0) continue;