feat(compiler): preserve npm static declaration overloads (#329)

This commit is contained in:
Chris Tate
2026-09-15 08:53:25 -05:00
committed by GitHub
parent 5a782a6150
commit 5efda821cf
12 changed files with 671 additions and 54 deletions
+1
View File
@@ -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
@@ -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<T>(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],
]));
});
});
@@ -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<string, readonly NpmStaticOverloadSignature[]>
>;
export interface NpmStaticOverloadRewrite {
text: string;
insertions: readonly { offset: number; length: number }[];
}
const SAFE_KEYWORD_TYPES = new Set<ts.SyntaxKind>([
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<string, ReadonlyMap<string, readonly NpmStaticOverloadSignature[]>>();
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<string, ts.MethodDeclaration[]>();
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<string, readonly NpmStaticOverloadSignature[]>();
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<string>,
): ReadonlyMap<string, string | null> {
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<string, { imported: string; specifier: string }>();
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<string, string | null>();
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<string> {
const names = new Set<string>();
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 })),
};
}
+30 -10
View File
@@ -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<string> = new Set();
let declarationOverloads: ReadonlyMap<string, NpmStaticDeclarationOverloads> = 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<string, string | null>();
export function setNpmStaticPackages(packages: Iterable<string>): void {
activePackages = new Set(packages);
declarationOverloads = new Map();
offenders.clear();
rewriteCache.clear();
untypedPkgCache.clear();
realpathProbed.clear();
}
export function setNpmStaticDeclarationOverloads(
overloads: ReadonlyMap<string, NpmStaticDeclarationOverloads>,
): 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 {
+111 -3
View File
@@ -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<string, Map<string, readonly NpmStaticOverloadSignature[]>>();
const seen = new Set<string>();
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<string, readonly NpmStaticOverloadSignature[]>();
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<string, NpmStaticDeclarationOverloads>();
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
+6 -3
View File
@@ -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"));
}
+5 -5
View File
@@ -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
+1
View File
@@ -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",
+6 -6
View File
@@ -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");
+4 -4
View File
@@ -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();
+3 -2
View File
@@ -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 {
+25 -21
View File
@@ -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)