From 5efda821cffeec5a0986e72d40ef466196819bc1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 15 Sep 2026 08:53:25 -0500 Subject: [PATCH] feat(compiler): preserve npm static declaration overloads (#329) --- eslint.config.mjs | 1 + .../frontend/npm-static-declarations.test.ts | 77 ++++ .../src/frontend/npm-static-declarations.ts | 402 ++++++++++++++++++ packages/compiler/src/frontend/npm-static.ts | 40 +- packages/compiler/src/frontend/program.ts | 114 ++++- packages/compiler/src/frontend/resolve.ts | 9 +- packages/compiler/src/index.ts | 10 +- scripts/test-ts7.mjs | 1 + .../commander-calc/calc-npm-static.ts | 12 +- tests/fixtures/npm-static/chainy-cli.ts | 8 +- .../npm-static/node_modules/chainy/index.js | 5 +- tests/harness/npm-static.test.ts | 46 +- 12 files changed, 671 insertions(+), 54 deletions(-) create mode 100644 packages/compiler/src/frontend/npm-static-declarations.test.ts create mode 100644 packages/compiler/src/frontend/npm-static-declarations.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 0adbb95d..00d0bff9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,7 @@ const TS5_ISLANDS = [ // The bundler-emitted-CJS export rewrite runs inside the fs shadow, // BEFORE the 7.0.2 program reads the file — a text→text parser island // beside cjs-lexer.ts (only strings cross its boundary). + "packages/compiler/src/frontend/npm-static-declarations.ts", "packages/compiler/src/frontend/npm-static-rewrite.ts", // Semantic cache validation parses source text only to identify exact // regex spans and syntax errors; its boundary is strings, offsets, and diff --git a/packages/compiler/src/frontend/npm-static-declarations.test.ts b/packages/compiler/src/frontend/npm-static-declarations.test.ts new file mode 100644 index 00000000..9a198035 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-declarations.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "vitest"; +import { + applyNpmStaticDeclarationOverloads, + npmStaticDeclarationReexports, + npmStaticRuntimeClassTargets, + parseNpmStaticDeclarationOverloads, +} from "./npm-static-declarations.js"; + +const declarations = ` +export class Chainy { + name(): string; + name(value: string): this; + tag(): string; + tag(value: string): this; + single(): string; + unsafe(): string; + unsafe(value: Date): this; + generic(value: T): T; + generic(value: string): string; +} +`; + +describe("npm-static declaration overload projection", () => { + test("extracts only complete representation-safe overload groups", () => { + const overloads = parseNpmStaticDeclarationOverloads("index.d.ts", declarations); + expect([...overloads.keys()]).toEqual(["Chainy"]); + expect([...overloads.get("Chainy")!.keys()]).toEqual(["name", "tag"]); + expect(overloads.get("Chainy")!.get("name")).toEqual([ + { parameters: [], returnType: "string" }, + { parameters: [{ name: "value", type: "string", optional: false }], returnType: "this" }, + ]); + }); + + test("injects overload and implementation JSDoc only into exported matching classes", () => { + const source = ` +class Hidden { + name(value) { return value === undefined ? "" : this; } +} +class Chainy { + name(value) { return value === undefined ? "" : this; } + tag(value) { return value === undefined ? "" : this; } +} +module.exports = { Chainy }; +`; + const rewritten = applyNpmStaticDeclarationOverloads( + "index.js", + source, + parseNpmStaticDeclarationOverloads("index.d.ts", declarations), + ); + expect(rewritten).not.toBeNull(); + expect(rewritten!.insertions).toHaveLength(2); + expect(rewritten!.text.match(/@overload/g)).toHaveLength(4); + expect(rewritten!.text).toContain("@param {string} [value] @returns {string | Chainy}"); + expect(rewritten!.text.slice(source.indexOf("class Hidden"), source.indexOf("class Chainy"))).not.toContain("@overload"); + }); + + test("reports only relative declaration-barrel edges", () => { + expect(npmStaticDeclarationReexports("esm.d.mts", ` + export * from "./index.js"; + export { Type } from "./types.js"; + export * from "other-package"; + `)).toEqual(["./index.js", "./types.js"]); + }); + + test("binds declaration classes to direct and one-hop runtime exports", () => { + expect(npmStaticRuntimeClassTargets("index.js", ` + const { Command, Other: Alias } = require("./lib/command.js"); + class Local {} + exports.Command = Command; + exports.Alias = Alias; + exports.Local = Local; + `, new Set(["Command", "Alias", "Local"]))).toEqual(new Map([ + ["Command", "./lib/command.js"], + ["Local", null], + ])); + }); +}); diff --git a/packages/compiler/src/frontend/npm-static-declarations.ts b/packages/compiler/src/frontend/npm-static-declarations.ts new file mode 100644 index 00000000..b0967c30 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-declarations.ts @@ -0,0 +1,402 @@ +/* Declaration-overload projection for --npm-static. The runtime program + * still resolves to and compiles package JavaScript, but an authored .d.ts + * can carry overloads inference cannot reproduce (the common getter/setter + * shape `name(): string` / `name(value): this`). This string-bounded + * TypeScript 5 parser island extracts only complete, representation-safe + * groups and respells them as JSDoc immediately before the matching + * exported JavaScript class method. TypeScript 7 then checks and lowers one + * world: implementation bodies remain the runtime truth, while overload + * calls get the package author's more precise signature. */ + +import ts from "typescript5"; + +export interface NpmStaticOverloadParameter { + name: string; + type: string; + optional: boolean; +} + +export interface NpmStaticOverloadSignature { + parameters: readonly NpmStaticOverloadParameter[]; + returnType: string; +} + +export type NpmStaticDeclarationOverloads = ReadonlyMap< + string, + ReadonlyMap +>; + +export interface NpmStaticOverloadRewrite { + text: string; + insertions: readonly { offset: number; length: number }[]; +} + +const SAFE_KEYWORD_TYPES = new Set([ + ts.SyntaxKind.BooleanKeyword, + ts.SyntaxKind.NeverKeyword, + ts.SyntaxKind.NullKeyword, + ts.SyntaxKind.NumberKeyword, + ts.SyntaxKind.StringKeyword, + ts.SyntaxKind.UndefinedKeyword, + ts.SyntaxKind.VoidKeyword, +]); + +function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean { + return ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === kind); +} + +function safeTypeText(node: ts.TypeNode, sourceFile: ts.SourceFile, className: string): string | null { + if (SAFE_KEYWORD_TYPES.has(node.kind) || ts.isThisTypeNode(node)) return node.getText(sourceFile); + if (ts.isParenthesizedTypeNode(node)) { + const inner = safeTypeText(node.type, sourceFile, className); + return inner === null ? null : `(${inner})`; + } + if (ts.isArrayTypeNode(node)) { + const element = safeTypeText(node.elementType, sourceFile, className); + return element === null ? null : `${element}[]`; + } + if (ts.isUnionTypeNode(node)) { + const arms = node.types.map((type) => safeTypeText(type, sourceFile, className)); + return arms.some((arm) => arm === null) ? null : arms.join(" | "); + } + return ts.isTypeReferenceNode(node) && + ts.isIdentifier(node.typeName) && + node.typeName.text === className && + (node.typeArguments?.length ?? 0) === 0 + ? className + : null; +} + +function overloadSignature( + sourceFile: ts.SourceFile, + className: string, + method: ts.MethodDeclaration, +): NpmStaticOverloadSignature | null { + if ( + !ts.isIdentifier(method.name) || + method.type === undefined || + (method.typeParameters?.length ?? 0) !== 0 || + hasModifier(method, ts.SyntaxKind.StaticKeyword) || + hasModifier(method, ts.SyntaxKind.PrivateKeyword) || + hasModifier(method, ts.SyntaxKind.ProtectedKeyword) + ) { + return null; + } + const returnType = safeTypeText(method.type, sourceFile, className); + if (returnType === null) return null; + const parameters: NpmStaticOverloadParameter[] = []; + for (const parameter of method.parameters) { + if ( + !ts.isIdentifier(parameter.name) || + parameter.name.text === "this" || + parameter.type === undefined || + parameter.initializer !== undefined || + parameter.dotDotDotToken !== undefined + ) { + return null; + } + const type = safeTypeText(parameter.type, sourceFile, className); + if (type === null) return null; + parameters.push({ + name: parameter.name.text, + type, + optional: parameter.questionToken !== undefined, + }); + } + return { parameters, returnType }; +} + +/** Extracts complete safe overload groups from exported non-generic classes. */ +export function parseNpmStaticDeclarationOverloads( + declarationPath: string, + source: string, +): NpmStaticDeclarationOverloads { + const sourceFile = ts.createSourceFile(declarationPath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const classes = new Map>(); + for (const statement of sourceFile.statements) { + if ( + !ts.isClassDeclaration(statement) || + statement.name === undefined || + (statement.typeParameters?.length ?? 0) !== 0 || + !hasModifier(statement, ts.SyntaxKind.ExportKeyword) || + hasModifier(statement, ts.SyntaxKind.DefaultKeyword) + ) { + continue; + } + const className = statement.name.text; + const groups = new Map(); + for (const member of statement.members) { + if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name)) continue; + const group = groups.get(member.name.text) ?? []; + group.push(member); + groups.set(member.name.text, group); + } + const overloads = new Map(); + for (const [name, methods] of groups) { + if (methods.length < 2) continue; + const signatures = methods.map((method) => overloadSignature(sourceFile, className, method)); + // A partial set could select the wrong branch. Keep inference when + // any authored signature is outside the projection's safe grammar. + if (signatures.some((signature) => signature === null)) continue; + overloads.set(name, signatures as NpmStaticOverloadSignature[]); + } + if (overloads.size > 0) classes.set(className, overloads); + } + return classes; +} + +/** Relative declaration-barrel edges whose target stays subject to the + * caller's package-bounded resolution. Bare type dependencies deliberately + * do not inherit the opted package's declaration trust. */ +export function npmStaticDeclarationReexports( + declarationPath: string, + source: string, +): readonly string[] { + const sourceFile = ts.createSourceFile(declarationPath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + return sourceFile.statements.flatMap((statement) => + ts.isExportDeclaration(statement) && + statement.moduleSpecifier !== undefined && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text.startsWith(".") + ? [statement.moduleSpecifier.text] + : [] + ); +} + +function requireSpecifier(expression: ts.Expression | undefined): string | null { + const argument = expression !== undefined && ts.isCallExpression(expression) ? expression.arguments[0] : undefined; + return expression !== undefined && + ts.isCallExpression(expression) && + ts.isIdentifier(expression.expression) && + expression.expression.text === "require" && + expression.arguments.length === 1 && + argument !== undefined && + ts.isStringLiteralLike(argument) + ? argument.text + : null; +} + +/** Maps declaration class names to their implementation edge from one + * runtime package entry. Null means the class is declared in the entry; + * a string is a relative re-export target. Multi-hop and aliased class + * re-exports stay out of the first safe slice. */ +export function npmStaticRuntimeClassTargets( + sourcePath: string, + source: string, + classNames: ReadonlySet, +): ReadonlyMap { + const sourceFile = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + const localClasses = new Set( + sourceFile.statements.flatMap((statement) => + ts.isClassDeclaration(statement) && statement.name !== undefined ? [statement.name.text] : [] + ), + ); + const required = new Map(); + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + const specifier = requireSpecifier(declaration.initializer); + if (specifier === null || !specifier.startsWith(".") || !ts.isObjectBindingPattern(declaration.name)) continue; + for (const element of declaration.name.elements) { + if (element.dotDotDotToken !== undefined || !ts.isIdentifier(element.name)) continue; + const imported = element.propertyName !== undefined && ts.isIdentifier(element.propertyName) + ? element.propertyName.text + : element.name.text; + required.set(element.name.text, { imported, specifier }); + } + } + } + const targets = new Map(); + const record = (exported: string, local: string, specifier?: string): void => { + if (!classNames.has(exported) || exported !== local || targets.has(exported)) return; + if (specifier !== undefined) { + targets.set(exported, specifier); + return; + } + const imported = required.get(local); + if (imported !== undefined && imported.imported === exported) targets.set(exported, imported.specifier); + else if (localClasses.has(local)) targets.set(exported, null); + }; + for (const statement of sourceFile.statements) { + if ( + ts.isClassDeclaration(statement) && + statement.name !== undefined && + hasModifier(statement, ts.SyntaxKind.ExportKeyword) + ) { + record(statement.name.text, statement.name.text); + continue; + } + if (ts.isExportDeclaration(statement) && statement.exportClause !== undefined && ts.isNamedExports(statement.exportClause)) { + const specifier = statement.moduleSpecifier !== undefined && ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; + if (specifier !== undefined && !specifier.startsWith(".")) continue; + for (const element of statement.exportClause.elements) { + record(element.name.text, element.propertyName?.text ?? element.name.text, specifier); + } + continue; + } + if (!ts.isExpressionStatement(statement) || !ts.isBinaryExpression(statement.expression)) continue; + const { left, right, operatorToken } = statement.expression; + if (operatorToken.kind !== ts.SyntaxKind.EqualsToken) continue; + if ( + ts.isPropertyAccessExpression(left) && + ts.isIdentifier(right) && + ((ts.isIdentifier(left.expression) && left.expression.text === "exports") || + (ts.isPropertyAccessExpression(left.expression) && + ts.isIdentifier(left.expression.expression) && + left.expression.expression.text === "module" && + left.expression.name.text === "exports")) + ) { + record(left.name.text, right.text); + continue; + } + if ( + ts.isPropertyAccessExpression(left) && + ts.isIdentifier(left.expression) && + left.expression.text === "module" && + left.name.text === "exports" && + ts.isObjectLiteralExpression(right) + ) { + for (const property of right.properties) { + if (ts.isShorthandPropertyAssignment(property)) record(property.name.text, property.name.text); + else if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + ts.isIdentifier(property.initializer) + ) { + record(property.name.text, property.initializer.text); + } + } + } + } + return targets; +} + +function exportedClassNames(sourceFile: ts.SourceFile): ReadonlySet { + const names = new Set(); + for (const statement of sourceFile.statements) { + if ( + ts.isClassDeclaration(statement) && + statement.name !== undefined && + hasModifier(statement, ts.SyntaxKind.ExportKeyword) + ) { + names.add(statement.name.text); + continue; + } + if (ts.isExportDeclaration(statement) && statement.exportClause !== undefined && ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) names.add(element.propertyName?.text ?? element.name.text); + continue; + } + if (!ts.isExpressionStatement(statement) || !ts.isBinaryExpression(statement.expression)) continue; + const { left, right, operatorToken } = statement.expression; + if (operatorToken.kind !== ts.SyntaxKind.EqualsToken) continue; + if ( + ts.isPropertyAccessExpression(left) && + ts.isIdentifier(right) && + ((ts.isIdentifier(left.expression) && left.expression.text === "exports") || + (ts.isPropertyAccessExpression(left.expression) && + ts.isIdentifier(left.expression.expression) && + left.expression.expression.text === "module" && + left.expression.name.text === "exports")) && + left.name.text === right.text + ) { + names.add(right.text); + continue; + } + if ( + ts.isPropertyAccessExpression(left) && + ts.isIdentifier(left.expression) && + left.expression.text === "module" && + left.name.text === "exports" && + ts.isObjectLiteralExpression(right) + ) { + for (const property of right.properties) { + if (ts.isShorthandPropertyAssignment(property)) names.add(property.name.text); + else if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + ts.isIdentifier(property.initializer) && + property.name.text === property.initializer.text + ) { + names.add(property.name.text); + } + } + } + } + return names; +} + +function overloadComment(signature: NpmStaticOverloadSignature): string { + const params = signature.parameters.map((parameter) => + `@param {${parameter.type}} ${parameter.optional ? `[${parameter.name}]` : parameter.name}` + ); + return `/** @overload ${params.join(" ")} @returns {${signature.returnType}} */`; +} + +function implementationComment( + className: string, + method: ts.MethodDeclaration, + signatures: readonly NpmStaticOverloadSignature[], +): string | null { + if (method.parameters.some((parameter) => !ts.isIdentifier(parameter.name) || parameter.dotDotDotToken !== undefined)) return null; + const maxParams = Math.max(...signatures.map((signature) => signature.parameters.length)); + if (method.parameters.length !== maxParams) return null; + const params: string[] = []; + for (let index = 0; index < maxParams; index++) { + const types = [...new Set(signatures.flatMap((signature) => signature.parameters[index]?.type ?? []))]; + if (types.length === 0) return null; + const parameter = method.parameters[index]; + if (parameter === undefined || !ts.isIdentifier(parameter.name)) return null; + const name = parameter.name.text; + const optional = signatures.some((signature) => { + const candidate = signature.parameters[index]; + return candidate === undefined || candidate.optional; + }); + params.push(`@param {${types.join(" | ")}} ${optional ? `[${name}]` : name}`); + } + const returns = [...new Set(signatures.map((signature) => + signature.returnType.replace(/\bthis\b/g, className) + ))]; + return `/** ${params.join(" ")} @returns {${returns.join(" | ")}} */`; +} + +/** Injects declaration overload JSDoc into matching exported JS classes. */ +export function applyNpmStaticDeclarationOverloads( + sourcePath: string, + source: string, + declarations: NpmStaticDeclarationOverloads, +): NpmStaticOverloadRewrite | null { + if (declarations.size === 0) return null; + const sourceFile = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + const exported = exportedClassNames(sourceFile); + const inserts: { offset: number; text: string }[] = []; + for (const statement of sourceFile.statements) { + if (!ts.isClassDeclaration(statement) || statement.name === undefined || !exported.has(statement.name.text)) continue; + const classOverloads = declarations.get(statement.name.text); + if (classOverloads === undefined) continue; + for (const member of statement.members) { + if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name) || member.body === undefined) continue; + const signatures = classOverloads.get(member.name.text); + if (signatures === undefined) continue; + const jsDocs = (member as ts.MethodDeclaration & { jsDoc?: readonly ts.JSDoc[] }).jsDoc ?? []; + if (jsDocs.some((doc) => source.slice(doc.pos, doc.end).includes("@overload"))) continue; + const implementation = jsDocs.length === 0 ? implementationComment(statement.name.text, member, signatures) : null; + if (jsDocs.length === 0 && implementation === null) continue; + const offset = jsDocs[0]?.getStart(sourceFile) ?? member.getStart(sourceFile); + const text = `${signatures.map(overloadComment).join(" ")} ${implementation === null ? "" : implementation + " "}`; + inserts.push({ offset, text }); + } + } + if (inserts.length === 0) return null; + let text = source; + for (const insert of [...inserts].sort((a, b) => b.offset - a.offset)) { + text = text.slice(0, insert.offset) + insert.text + text.slice(insert.offset); + } + return { + text, + insertions: inserts + .sort((a, b) => a.offset - b.offset) + .map((insert) => ({ offset: insert.offset, length: insert.text.length })), + }; +} diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index f05b648d..8bcc51cf 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -8,14 +8,14 @@ * included) types the bodies, and every statement the lowering cannot * honor becomes the standard JS runtime fence (the trap throws AT the * statement if the program ever drives it — trust-but-verify, never - * silent trust). The .d.ts is deliberately DROPPED from the opted-in - * package's resolution: a declaration is a CLAIM about the body, and the - * compiled artifact must be built from what the body provably is, not - * from what the declaration says it should be. Program-side use sites - * therefore typecheck against the INFERRED export surface; where the - * package's own JSDoc (usually written against that same .d.ts) types a - * boundary, the declared types flow in through inference and the runtime - * fences guard the sites the checker could not prove. + * silent trust). The .d.ts is deliberately DROPPED from module resolution: + * a declaration is a CLAIM about the body, and the compiled artifact must + * be built from what the body provably is, not from declaration-only + * values. One bounded part of that claim survives: complete non-generic + * overload groups over representation-safe types project as JSDoc onto + * their matching exported runtime class. Calls retain authored overload + * precision while the implementation body, its union ABI, and every + * runtime fence still come from JavaScript. * * MECHANISM. Resolution is the single lever: * - tsgo's server-side resolution reads package.json and probes sibling @@ -26,6 +26,10 @@ * @types twin is hidden whole — the bundler resolution then lands on * the shipped JS, which allowJs + maxNodeModuleJsDepth admit into the * program as checkable source. + * - Before that shadow is enabled, the package's own declaration entry + * and relative declaration barrels are scanned for safe overload groups. + * The runtime entry binds each class to its direct or one-hop re-export + * file; only that file receives the generated JSDoc projection. * - scriptc's own resolver (resolve.ts) mirrors the same answer: for an * opted-in package the types pass is skipped, the "types" export * condition is dropped, and the @types mangling never runs. @@ -56,10 +60,13 @@ import { dirname } from "node:path"; import { rewriteBundlerCjsExports } from "./npm-static-rewrite.js"; +import { applyNpmStaticDeclarationOverloads } from "./npm-static-declarations.js"; +import type { NpmStaticDeclarationOverloads } from "./npm-static-declarations.js"; import { npmPackageNameOf, registerWorkspacePackage, workspacePackageOfPath } from "./workspace-registry.js"; import { trackedExists, trackedReadFile, trackedRealpath } from "./input-tracker.js"; let activePackages: ReadonlySet = new Set(); +let declarationOverloads: ReadonlyMap = new Map(); /** Offender records of the CURRENT load attempt: packages whose static * compilation the preflight had to refuse, with the first reason. The @@ -72,12 +79,20 @@ const rewriteCache = new Map(); export function setNpmStaticPackages(packages: Iterable): void { activePackages = new Set(packages); + declarationOverloads = new Map(); offenders.clear(); rewriteCache.clear(); untypedPkgCache.clear(); realpathProbed.clear(); } +export function setNpmStaticDeclarationOverloads( + overloads: ReadonlyMap, +): void { + declarationOverloads = overloads; + rewriteCache.clear(); +} + export function npmStaticActive(): boolean { return activePackages.size > 0; } @@ -373,11 +388,16 @@ export function npmStaticFsShadow(): NpmStaticFsShadow | null { try { const source = trackedReadFile(path); if (source !== null) { - const answer = rewriteBundlerCjsExports(source, path); + const projected = applyNpmStaticDeclarationOverloads( + path, + source, + declarationOverloads.get(path.split("\\").join("/")) ?? new Map(), + ); + const answer = rewriteBundlerCjsExports(projected?.text ?? source, path); if (answer !== null && typeof answer === "object") { reportNpmStaticOffender(target.pkg, answer.degrade); } else { - rewritten = answer; + rewritten = answer ?? projected?.text ?? null; } } } catch { diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 61f5e190..ab1cad0e 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -52,7 +52,9 @@ import { } from "../diagnostics/diagnostic.js"; import { isNodeModulesPath, nearestInvalidPackageJsonPath, nearestPackageType, nearestPkgJsonPath, projectDtsRuntimeSibling, resolveBareModule, resolveProjectModule, resolveTypeDirective, setProjectPathMappings, setProjectRealm } from "./resolve.js"; import { probeNodeImportRefusal, probeNodeRequireRefusal } from "./npm.js"; -import { isNpmStaticPackage, npmStaticActive, npmStaticFsShadow, npmStaticPackageOfPath, reportNpmStaticOffender, setNpmStaticPackages } from "./npm-static.js"; +import { isNpmStaticPackage, npmStaticActive, npmStaticFsShadow, npmStaticPackageOfPath, reportNpmStaticOffender, setNpmStaticDeclarationOverloads, setNpmStaticPackages } from "./npm-static.js"; +import { npmStaticDeclarationReexports, npmStaticRuntimeClassTargets, parseNpmStaticDeclarationOverloads } from "./npm-static-declarations.js"; +import type { NpmStaticDeclarationOverloads, NpmStaticOverloadSignature } from "./npm-static-declarations.js"; import { provenanceEntryFor, provenancePaths } from "./provenance-registry.js"; import { cjsLexerVisibleNames } from "./cjs-lexer.js"; import { @@ -72,6 +74,7 @@ import { clearWorkspacePackages, isRelativeSpecifier, isWorkspacePackageName, + npmPackageNameOf, registerWorkspacePackage, workspacePackageOfPath, } from "./workspace-registry.js"; @@ -81,7 +84,7 @@ import { JS_ANY_OPERATOR_CODES, JS_RELAXED_TSC_CODES, } from "./tsc-codes.js"; -import { trackedFileExists } from "./input-tracker.js"; +import { trackedFileExists, trackedReadFile, trackedRealpath } from "./input-tracker.js"; const BASE_OPTIONS: ts.Ts7CompilerOptions = { strict: true, @@ -248,6 +251,73 @@ export interface LoadResult { projectWorld: () => ts.Program; } +function declarationReexportTarget(fromFile: string, specifier: string): string | null { + const base = resolve(dirname(fromFile), specifier); + const candidates: string[] = []; + const extension = /\.(mjs|cjs|js)$/.exec(base)?.[1]; + if (extension !== undefined) { + const stem = base.slice(0, -(extension.length + 1)); + if (extension === "mjs") candidates.push(`${stem}.d.mts`, `${stem}.d.ts`); + else if (extension === "cjs") candidates.push(`${stem}.d.cts`, `${stem}.d.ts`); + else candidates.push(`${stem}.d.ts`, `${stem}.d.mts`, `${stem}.d.cts`); + } else { + candidates.push(base, `${base}.d.ts`, `${base}.d.mts`, `${base}.d.cts`); + } + candidates.push( + resolve(base, "index.d.ts"), + resolve(base, "index.d.mts"), + resolve(base, "index.d.cts"), + ); + return candidates.find((candidate) => trackedFileExists(candidate)) ?? null; +} + +function runtimeReexportTarget(fromFile: string, specifier: string): string | null { + const base = resolve(dirname(fromFile), specifier); + const candidates = [ + base, + `${base}.js`, + `${base}.mjs`, + `${base}.cjs`, + resolve(base, "index.js"), + resolve(base, "index.mjs"), + resolve(base, "index.cjs"), + ]; + const target = candidates.find((candidate) => trackedFileExists(candidate) && isJsSourceFileName(candidate)); + return target === undefined ? null : (trackedRealpath(target) ?? target); +} + +/** Loads an opted package's own declaration entry and its relative barrel + * closure. This is intentionally narrower than TypeScript module + * resolution: bare edges name other packages and never inherit trust. */ +function npmStaticDeclarationOverloadsOf(entryPath: string): NpmStaticDeclarationOverloads { + const classes = new Map>(); + const seen = new Set(); + const packageJson = nearestPkgJsonPath(entryPath); + const packageRoot = (packageJson === null ? dirname(entryPath) : dirname(packageJson)).split("\\").join("/"); + const visit = (path: string): void => { + path = resolve(path); + const normalized = path.split("\\").join("/"); + if (normalized !== packageRoot && !normalized.startsWith(packageRoot + "/")) return; + if (seen.has(path)) return; + seen.add(path); + const source = trackedReadFile(path); + if (source === null) return; + for (const [className, methods] of parseNpmStaticDeclarationOverloads(path, source)) { + const target = classes.get(className) ?? new Map(); + classes.set(className, target); + for (const [methodName, signatures] of methods) { + if (!target.has(methodName)) target.set(methodName, signatures); + } + } + for (const specifier of npmStaticDeclarationReexports(path, source)) { + const target = declarationReexportTarget(path, specifier); + if (target !== null) visit(target); + } + }; + visit(entryPath); + return classes; +} + /** See LoadResult.startupCrash: Node's exact error message, the IR error * class that carries it (a RUNTIME_ERROR_CLASSES name — %Error for the * resolver's ERR_MODULE_NOT_FOUND family, %TypeError for invalid-specifier @@ -447,7 +517,45 @@ export function loadProgram( } externalTypes.set(specifier, declarationPath); } - setNpmStaticPackages(opts?.npmStatic ?? []); + const npmStaticPackages = [...new Set(opts?.npmStatic ?? [])]; + // Resolve authored declaration entries before enabling the JS-only + // package resolver. Safe overload metadata binds through the runtime + // entry to one exact implementation file; it never contributes values + // or executable module edges. + const declarationOverloads = new Map(); + for (const pkg of npmStaticPackages) { + const resolved = resolveBareModule(entryPath, pkg, "types-only"); + if ( + resolved === null || + resolved.packageName !== pkg || + !/\.d\.(?:ts|mts|cts)$/.test(resolved.typesFile) + ) { + continue; + } + const overloads = npmStaticDeclarationOverloadsOf(resolved.typesFile); + if (overloads.size === 0) continue; + const runtime = resolveBareModule(entryPath, pkg, "js-only"); + if (runtime === null || !isJsSourceFileName(runtime.typesFile)) continue; + const runtimeSource = trackedReadFile(runtime.typesFile); + if (runtimeSource === null) continue; + const targets = npmStaticRuntimeClassTargets(runtime.typesFile, runtimeSource, new Set(overloads.keys())); + for (const [className, specifier] of targets) { + const methods = overloads.get(className); + if (methods === undefined) continue; + const target = specifier === null ? runtime.typesFile : runtimeReexportTarget(runtime.typesFile, specifier); + if (target === null) continue; + const targetPackage = npmPackageNameOf(target); + const targetNorm = target.split("\\").join("/"); + const insideWorkspace = runtime.workspaceDir !== undefined && + targetNorm.startsWith(runtime.workspaceDir.split("\\").join("/") + "/"); + if (targetPackage !== pkg && !insideWorkspace) continue; + const byClass = new Map(declarationOverloads.get(targetNorm) ?? []); + byClass.set(className, methods); + declarationOverloads.set(targetNorm, byClass); + } + } + setNpmStaticPackages(npmStaticPackages); + setNpmStaticDeclarationOverloads(declarationOverloads); // 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/src/frontend/resolve.ts b/packages/compiler/src/frontend/resolve.ts index 5c1a9e43..47fb7909 100644 --- a/packages/compiler/src/frontend/resolve.ts +++ b/packages/compiler/src/frontend/resolve.ts @@ -766,8 +766,10 @@ export function resolveBareModule( fromFile: string, specifier: string, /** "js-only" forces the runtime-JS resolution regardless of the active - * --npm-static set (the auto-detection probe); default follows the set. */ - mode?: "js-only", + * --npm-static set (the auto-detection probe); "types-only" forces the + * unshadowed declaration pass for overload-overlay discovery; default + * follows the active set. */ + mode?: "js-only" | "types-only", ): BareResolution | null { const pkgName = packageNameOfSpecifier(specifier); const rest = specifier.slice(pkgName.length).replace(/^\//, ""); @@ -775,7 +777,7 @@ export function resolveBareModule( // An opted-in --npm-static package resolves to its RUNTIME JS: the js // pass only, the "types" export condition dropped, the @types mangling // never consulted — mirroring the shadowed world the tsgo host serves. - const npmStatic = mode === "js-only" || isNpmStaticPackage(pkgName); + const npmStatic = mode === "js-only" || (mode !== "types-only" && isNpmStaticPackage(pkgName)); const conditions = npmStatic ? JS_ONLY_CONDITIONS : EXPORT_CONDITIONS; const inPackage = (nmPkgDir: string, name: string, pass: ResolutionPass): BareResolution | null => { @@ -859,6 +861,7 @@ export function resolveBareModule( } }; + if (mode === "types-only") return passOnce("types"); return npmStatic ? passOnce("js") : (passOnce("types") ?? passOnce("js")); } diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index bde285f7..b0b7cd51 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -790,11 +790,11 @@ function runFrontend( preflight = checkPreflight(load); } // The last resort, ALL modes: an opt-in can change the PROGRAM's OWN - // typecheck through errors that name no package at all (the inferred - // surface replaces the shipped .d.ts — the commander name()/description() - // chaining shape, or a .d.ts type-GUARD an inferred JS function cannot - // reproduce, so every catch-clause narrowing site reports "'err' is of - // type 'unknown'"). Those SC0001s anchor in USER files no offender or + // typecheck through errors that name no package at all (declaration + // shapes outside the safe overload projection — generic overloads, + // declaration-only members, or a .d.ts type-GUARD an inferred JS + // function cannot reproduce). Those SC0001s anchor in USER files no + // offender or // message attribution reaches, so each remaining package is probed // ALONE-dropped (n is the opt-in count — a handful of extra analysis // loads); culprits whose removal clears the errors fall back with a diff --git a/scripts/test-ts7.mjs b/scripts/test-ts7.mjs index 4ecc1b86..66f0248f 100644 --- a/scripts/test-ts7.mjs +++ b/scripts/test-ts7.mjs @@ -10,6 +10,7 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const ALLOWED_TYPESCRIPT5_IMPORTS = new Set([ "packages/compiler/src/frontend/cjs-lexer.ts", "packages/compiler/src/frontend/lowering/lower-comptime.ts", + "packages/compiler/src/frontend/npm-static-declarations.ts", "packages/compiler/src/frontend/npm-static-rewrite.ts", "packages/compiler/src/frontend/npm.ts", "packages/compiler/src/frontend/provenance.ts", diff --git a/tests/fixtures/commander-calc/calc-npm-static.ts b/tests/fixtures/commander-calc/calc-npm-static.ts index fb92af18..10099638 100644 --- a/tests/fixtures/commander-calc/calc-npm-static.ts +++ b/tests/fixtures/commander-calc/calc-npm-static.ts @@ -1,15 +1,15 @@ // The --npm-static commander probe: the same CLI shape as calc.ts, written // against commander's INFERRED surface (no .d.ts in the program — the -// getter/setter JSDoc unions mean no chaining through name()/description(), -// and action callbacks annotate their own params). npm-static.test.ts +// package declarations provide safe getter/setter overloads while action +// callbacks annotate their own params). npm-static.test.ts // asserts the coverage numbers; the binary builds fully static and traps at // the first driven runtime fence. Implicit-any monomorphization carried the // typed-value → untyped-param boundary (_registerCommand and its local // knownBy helper instantiate per argument types now); the frontier behind -// it, pinned: getter/setter JSDoc union returns (`cmd.name()` is typed -// string | Command inside knownBy's body) and implicit-any FIELD writes of -// class instances (`cmd.parent = this` — parent inferred `any` from its -// constructor null, and a Command cannot ride the checked-dynamic slot). +// it, pinned: safe package-maintained overload groups refine calls such as +// `cmd.name()`, while implicit-any FIELD writes of class instances remain +// (`cmd.parent = this` — parent inferred `any` from its constructor null, +// and a Command cannot ride the checked-dynamic slot). import { Command } from "commander"; const program = new Command(); program.name("calc"); diff --git a/tests/fixtures/npm-static/chainy-cli.ts b/tests/fixtures/npm-static/chainy-cli.ts index 43f03ae0..1e229db9 100644 --- a/tests/fixtures/npm-static/chainy-cli.ts +++ b/tests/fixtures/npm-static/chainy-cli.ts @@ -1,7 +1,7 @@ -// Chained getter/setter calls typed by the package's own .d.ts overloads: -// the auto opt-in drops chainy back to the island (its INFERRED surface -// types name() as `string | Chainy`, breaking these chains) — the program -// must stay analyzable either way. +// Chained getter/setter calls typed by the package's own .d.ts overloads. +// --npm-static projects the safe overload groups onto the matching runtime +// JavaScript class, preserving the authored call surface while compiling +// and validating the actual implementation body. import { Chainy } from "chainy"; const c = new Chainy(); diff --git a/tests/fixtures/npm-static/node_modules/chainy/index.js b/tests/fixtures/npm-static/node_modules/chainy/index.js index 40b83f5e..0abd137b 100644 --- a/tests/fixtures/npm-static/node_modules/chainy/index.js +++ b/tests/fixtures/npm-static/node_modules/chainy/index.js @@ -1,7 +1,8 @@ // The commander shape in miniature: a getter/setter method whose declared // OVERLOADS (index.d.ts) say `name(): string` / `name(v): this`, while the -// JS body infers the union `string | Chainy` — chained program-side calls -// typecheck against the .d.ts and FAIL against the inferred surface. +// JS body infers the union `string | Chainy`. --npm-static projects these +// safe overload groups onto this exported runtime class before TS 7 parses +// it, preserving precise calls while compiling the body below. 'use strict'; class Chainy { diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index cde9fd78..427542cf 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -183,10 +183,10 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { // differential on the island lane (npm.test.ts) for now. Implicit-any // monomorphization moved the driven frontier BEHIND the typed-value → // untyped-param boundary (methods like _registerCommand and local - // helpers like knownBy now instantiate per argument types); the next - // fences are implicit-any FIELD writes of class instances (`cmd.parent - // = this` — the field inferred `any` from its ctor null) and - // getter/setter JSDoc union returns (`cmd.name()` → string | Command). + // helpers like knownBy now instantiate per argument types). Safe + // package-maintained overload groups preserve getter/setter results such + // as `cmd.name()` → string; the next fences include implicit-any FIELD + // writes of class instances (`cmd.parent = this`) and wider unknown flows. test("commander compiles static at the pinned coverage floor", () => { const { coverage } = analyze(join(fixturesRoot, "commander-calc/calc-npm-static.ts"), { npmStatic: ["commander"], @@ -198,6 +198,8 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { const failed = coverage.stats.statementsFailed + (coverage.unreached?.stats.statementsFailed ?? 0); expect(total).toBeGreaterThan(1000); // the whole package joined the program expect((total - failed) / total).toBeGreaterThanOrEqual(0.85); + expect(total - failed).toBeGreaterThanOrEqual(1062); + expect(coverage.runtimeFences?.length ?? 0).toBeLessThanOrEqual(115); }, 180_000); // Tier 3: the island fallback — esbundled's chunk requires "net", an @@ -216,24 +218,26 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { expect(coverage.preflightFailed).toBe(false); }, 120_000); - // AUTO drops a package whose opt-in breaks the PROGRAM's own typecheck - // (the .d.ts-overload vs inferred-surface gap — commander's chaining - // shape in miniature): chainy's declared `name(): string / name(v): - // this` overloads admit the chained spelling, its inferred surface - // (`string | Chainy`) does not, so auto answers fallback with the - // inferred-surface note and the program stays analyzable. Explicit - // opt-ins keep the errors (the user asked for exactly that package). - test("auto falls back when the program fails against an inferred surface", () => { - const { coverage } = analyze(join(pilotRoot, "chainy-cli.ts"), { npmStatic: "auto" }); - expect(coverage.npmStatic).toEqual([ - { - package: "chainy", - status: "fallback", - detail: "auto: the program does not typecheck against its inferred surface", - }, - ]); + // A package-maintained .d.ts may state overloads its readable JS body + // cannot infer. chainy is commander's getter/setter shape in miniature: + // name()/tag() return strings with no argument and `this` with one. The + // safe declaration groups project into JSDoc on the matching runtime + // class, so the body still compiles from JS while calls keep the authored + // surface — no island fallback and no unchecked declaration-only value. + test("auto preserves safe declaration overloads while compiling the JavaScript body", async () => { + const entry = join(pilotRoot, "chainy-cli.ts"); + const { coverage } = analyze(entry, { npmStatic: "auto" }); + expect(coverage.npmStatic).toEqual([{ package: "chainy", status: "static" }]); expect(coverage.preflightFailed).toBe(false); - }, 120_000); + expect(coverage.stats.statementsFailed).toBe(0); + const binary = await buildStatic(entry, "auto"); + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry]), + runBinary(binary, []), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.exitCode).toBe(nodeRes.exitCode); + }, 180_000); // Non-opted UNTYPED node_modules packages keep the checked-dynamic // surface: maxNodeModuleJsDepth (active on every --npm-static load)