Keep the .d.ts-only types of a package compiled from its JavaScript (#1729)

This commit is contained in:
Chris Tate
2026-10-22 00:38:08 -05:00
committed by GitHub
27 changed files with 902 additions and 2 deletions
@@ -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"]);
});
@@ -0,0 +1,238 @@
/** 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 { NPM_STATIC_TYPES_SUFFIX } from "./npm-static.js";
import type { FrontendServices } from "./services.js";
import * as ts from "./ts7/syntax.js";
import {
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<string, string>;
/** The typedef block to append, by runtime entry (forward slashes). */
typedefs: Map<string, string>;
}
/** 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<string> {
const names = new Set<string>();
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<string, string>();
const typedefs = new Map<string, string>();
const parsed = new Map<string, ts.SourceFile | null>();
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<string, string>();
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);
};
const exportsOf = new Map<string, NpmStaticDeclarationExports | null>();
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<string>,
): { types: Map<string, Found>; values: Set<string> } => {
const result = { types: new Map<string, Found>(), values: new Set<string>() };
if (seen.has(file)) return result;
seen.add(file);
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, decl: { ...t.decl, 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;
};
// 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.
//
// `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<string, boolean>();
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<string, string | null>([[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;
}
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");
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);
// 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),
)
.map(({ decl }) => decl);
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 };
}
@@ -0,0 +1,125 @@
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<T extends object = {}, U = string> { 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 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<T> { key: string; value: T; next?: Row<T>; tags: Tag[] }
type Tag = "a" | "b";
interface Counter { add(value: number): void }
type Callback = (row: Row<string>) => void;
type Keys = keyof Row<string>;
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';
export type Box = import("./paging").Page<number>;
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<number>;
export { readFile } from "fs";
`);
});
test("the typedef block re-exports each type with its parameters", () => {
expect(
npmStaticTypedefBlock(
[
{
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",
),
).toBe(`/**
* @typedef {import("./index.scriptc-types").Mode} Mode
*/
/**
* @template {object} [T={}]
* @template [U=string]
* @typedef {import("./index.scriptc-types").Options<T, U>} Options
*/`);
});
@@ -0,0 +1,287 @@
/* 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 (`<file>.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<T>} Box * /
*
* Classes and values keep the JavaScript's own declarations. */
import * as ts from "./ts7/syntax.js";
import { tokenStartOfNode } from "./ts7/node-functions.js";
/** 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. */
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"`, 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<string, NpmStaticTypeDeclaration>;
/** The file's named imports: local name to imported name and specifier. */
imports: ReadonlyMap<string, { local: string; specifier: string }>;
/** Every class, function, namespace and variable the file declares. */
valueNames: ReadonlySet<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)}]`;
});
}
/** Syntax kinds of a type that describes behavior or is computed. */
const BEHAVIOR_KINDS: ReadonlySet<ts.SyntaxKind> = 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<string>();
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[] = [];
const values: string[] = [];
const named: { exported: string; local: string; specifier: string | null }[] = [];
const stars: string[] = [];
const localTypes = new Map<string, NpmStaticTypeDeclaration>();
const localValues = new Set<string>();
// `import { A as B } from "./x"`: local name B is A of "./x".
const imported = new Map<string, { local: string; specifier: string }>();
for (const stmt of sf.statements.nodes) {
const exported = isExported(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 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,
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 && merged === undefined) types.push(decl);
} else if (ts.isEnumDeclaration(stmt)) {
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 (
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, 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) {
resolvedNamed.push(n);
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 });
}
// 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
* 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");
}
+40 -1
View File
@@ -16,6 +16,7 @@ import {
} from "./workspace-registry.js";
import {
clearVirtualSources,
isVirtualSource,
setVirtualSource,
trackedExists,
trackedReadFile,
@@ -53,10 +54,43 @@ 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<string, string>();
/** 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<string, string>,
typedefs: ReadonlyMap<string, string>,
): void {
for (const [path, text] of copies) setVirtualSource(path, text);
typeExportTypedefs = new Map(typedefs);
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<T extends Iterable<string>>(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 +558,17 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
} catch {
rewritten = null; // unreadable/unparseable: fall through untouched
}
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.
if (path.endsWith(NPM_STATIC_TYPES_SUFFIX) && isVirtualSource(path)) return false;
const target = shadowTargetOf(path);
if (!target) return false;
if (isBundledShimForwarder(path)) return false;
@@ -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
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7001-package-declared-types/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7004-package-declared-type-reexports/main.ts"
],
"diags": []
}
@@ -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<Issue> = { 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);
@@ -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<T>(page: Page<T>): T | undefined;
@@ -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];
}
@@ -0,0 +1,4 @@
export interface Page<T> {
items: T[];
next: string | null;
}
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1 @@
{ "name": "issue-list", "version": "1.0.0", "main": "./lib/index.js", "types": "./lib/index.d.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);
@@ -0,0 +1,5 @@
export interface Row {
key: string;
value: string;
}
export declare function readRows(text: string, into: Row[]): number;
@@ -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;
}
@@ -0,0 +1 @@
{ "name": "row-module", "version": "1.0.0", "main": "./lib/index.mjs", "types": "./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;
@@ -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)) });
}
}
@@ -0,0 +1,5 @@
export type Kind = "circle" | "square";
export interface Shape {
kind: Kind;
size: number;
}
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1 @@
{ "name": "shape-kinds", "version": "1.0.0", "main": "./lib/index.js", "types": "./lib/index.d.ts" }
@@ -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
@@ -0,0 +1 @@
{ "plain": 2683, "san": 1176 }
@@ -0,0 +1 @@
{ "plain": 1015, "san": 2516 }
+13
View File
@@ -506,6 +506,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;