Add the shape overlay: typed JavaScript from analysis-proven facts (opt-in) (#2176)

This commit is contained in:
Chris Tate
2026-10-26 11:33:47 -05:00
committed by GitHub
39 changed files with 1703 additions and 35 deletions
+6
View File
@@ -144,6 +144,11 @@ async function tryFastPath(): Promise<number | null> {
...(values["cpu-dispatch"] === true ? { cpuDispatch: true as const } : {}),
...(checker === undefined ? {} : { checker }),
npmStatic,
// Mirrors the full compiler's key: shape inference's overlay is opt-in
// (SCRIPTC_SHAPE_OVERLAY=1) and --no-shape-overlay turns it off.
...(process.env["SCRIPTC_SHAPE_OVERLAY"] === "1" && values["shape-overlay"] !== false
? { shapeOverlay: true as const }
: {}),
ffiProfile: ffiPath === null ? null : { path: ffiPath, bytes: ffiBytes! },
target: `${process.env["SCRIPTC_TARGET"] ?? "native"}:${buildPlatform}:${process.arch}:${
helperObjectRoute ? "runtime-pack" : "driver-tu"
@@ -231,6 +236,7 @@ async function coverageFastPath(
values["provenance-sources"] ||
values["emit-ir"] ||
(values["npm-static"] ?? []).length > 0 ||
values["shape-overlay"] === false ||
(values["external-types"] ?? []).length > 0 ||
// The replay covers the default Node island policy only.
hasNodeIslandFlags(values)
@@ -409,12 +409,24 @@ export function emitSerializationExpr(
}
const dynV = emitBorrowedInput(host, e.value);
const helper = host.dyn.dynCheckHelper(e.type, e.preserveRefs);
if (isDynTypedRefType(e.type)) return emitClassCheck(host, e.type, dynV.name, helper, note);
// A check of an analysis-proven fact (shape inference's overlay)
// names its site, so a failure reports a compiler bug.
const site = e.inferredSite !== undefined ? host.cstr(e.inferredSite) : null;
if (site !== null) {
host.declare(`declare void @scr_dyn_inferred_site(ptr)`);
B.line(`call void @scr_dyn_inferred_site(ptr ${site})`);
}
if (isDynTypedRefType(e.type)) {
const checked = emitClassCheck(host, e.type, dynV.name, helper, note);
if (site !== null) B.line(`call void @scr_dyn_inferred_site(ptr null)`);
return checked;
}
const ty = host.llType(e.type);
const t = B.tmp();
B.line(
`${t} = call ${ty === "i1" ? "zeroext i1" : ty} @${helper}(ptr ${dynV.name}, ptr null)`,
);
if (site !== null) B.line(`call void @scr_dyn_inferred_site(ptr null)`);
const out = host.own({ name: t, type: e.type });
host.emitPendingCheck(undefined, false, undefined, note);
return out;
+1
View File
@@ -641,6 +641,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
? {}
: { profileUse: resolve(values["profile-use"]) }),
...(npmStatic !== undefined ? { npmStatic } : {}),
...(values["shape-overlay"] === false ? { shapeOverlay: false } : {}),
...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}),
...(printNativeLinkInfo ? { nativeLinkInfo: true } : {}),
...(tier !== undefined ? { tier } : {}),
+5
View File
@@ -104,6 +104,10 @@ Options:
"pkg..." also names every package pkg depends on).
Packages that fail preflight are refused with a
diagnostic. Eligible dependencies compile automatically.
--no-shape-overlay
compile npm packages' JavaScript without the types shape
inference proves (per package: "scriptc": {"packages":
{"<name>": {"shapes": false}}} in package.json)
--provenance-sources
EXPERIMENTAL: compile npm dependencies from their
provenance-attested SOURCE (fetched at the attested
@@ -180,6 +184,7 @@ export const CLI_OPTIONS = {
sanitize: { type: "boolean", default: false },
ffi: { type: "string" },
"npm-static": { type: "string", multiple: true },
"shape-overlay": { type: "boolean" },
"provenance-sources": { type: "boolean", default: false },
"external-types": { type: "string", multiple: true },
tier: { type: "string" },
+4
View File
@@ -67,6 +67,10 @@ export interface CompileBaseOptions {
/** Eligible packages compile automatically. Explicit names override automatic selection heuristics. */
npmStatic?: readonly string[] | "auto";
/** Shape inference's typed overlay for opted-in packages' JavaScript.
* False disables it (`--no-shape-overlay`); a package opts out with
* `"scriptc": { "packages": { "<name>": { "shapes": false } } }`. */
shapeOverlay?: boolean;
/** Outbound native FFI manifest. Its signature-only TypeScript bindings
* lower to direct C ABI calls. Source outputs retain those declarations;
* archive/system-library inputs join only an executable link. */
@@ -64,7 +64,10 @@
* parses date strings with V8's grammar. SC61xx are performance
* notes, also warnings (coverage --fail-on=dynamic opts in):
* likely-hot functions that use untyped values (SC6101,
* diagnostics/dynamic-tier.ts)
* diagnostics/dynamic-tier.ts), package files whose projected
* declaration types were dropped (SC6102), and shape inference's
* overlay report: what it typed and how many inferred facts the
* build checks (SC6103, frontend/shape-inference)
* SC8xxx Node island policy: packages and modules outside the native
* subset that run (or, in report-only mode, would run) in a
* Node island — unknown project configuration (SC8004), a
@@ -165,6 +165,9 @@ export interface EarlyExecutableCacheOptions {
/** Omitted unless SCRIPTC_YOUNG_NURSERY=1 (backend/young-nursery.ts). */
youngNursery?: true;
npmStatic: readonly string[] | "auto" | null;
/** Omitted unless shape inference's overlay compiles the packages
* (frontend/shape-inference). */
shapeOverlay?: true;
/** Raw manifest identity: path and bytes. Native archives remain under the
* stricter native cache's independent dependency validation. */
ffiProfile: { path: string; bytes: Uint8Array } | null;
@@ -1,3 +1,4 @@
import { shapeOverlayAnnotated } from "../shape-inference/index.js";
import {
typedArgumentsCandidate,
typedArgumentsShapes,
@@ -181,6 +182,12 @@ export function checkedJsDocumentedParameter(
lowerer: Lowerer,
param: ts.ParameterDeclaration,
): boolean {
if (
param.parent !== undefined &&
shapeOverlayAnnotated(param.parent) &&
documentedParameter(param)
)
return true;
return lowerer.checkedJsFile(ts.getSourceFileOfNode(param)) && documentedParameter(param);
}
@@ -436,6 +436,22 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
loc,
};
}
// Plain data (records, arrays, strings, numbers, booleans, and unions
// of them) is never a promise: false after the operand's evaluation.
const neverPromise = (t: IrType): boolean =>
NEVER_PROMISE_KINDS.has(t.kind) ||
(t.kind === "union" && (lowerer.unions.get(t.unionId)?.arms ?? [t]).every(neverPromise));
if (neverPromise(left.type)) {
const no: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc };
if (isSafeToDiscard(left)) return no;
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: left, loc }],
result: no,
type: BOOL,
loc,
};
}
lowerer.unsupported(
"SC1090",
expr,
@@ -2275,3 +2291,20 @@ function nodeDeclaredMember(lowerer: Lowerer, t: ts.Type, key: string): boolean
);
});
}
const NEVER_PROMISE_KINDS: ReadonlySet<IrType["kind"]> = new Set([
"record",
"array",
"string",
"f64",
"bool",
"bigint",
"date",
"map",
"set",
"regex",
"bytes",
"symbol",
"nullT",
"undefinedT",
]);
@@ -1,3 +1,5 @@
import { shapeOverlayAnnotated } from "../shape-inference/index.js";
import { documentedParameter } from "./call-signatures.js";
import { staticNameBindings } from "./static-function-names.js";
import { positionTag } from "./structural-positions.js";
import { keyedHelperName } from "./stable-names.js";
@@ -1863,10 +1865,14 @@ export function implicitAnyParamSymbolsOf(
// The variadic-`arguments` form keeps its dynRest story whole.
if (bodyReadsArguments(decl)) return null;
let hasImplicitParams = false;
const overlayTyped = shapeOverlayAnnotated(decl);
const out = decl.parameters.nodes.map((param): ts.Symbol | null => {
if (!ts.isIdentifier(param.name)) return null;
if (param.dotDotDotToken || param.questionToken || param.initializer) return null;
if (param.name.text === "this") return null;
// A parameter typed by shape inference's overlay is a proven type,
// not an implicit type parameter.
if (overlayTyped && documentedParameter(param)) return null;
const t = lowerer.typeOf(param.name);
const broadFunction = lowerer.checker.typeToString(t) === "Function";
const broadArray = lowerer.checkerAnyArrayType(t);
@@ -1,4 +1,5 @@
import { lowerAsyncLocalStorageOptions } from "./builtins/async-context.js";
import { shapeOverlayAnnotated } from "../shape-inference/index.js";
import {
DATE_TIME_FIELD,
dateRootOf,
@@ -1181,7 +1182,14 @@ function jsFieldInitializerType(lowerer: Lowerer, type: IrType, expression: ts.E
)
return DYN;
}
if (type.kind === "array" || type.kind === "record") return DYN;
// Shape inference's overlay types a field as a record or array only when
// the analysis proves its values never reach code that could observe a
// copy, so those fields keep their native layout.
if (
(type.kind === "array" || type.kind === "record") &&
!shapeOverlayAnnotated(expression.parent?.parent ?? expression)
)
return DYN;
// JavaScript documentation cannot guarantee that a call or an indexed
// read returns a present number. Numeric fields receiving those values
// must retain undefined, so later arithmetic performs JS coercion (NaN).
@@ -3374,6 +3382,7 @@ export function collectClassShapeInner(
!functionLocalClass(decl) &&
ts.isIdentifier(member.name) &&
!lowerer.virtualJsMethods.has(member) &&
!lowerer.checkedJsOverrideMethods.has(member) &&
!lowerer.prototypeMethodAccesses.has(member.name.text) &&
inst === undefined &&
decl.typeParameters === undefined &&
@@ -7692,6 +7701,16 @@ export function collectVirtualJsMethods(lowerer: Lowerer, files: readonly ts.Sou
if (!ts.isIdentifier(member.name)) continue;
const property = lowerer.checker.getPropertyOfType(base, member.name.text);
for (const declaration of property ? property.declarations : []) {
if (
ts.isMethodDeclaration(declaration) &&
isJsSourceFile(ts.getSourceFileOfNode(declaration)) &&
checkedJsOverride(lowerer, declaration, member)
) {
// A checked override pair dispatches through the vtable,
// so neither side may become an implicit generic.
lowerer.checkedJsOverrideMethods.add(declaration);
lowerer.checkedJsOverrideMethods.add(member);
}
if (
ts.isMethodDeclaration(declaration) &&
isJsSourceFile(ts.getSourceFileOfNode(declaration)) &&
@@ -7778,7 +7797,7 @@ function checkedJsOverride(
): boolean {
return [base, member].every(
(method) =>
lowerer.checkedJsFile(ts.getSourceFileOfNode(method)) &&
(lowerer.checkedJsFile(ts.getSourceFileOfNode(method)) || shapeOverlayAnnotated(method)) &&
method.parameters.nodes.every(documentedParameter),
);
}
@@ -1,3 +1,5 @@
import { markInferredFactChecks, shapeOverlayFileNames } from "../shape-inference/index.js";
import { npmStaticPackageOfPath } from "../npm-static.js";
import { globalMemberValue } from "./global-values.js";
import { unionNarrowFailureMessage } from "./union-narrow.js";
import {
@@ -3301,6 +3303,8 @@ export class Lowerer {
/** Inferred JS methods participating in an override chain keep a vtable
* ABI instead of call-site specialization. Filled before class collection. */
readonly virtualJsMethods = new Set<ts.MethodDeclaration>();
/** Methods of checked-JavaScript override pairs (vtable dispatch). */
readonly checkedJsOverrideMethods = new Set<ts.MethodDeclaration>();
/** Descendant writes must widen the original JS field before collecting
* any class layout; base and derived instances share its storage ABI. */
readonly inheritedJsFieldWrites = new Map<
@@ -8708,6 +8712,7 @@ export class Lowerer {
// reference are untouched — byte-stability holds.
checkRequireBindingAccesses(this, functions);
refuseX509DynValues(this, functions);
markInferredFactChecks(functions, this.program);
const module: IrModule | null =
this.diags.length > 0
? null
@@ -8723,6 +8728,13 @@ export class Lowerer {
...(this.usesWorkers ? { workers: true } : {}),
...(this.usesLifetimeRegions ? { lifetimeRegions: true as const } : {}),
...(this.declBoundary ? { declBoundary: true as const } : {}),
...(shapeOverlayFileNames().length > 0
? {
inferredFactFiles: shapeOverlayFileNames()
.map((file) => ({ file, pkg: npmStaticPackageOfPath(file) ?? "" }))
.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0)),
}
: {}),
...(this.foreignElementKeys.size > 0
? { foreignElementKeys: [...this.foreignElementKeys].sort() }
: {}),
@@ -205,14 +205,26 @@ export function packageShimEntries(): string[] {
let unloadedPackages: ReadonlySet<string> = new Set();
/** Reads the project's package-shim configuration: the `scriptc.packages`
* entries marked "unloaded" in the entry's nearest package.json
* (project-config.ts validates the rest). Called once per program load. */
* entries marked "unloaded", and those with `"shapes": false`, in the
* entry's nearest package.json (project-config.ts validates the rest).
* Called once per program load. */
export function configurePackageShims(entryPath: string): void {
const packages = new Set<string>();
const noShapes = new Set<string>();
for (const [name, setting] of readProjectConfig(entryPath).packages) {
if (setting.unloaded) packages.add(name);
if (!setting.shapes) noShapes.add(name);
}
unloadedPackages = packages;
shapelessPackages = noShapes;
}
let shapelessPackages: ReadonlySet<string> = new Set();
/** Packages the project compiles without shape inference's overlay
* (`"scriptc": { "packages": { "<name>": { "shapes": false } } }`). */
export function isShapelessPackage(name: string): boolean {
return shapelessPackages.has(name);
}
/** True when `--npm-static` leaves the package `name` out of its package
+6 -2
View File
@@ -1,3 +1,4 @@
import { shapeOverlayReport } from "./shape-inference/index.js";
/** Shared frontend orchestration. Hosts supply a program loader so Node
* and native callers use the same npm selection, preflight and lowering. */
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
@@ -801,8 +802,11 @@ function loadFrontend(
});
// The declaration-typed boundary's notes (SC6102) join the
// performance warnings; they never fail a build.
const notes = finalLoad.declBoundaryNotes;
if (notes === undefined) return lowered;
const notes = [
...(finalLoad.declBoundaryNotes ?? []),
...shapeOverlayReport(finalLoad.entry.fileName),
];
if (notes.length === 0) return lowered;
return { ...lowered, performance: [...notes, ...(lowered.performance ?? [])] };
},
npmStatic: statuses,
+30 -1
View File
@@ -148,7 +148,12 @@ import {
} from "./decl-projection.js";
import { configureTypePatches, packageSourceText } from "./type-patches/layer.js";
import { resetDeclOriginVersions } from "./lowering/decl-origin.js";
import { reportShapes } from "./shape-inference/index.js";
import {
reportShapes,
resetShapeOverlay,
shapeOverlayEnabled,
shapeOverlayer,
} from "./shape-inference/index.js";
import { inferredJsDiagnosticSuppressed } from "./inferred-js-diagnostics.js";
import {
containsJsx,
@@ -884,6 +889,9 @@ function loadProgram7(
/** The declaration-typed boundary's projection (decl-projection.ts):
* answers rewritten user files for the lowering world, or null. */
projector?: (program: ts.Program) => DeclProjection | null,
/** Shape inference's typed overlay (shape-inference/overlay.ts):
* rewritten package JavaScript for the lowering world, or null. */
overlayer?: (program: ts.Program) => ReadonlyMap<string, string> | null,
): LoadResult & { disposeAll: () => void } {
const config = adoptProjectConfig7(host, entryPath);
const configuredPaths = config.options["paths"];
@@ -1036,6 +1044,26 @@ function loadProgram7(
}
}
}
// Shape inference's overlay: package files typed from proven facts. The
// tsc gate keeps reading the files as written.
// A load without the overlay must not see an earlier load's overlay
// files (the registry is per process).
resetShapeOverlay();
if (overlayer !== undefined && jsx.refusal === null) {
const overlay = overlayer(program);
if (overlay !== null && overlay.size > 0) {
const before = program;
for (const [file, text] of overlay) host.addVirtualFile(file, text);
program = host.createProgram([...programRoots, overridesDtsPath()], options);
if (tscWorld === undefined)
tscWorld = {
program: before,
diagnostics: ts.getPreEmitDiagnostics(before),
projectWorld: null,
};
else if (tscWorld.program !== before) before.dispose();
}
}
const entry = program.getSourceFile(entryPath);
if (!entry) throw new Error(`could not load ${entryPath}`);
const externalTypeSpecifiersByFile = externalTypeFileClosure7(program, externalTypes);
@@ -1303,6 +1331,7 @@ export function loadProgram(
externalTypes,
npmStaticPackages.length > 0,
projector,
npmStaticPackages.length > 0 && shapeOverlayEnabled() ? shapeOverlayer : undefined,
);
// Shape inference, stage S1: a report only (SCRIPTC_DEBUG_SHAPES).
const shapes = process.env["SCRIPTC_DEBUG_SHAPES"];
@@ -70,6 +70,8 @@ export interface LiteralSite {
readonly kind: "object" | "array";
/** Static keys in creation order; null when a spread or computed key makes them unknown. */
readonly keys: string[] | null;
/** The literal's own static keys (without spread contributions). */
readonly ownKeys: string[];
readonly hasAccessor: boolean;
readonly hasProto: boolean;
readonly hasComputedKey: boolean;
@@ -129,6 +131,17 @@ export interface ShapeAnalysis {
readonly escapedFunctions: ReadonlySet<FunctionInfo>;
/** ES5 constructors: each function's default prototype object. */
readonly protoOf: ReadonlyMap<Atom, Atom>;
/** ES5 constructor instances: the function atom whose `new` made each. */
readonly fnOfInstance: ReadonlyMap<Atom, Atom>;
/** Every value a declaration (parameter, variable, binding element) holds,
* merged over the analysis contexts it was analyzed in. */
declValues(decl: Node): Set<Atom>;
/** The classes a class declaration's chain contains, base last. */
classChain(decl: Node): Node[];
/** `if` statements in walked code whose condition is known: the branch
* whose condition values never allowed it is absent from `walkedBranches`. */
readonly branchIfs: ReadonlySet<ts.IfStatement>;
readonly walkedBranches: ReadonlySet<Node>;
}
/** Array.prototype members: other names on an array are own properties. */
@@ -238,6 +251,47 @@ function isLoop(node: Node): boolean {
);
}
/** Whether control can reach the end of `stmts`. False only when every
* path ends in a return or throw, or in a `while (true)`, `do … while
* (true)` or `for (;;)` loop with no break inside; anything else is
* assumed to complete. */
function canComplete(stmts: readonly Node[]): boolean {
return stmts.every(canCompleteStatement);
}
function canCompleteStatement(s: Node): boolean {
if (ts.isReturnStatement(s) || ts.isThrowStatement(s)) return false;
if (ts.isBlock(s)) return canComplete(list(s.statements));
if (ts.isIfStatement(s))
return (
s.elseStatement === undefined ||
canCompleteStatement(s.thenStatement) ||
canCompleteStatement(s.elseStatement)
);
if (ts.isTryStatement(s)) {
if (s.finallyBlock !== undefined && !canCompleteStatement(s.finallyBlock)) return false;
return (
canCompleteStatement(s.tryBlock) ||
(s.catchClause !== undefined && canCompleteStatement(s.catchClause.block))
);
}
const endless =
((ts.isWhileStatement(s) || ts.isDoStatement(s)) &&
s.expression.kind === ts.SyntaxKind.TrueKeyword) ||
(ts.isForStatement(s) && s.condition === undefined);
return !endless || containsBreak(s);
}
/** Whether a break statement occurs in `node`'s body, outside nested
* functions. */
function containsBreak(node: Node): boolean {
return (
ts.forEachChild(node, (c) =>
ts.isBreakStatement(c) || (!ts.isFunctionLike(c) && containsBreak(c)) ? true : undefined,
) === true
);
}
const LOOP_CALLBACK_METHODS: ReadonlySet<string> = new Set([
"map",
"forEach",
@@ -357,6 +411,11 @@ class Analyzer {
nodeLabel: this.nodeLabel,
escapedFunctions: this.escapedFunctionCalls,
protoOf: this.protoOf,
fnOfInstance: this.fnOfInstance,
declValues: (decl) => this.declValues(decl),
classChain: (decl) => this.classChain(decl),
branchIfs: this.branchIfs,
walkedBranches: this.walkedBranches,
};
}
@@ -449,6 +508,78 @@ class Analyzer {
return p;
}
/** A boolean literal: labeled `boolean` like any boolean, but its
* truthiness is known, so `if` statements on it walk one branch. */
boolLiteral(value: boolean): Atom {
return this.atoms.intern(`prim:boolean:${value}`, () => ({
kind: "prim",
label: "boolean",
literal: value,
}));
}
/** The analysis environment a deferred walk restores. */
private env(): WalkEnv {
return {
ctx: this.ctx,
ctxRoot: this.ctxRoot,
currentFn: this.currentFn,
loopDepth: this.loopDepth,
};
}
private inEnv(env: WalkEnv, walk: () => void): void {
const saved = this.env();
this.ctx = env.ctx;
this.ctxRoot = env.ctxRoot;
this.currentFn = env.currentFn;
this.loopDepth = env.loopDepth;
try {
walk();
} finally {
this.ctx = saved.ctx;
this.ctxRoot = saved.ctxRoot;
this.currentFn = saved.currentFn;
this.loopDepth = saved.loopDepth;
}
}
readonly branchIfs = new Set<ts.IfStatement>();
readonly walkedBranches = new Set<Node>();
/** Ifs whose branches wait for condition values; at the fixpoint, an
* `if` whose condition node stayed empty walks every waiting branch. */
private readonly pendingIfs: PendingIf[] = [];
/** An `if` walks a branch once a condition value allows it: the then
* branch on a value that may be truthy, the else branch on one that may
* be falsy. A branch no value allows is never walked, so its effects
* (returns, calls, writes) stay out of the facts; the overlay turns it
* into a checked internal error. */
private ifStatement(s: ts.IfStatement): void {
const cond = this.expr(s.expression);
this.branchIfs.add(s);
const pending: PendingIf = {
s,
cond,
env: this.env(),
thenDone: false,
elseDone: s.elseStatement === undefined,
};
this.pendingIfs.push(pending);
this.on(cond, (a) => {
if (!pending.thenDone && mayBeTruthy(a)) this.walkBranch(pending, "then");
if (!pending.elseDone && mayBeFalsy(a)) this.walkBranch(pending, "else");
});
}
private walkBranch(pending: PendingIf, which: "then" | "else"): void {
const branch = which === "then" ? pending.s.thenStatement : pending.s.elseStatement!;
if (which === "then") pending.thenDone = true;
else pending.elseDone = true;
this.walkedBranches.add(branch);
this.inEnv(pending.env, () => this.stmt(branch));
}
builtin(path: string, receiver?: Atom): Atom {
const key = receiver === undefined ? `builtin:${path}` : `builtin:${path}@${receiver.id}`;
return this.atoms.intern(key, () => ({ kind: "builtin", label: path, path, receiver }));
@@ -717,6 +848,24 @@ class Analyzer {
return `${inside ? this.ctx : ""}|${this.nodeKey(node)}`;
}
private declIndex: Map<number, number[]> | null = null;
declValues(decl: Node): Set<Atom> {
if (this.declIndex === null) {
this.declIndex = new Map();
for (const [key, id] of this.bindings) {
const k = Number(key.slice(key.lastIndexOf("|") + 1));
let l = this.declIndex.get(k);
if (l === undefined) this.declIndex.set(k, (l = []));
l.push(id);
}
}
const out = new Set<Atom>();
const k = this.nodeKeys.get(decl);
if (k === undefined) return out;
for (const id of this.declIndex.get(k) ?? []) for (const a of this.nodes[id]!.atoms) out.add(a);
return out;
}
receiverCtx(atom: Atom): string {
return atom.kind === "instance" || atom.kind === "class" ? `C${atom.id}` : "";
}
@@ -812,8 +961,11 @@ class Analyzer {
}
const body = fn.body;
if (body !== undefined) {
if (ts.isBlock(body)) this.stmts(list(body.statements));
else this.edge(this.expr(body as ts.Expression), info.ret);
if (ts.isBlock(body)) {
this.stmts(list(body.statements));
// A call that runs off the end of the body returns undefined.
if (canComplete(list(body.statements))) this.add(info.ret, this.prim("undefined"));
} else this.edge(this.expr(body as ts.Expression), info.ret);
}
this.currentFn = saved;
this.loopDepth = savedLoop;
@@ -1147,8 +1299,9 @@ class Analyzer {
return;
}
if (ts.isReturnStatement(s)) {
if (s.expression !== undefined && this.currentFn !== null)
this.edge(this.expr(s.expression), this.currentFn.ret);
if (this.currentFn === null) return;
if (s.expression !== undefined) this.edge(this.expr(s.expression), this.currentFn.ret);
else this.add(this.currentFn.ret, this.prim("undefined"));
return;
}
if (ts.isFunctionDeclaration(s)) {
@@ -1222,6 +1375,10 @@ class Analyzer {
this.expr(s.expression);
return;
}
if (ts.isIfStatement(s) && this.currentFn !== null) {
this.ifStatement(s);
return;
}
// Blocks, ifs, switches, labels: walk children, evaluating expressions.
ts.forEachChild(s, (child) => {
if (ts.isExpression(child)) this.expr(child);
@@ -1316,8 +1473,9 @@ class Analyzer {
case ts.SyntaxKind.BigIntLiteral:
return this.constNode(this.prim("bigint"));
case ts.SyntaxKind.TrueKeyword:
return this.constNode(this.boolLiteral(true));
case ts.SyntaxKind.FalseKeyword:
return this.constNode(this.prim("boolean"));
return this.constNode(this.boolLiteral(false));
case ts.SyntaxKind.NullKeyword:
return this.constNode(this.prim("null"));
case ts.SyntaxKind.RegularExpressionLiteral:
@@ -1749,6 +1907,7 @@ class Analyzer {
node: e,
kind: "object",
keys: keysKnown ? keys : null,
ownKeys: keys,
hasAccessor,
hasProto,
hasComputedKey,
@@ -1915,6 +2074,7 @@ class Analyzer {
node: e,
kind: "array",
keys: null,
ownKeys: [],
hasAccessor: false,
hasProto: hasHole,
hasComputedKey: false,
@@ -2558,7 +2718,11 @@ class Analyzer {
this.activate(info);
this.add(info.thisNode, inst);
this.bindArgs(info, args, spread);
this.edge(info.ret, dst);
// `new` ignores a primitive result (and the undefined of a
// body that runs off its end) and yields the instance.
this.on(info.ret, (r) => {
if (r.kind !== "prim") this.add(dst, r);
});
this.edgeFor(e, info);
}
return;
@@ -3205,6 +3369,21 @@ class Analyzer {
const before = this.steps;
for (const c of this.lateCopies) c();
this.solve();
// A condition with no values at the fixpoint is one the analysis
// could not evaluate: walk both of its branches.
let walked = false;
for (const p of this.pendingIfs) {
if (this.nodes[p.cond]!.atoms.size > 0) continue;
if (!p.thenDone) {
this.walkBranch(p, "then");
walked = true;
}
if (!p.elseDone) {
this.walkBranch(p, "else");
walked = true;
}
}
if (walked) continue;
if (this.steps === before || this.budgetExceeded) return;
}
}
@@ -3227,3 +3406,34 @@ function cjsExportName(decl: Node): string | null {
return decl.left.name.text;
return null;
}
interface WalkEnv {
readonly ctx: string;
readonly ctxRoot: Node | null;
readonly currentFn: FunctionInfo | null;
readonly loopDepth: number;
}
interface PendingIf {
readonly s: ts.IfStatement;
readonly cond: number;
readonly env: WalkEnv;
thenDone: boolean;
elseDone: boolean;
}
/** Whether a value of the atom may be truthy (only `null`, `undefined` and
* the literal `false` never are; ⊤ may be anything). */
function mayBeTruthy(a: Atom): boolean {
if (a.kind !== "prim") return true;
if (a.label === "null" || a.label === "undefined") return false;
return a.literal !== false;
}
/** Whether a value of the atom may be falsy (objects, arrays, functions,
* classes, promises and collections never are). */
function mayBeFalsy(a: Atom): boolean {
if (a.kind === "top") return true;
if (a.kind !== "prim") return false;
return a.literal !== true;
}
@@ -38,6 +38,8 @@ export interface Atom {
/** function atoms: the analysis context of the function's clone (a
* method's receiver class), "" for the context-insensitive default. */
readonly ctx?: string | undefined;
/** prim `boolean` atoms of a literal: its value. */
readonly literal?: boolean | undefined;
}
export class AtomTable {
@@ -1,7 +1,13 @@
/* Shape inference for untyped npm JavaScript
* (results/design-shape-inference/DESIGN.md). Stage S1: report only. */
* (results/design-shape-inference/DESIGN.md): the report (S1) and the typed
* overlay (overlay.ts). */
import type * as ts from "../ts7/adapter.js";
import { analyzeShapes } from "./analyze.js";
import type { IrFunction } from "../../ir/ir.js";
import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js";
import { npmStaticPackageOfPath } from "../npm-static.js";
import { isShapelessPackage } from "../package-shims.js";
import { analyzeShapes, isPackageJsFile } from "./analyze.js";
import { OVERLAY_MARK, planShapeOverlay } from "./overlay.js";
import { buildShapeReport } from "./report.js";
import { computeConstantGraphs, constantGraphReport } from "./constant-graphs.js";
@@ -10,7 +16,8 @@ export { buildShapeReport } from "./report.js";
/** SCRIPTC_DEBUG_SHAPES: `1` reports package code in hot functions,
* `verbose` every package site, `all` user files too, any other value only
* files whose path contains it. Writes to stderr; changes nothing else. */
* files whose path contains it. Writes to stderr; changes nothing else. The
* build cache does not key on the variable: use SCRIPTC_NO_CACHE=1. */
export function reportShapes(program: ts.Program, filter: string): void {
const start = performance.now();
const analysis = analyzeShapes(program, program.getTypeChecker());
@@ -22,3 +29,134 @@ export function reportShapes(program: ts.Program, filter: string): void {
const ms = (performance.now() - start).toFixed(0);
process.stderr.write(`[shapes] ${ms} ms\n${lines.map((l) => `[shapes] ${l}`).join("\n")}\n`);
}
/* The overlay's files for the current load: the lowering treats them as
* checked JavaScript (their JSDoc types are the analysis's proven facts). */
let overlayFiles: ReadonlySet<string> = new Set();
let overlayAnnotations = 0;
let overlayDeadBranches = 0;
export function isShapeOverlayFile(fileName: string): boolean {
return overlayFiles.has(fileName);
}
/** Whether a statement or declaration carries an overlay annotation. */
export function shapeOverlayAnnotated(node: ts.Node): boolean {
const sf = node.getSourceFile();
if (!overlayFiles.has(sf.fileName)) return false;
return sf.text.slice(node.pos, node.getStart(sf)).includes(OVERLAY_MARK);
}
export function shapeOverlayFileNames(): readonly string[] {
return [...overlayFiles];
}
export function shapeOverlayAnnotationCount(): number {
return overlayAnnotations;
}
export function resetShapeOverlay(): void {
overlayFiles = new Set();
overlayAnnotations = 0;
overlayDeadBranches = 0;
inferredFactChecks = 0;
}
/** Whether the overlay runs. While it is new it is opt-in
* (SCRIPTC_SHAPE_OVERLAY=1); `--no-shape-overlay` (the `shapeOverlay:
* false` build option) sets the variable to 0 for the build. */
export function shapeOverlayEnabled(): boolean {
return process.env["SCRIPTC_SHAPE_OVERLAY"] === "1";
}
/** loadProgram7's overlayer: analyze, plan, and register the overlay files. */
export function shapeOverlayer(program: ts.Program): ReadonlyMap<string, string> | null {
resetShapeOverlay();
const analysis = analyzeShapes(program, program.getTypeChecker());
if (analysis.budgetExceeded) return null;
const report = buildShapeReport(analysis, "1");
const packageFiles = analysis.files.filter((sf) => {
if (!isPackageJsFile(sf)) return false;
const pkg = npmStaticPackageOfPath(sf.fileName);
return pkg === null || !isShapelessPackage(pkg);
});
const overlay = planShapeOverlay(analysis, report.sites, packageFiles);
overlayFiles = new Set(overlay.files.keys());
overlayAnnotations = overlay.annotations;
overlayDeadBranches = overlay.deadBranches;
if (process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY"] !== undefined) {
for (const [file, text] of overlay.files)
process.stderr.write(`[shape-overlay] ${file}\n${text}\n`);
}
return overlay.files;
}
/** Marks the checked conversions lowered in overlay files: each checks an
* analysis-proven fact, so its failure names the site as an analysis bug. */
export function markInferredFactChecks(
functions: readonly IrFunction[],
program: ts.Program,
): number {
if (overlayFiles.size === 0) return 0;
let count = 0;
const lines = new Map<string, (pos: number) => number>();
const lineOf = (file: string, pos: number): number => {
let f = lines.get(file);
if (f === undefined) {
const sf = program.getSourceFile(file);
f = sf === undefined ? () => 0 : (p: number) => sf.getLineAndCharacterOfPosition(p).line + 1;
lines.set(file, f);
}
return f(pos);
};
const visit = (x: unknown): void => {
if (Array.isArray(x)) {
for (const v of x) visit(v);
return;
}
if (x === null || typeof x !== "object") return;
const node = x as {
kind?: unknown;
loc?: { file: string; start: number };
inferredSite?: string;
};
if (node.kind === "dynCheck" && node.loc !== undefined && overlayFiles.has(node.loc.file)) {
const pkg = npmStaticPackageOfPath(node.loc.file) ?? "";
const rel = node.loc.file.split("/node_modules/").pop() ?? node.loc.file;
node.inferredSite = `${pkg} ${rel}:${lineOf(node.loc.file, node.loc.start)}`;
count++;
}
for (const v of Object.values(x)) if (v !== null && typeof v === "object") visit(v);
};
for (const fn of functions) visit(fn.body);
inferredFactChecks = count;
return count;
}
let inferredFactChecks = 0;
/** Inferred-fact checks inserted by the last build (the build report). */
export function shapeInferredFactCheckCount(): number {
return inferredFactChecks;
}
/** SC6103, the overlay's build report: what the overlay typed and how many
* checks of inferred facts the build inserted. A note, never a failure. */
export function shapeOverlayReport(entryFile: string): ScrDiagnostic[] {
if (overlayFiles.size === 0) return [];
const packages = new Set([...overlayFiles].map((f) => npmStaticPackageOfPath(f) ?? f));
return [
{
code: "SC6103",
message:
`shape inference typed ${overlayAnnotations} declarations in ${overlayFiles.size} files of ${packages.size} ` +
`package${packages.size === 1 ? "" : "s"} (${[...packages].sort().join(", ")}); the build checks ` +
`${inferredFactChecks} inferred fact${inferredFactChecks === 1 ? "" : "s"} at run time` +
(overlayDeadBranches > 0
? ` and replaces ${overlayDeadBranches} branch${overlayDeadBranches === 1 ? "" : "es"} it proved unreachable with checks`
: ""),
loc: { file: entryFile, start: 0, end: 0 },
hint: "a failing check reports a scriptc analysis bug; --no-shape-overlay compiles without the overlay",
},
];
}
@@ -0,0 +1,32 @@
import { afterAll, expect, test } from "vitest";
import { closeSourceParser, parseSourceFile } from "../ts7/source-parser-node.js";
import { neutralizeDocTags, OVERLAY_MARK } from "./overlay.js";
afterAll(closeSourceParser);
test("neutralizeDocTags rewrites JSDoc comments and keeps literals", () => {
const source = [
"/** @param {number} a */",
"function f(a) {",
' const s = "/** @type {number} */";',
" const t = `/** @returns {string} ${a} */`;",
" const r = /\\/\\*\\* @param \\*\\//;",
" // /** @type {string} */",
` /** @type {string} ${OVERLAY_MARK} */ const u = s + t;`,
" return a / 2 /** @returns {number} */;",
"}",
].join("\n");
const out = neutralizeDocTags(parseSourceFile("overlay.js", source, "js"));
expect(out.length).toBe(source.length);
expect(out.split("\n")).toEqual([
"/** -param {number} a */",
"function f(a) {",
' const s = "/** @type {number} */";',
" const t = `/** @returns {string} ${a} */`;",
" const r = /\\/\\*\\* @param \\*\\//;",
" // /** @type {string} */",
` /** @type {string} ${OVERLAY_MARK} */ const u = s + t;`,
" return a / 2 /** -returns {number} */;",
"}",
]);
});
@@ -0,0 +1,774 @@
/* Shape inference: the typed overlay (results/design-shape-inference,
* maintainer decision 2026-10-23). The analysis's proven facts become JSDoc
* types in a rewritten copy of each package JavaScript file, and the
* lowering treats the rewritten file like a checked JavaScript file, so the
* static tier compiles the package's hot code from those types.
*
* Only proven facts are written: a position whose value set contains ⊤
* (anything from islands, JSON.parse, the network or the file system,
* reflection, globals, `process`, caught exceptions, or code the analysis
* cannot see) stays untyped. A check the lowering inserts where a value
* meets an inferred type therefore fails only when the analysis is wrong;
* such checks carry the inferred-fact failure message.
*
* The package's own JSDoc is a hint, not a declaration: its tags are
* neutralized in the rewritten file, so only the analysis's types remain.
*
* Every edit is inline: no line breaks are added or removed, so positions
* keep their lines. */
import * as ts from "../ts7/adapter.js";
import type { Atom } from "./domain.js";
import type { ShapeAnalysis } from "./analyze.js";
import type { SiteVerdict } from "./report.js";
export interface ShapeOverlay {
/** Rewritten text per package file. */
readonly files: Map<string, string>;
/** Annotations written, for the build report. */
readonly annotations: number;
/** Branches the analysis proved unreachable, replaced by checks. */
readonly deadBranches: number;
}
interface Edit {
readonly pos: number;
readonly text: string;
/** Replaces pos..end instead of inserting at pos. */
readonly end?: number;
}
/** Marks the overlay's own comments (an unknown JSDoc tag, ignored). */
export const OVERLAY_MARK = "@scriptcShape";
const MARK = OVERLAY_MARK;
/** Above 1, values of several record key sets type as one merged record
* with optional fields (SCRIPTC_SHAPE_RECORD_ARMS; experimental). */
const MAX_RECORD_ARMS = Number(process.env.SCRIPTC_SHAPE_RECORD_ARMS ?? 1);
/** The most class or record arms an inferred union may have. */
const MAX_ARMS = 6;
function list<T extends ts.Node>(arr: { readonly nodes: readonly T[] } | undefined): readonly T[] {
return arr?.nodes ?? [];
}
let debugIndex = 0;
export function planShapeOverlay(
analysis: ShapeAnalysis,
verdicts: readonly SiteVerdict[],
packageFiles: readonly ts.SourceFile[],
): ShapeOverlay {
const pSites = new Map<Atom, SiteVerdict>();
const siteVerdicts = new Set<Atom>(verdicts.map((v) => v.site.atom));
// Records and arrays print only for sites whose objects never escape: a
// native record that reached untyped code would be seen as a copy.
const siteKeys = new Map<Atom, { keys: string[]; optional: Set<string> }>();
for (const v of verdicts) {
if (v.verdict !== "P-local") continue;
if (v.site.kind === "array") {
pSites.set(v.site.atom, v);
continue;
}
const keys = keysOfSite(analysis, v);
if (keys === null) continue;
siteKeys.set(v.site.atom, keys);
pSites.set(v.site.atom, v);
}
const files = new Map<string, string>();
let annotations = 0;
let deadBranches = 0;
debugIndex = 0;
// `if` branches no condition value allowed, per file: the overlay
// replaces each with a throwing check, so the checker and the lowering
// see the code the analysis saw.
const deadByFile = new Map<ts.SourceFile, ts.Statement[]>();
if (process.env["SCRIPTC_SHAPE_DEAD_BRANCHES"] !== "0")
for (const s of analysis.branchIfs) {
for (const branch of [s.thenStatement, s.elseStatement]) {
if (branch === undefined || analysis.walkedBranches.has(branch)) continue;
const sf = s.getSourceFile();
let list = deadByFile.get(sf);
if (list === undefined) deadByFile.set(sf, (list = []));
list.push(branch);
}
}
for (const sf of packageFiles) {
const typedefs = new Map<string, string>();
const typedefText: string[] = [];
const edits: Edit[] = [];
const className = (decl: ts.Node): string | null => {
if (decl.getSourceFile() !== sf) return null;
const own = (decl as ts.ClassLikeDeclaration).name;
if (own !== undefined && ts.isClassDeclaration(decl) && decl.parent === sf) return own.text;
const p = decl.parent;
if (
p !== undefined &&
ts.isVariableDeclaration(p) &&
ts.isIdentifier(p.name) &&
p.parent?.parent?.parent === sf
)
return p.name.text;
return null;
};
// `inner`: inside a record field, where ⊤ and unprintable values are `any`.
const typeOf = (atoms: ReadonlySet<Atom>, inner: boolean, depth: number): string | null => {
if (atoms.size === 0) return inner ? "any" : null;
const arms = new Set<string>();
let objects = 0;
// Every array arm becomes one Array<…> of the joined element values.
const elements = new Set<Atom>();
let hasArray = false;
const promised = new Set<Atom>();
let hasPromise = false;
const recordSites: Atom[] = [];
for (const a of atoms) {
let t: string | null = null;
switch (a.kind) {
case "prim":
t =
a.label === "null" ||
a.label === "undefined" ||
a.label === "string" ||
a.label === "number" ||
a.label === "boolean" ||
a.label === "bigint"
? a.label
: null;
break;
case "instance":
t = a.node !== undefined ? className(a.node) : null;
objects++;
break;
case "object": {
objects++;
const v = pSites.get(a);
if (v === undefined || depth >= 4 || !siteKeys.has(a)) return inner ? "any" : null;
recordSites.push(a);
continue;
}
case "array": {
objects++;
if (analysis.escaped.has(a) || (siteVerdicts.has(a) && !pSites.has(a)))
return inner ? "any" : null;
const el = analysis.elements.get(a);
if (el !== undefined) for (const x of analysis.nodes[el]!.atoms) elements.add(x);
hasArray = true;
continue;
}
case "promise": {
// One promise arm: the values it resolves to, joined.
const value = analysis.fields.get(a)?.get("value");
if (value !== undefined) for (const x of analysis.nodes[value]!.atoms) promised.add(x);
hasPromise = true;
continue;
}
default:
t = null;
}
if (t === null) return inner ? "any" : null;
arms.add(t);
}
// Records of one key set print as their typedef; records of several
// key sets merge into one record whose fields missing from some sites
// are optional (unions of overlapping records would need the lowering
// to pick an arm per value).
if (recordSites.length > 0) {
const keySets = new Set(recordSites.map(keySetOf));
if (keySets.size === 1) arms.add(recordType(recordSites[0]!, depth));
else if (MAX_RECORD_ARMS > 1) arms.add(mergedRecordType(recordSites, depth));
else return inner ? "any" : null;
}
if (hasArray) {
const inside = depth < 4 ? typeOf(elements, true, depth + 1) : "any";
arms.add(`Array<${inside ?? "any"}>`);
}
// A promise beside other values cannot cross into untyped code
// (maybe-promise unions stay typed-only), so it stays untyped here.
if (hasPromise) return inner ? "any" : null;
if (objects > MAX_ARMS) return inner ? "any" : null;
if (arms.has("any")) return inner ? "any" : null;
return [...arms].join(" | ");
};
// One typedef per key set: sites with the same keys intern to the same
// structural record, so their field types are joined.
const keySetOf = (site: Atom): string => {
const k = siteKeys.get(site)!;
return k.keys
.map((key) => (k.optional.has(key) ? `${key}?` : key))
.sort()
.join(",");
};
const sitesByKeys = new Map<string, Atom[]>();
for (const site of pSites.keys()) {
if (pSites.get(site)!.site.kind !== "object") continue;
const k = keySetOf(site);
let l = sitesByKeys.get(k);
if (l === undefined) sitesByKeys.set(k, (l = []));
l.push(site);
}
const recordType = (site: Atom, depth: number): string => {
const keySet = keySetOf(site);
const existing = typedefs.get(keySet);
if (existing !== undefined) return existing;
const name = `__shape_${typedefs.size}`;
typedefs.set(keySet, name);
const { keys, optional } = siteKeys.get(site)!;
const parts = keys.map((k) => {
const atoms = new Set<Atom>();
for (const other of sitesByKeys.get(keySet) ?? [site]) {
const id = analysis.fields.get(other)?.get(k);
if (id !== undefined) for (const a of analysis.nodes[id]!.atoms) atoms.add(a);
}
const t = typeOf(atoms, true, depth + 1);
return `${/^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k)}${optional.has(k) ? "?" : ""}: ${t ?? "any"}`;
});
typedefText.push(`/** @typedef {{${parts.join(", ")}}} ${name} ${MARK} */`);
return name;
};
const mergedRecordType = (sites: readonly Atom[], depth: number): string => {
const all = new Set<Atom>();
for (const site of sites)
for (const other of sitesByKeys.get(keySetOf(site)) ?? [site]) all.add(other);
const keys: string[] = [];
const optional = new Set<string>();
for (const site of all) {
const k = siteKeys.get(site)!;
for (const key of k.keys) {
if (!keys.includes(key)) keys.push(key);
if (k.optional.has(key)) optional.add(key);
}
}
for (const site of all) {
const own = siteKeys.get(site)!.keys;
for (const key of keys) if (!own.includes(key)) optional.add(key);
}
const keySet = `merged:${keys
.map((k) => (optional.has(k) ? `${k}?` : k))
.sort()
.join(",")}`;
const existing = typedefs.get(keySet);
if (existing !== undefined) return existing;
const name = `__shape_${typedefs.size}`;
typedefs.set(keySet, name);
const parts = keys.map((k) => {
const atoms = new Set<Atom>();
for (const site of all) {
const id = analysis.fields.get(site)?.get(k);
if (id !== undefined) for (const a of analysis.nodes[id]!.atoms) atoms.add(a);
}
const t = typeOf(atoms, true, depth + 1);
return `${/^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k)}${optional.has(k) ? "?" : ""}: ${t ?? "any"}`;
});
typedefText.push(`/** @typedef {{${parts.join(", ")}}} ${name} ${MARK} */`);
return name;
};
// Functions called with `new` yield their instance, whatever they return.
const constructed = new Set<ts.Node | undefined>(
[...analysis.fnOfInstance.values()].map((a) => a.node),
);
// Return values merged over every analysis context of each function.
const returns = new Map<ts.Node, Set<Atom>>();
for (const info of analysis.functions.values()) {
if (info.node.getSourceFile() !== sf || info.isAsync || info.isGenerator) continue;
let set = returns.get(info.node);
if (set === undefined) returns.set(info.node, (set = new Set()));
for (const a of analysis.nodes[info.ret]!.atoms) set.add(a);
}
// Receivers each method runs on, merged over its analysis contexts.
const thisAtoms = new Map<ts.Node, Set<Atom>>();
for (const info of analysis.functions.values()) {
if (info.node.getSourceFile() !== sf || !ts.isMethodDeclaration(info.node)) continue;
let set = thisAtoms.get(info.node);
if (set === undefined) thisAtoms.set(info.node, (set = new Set()));
for (const a of analysis.nodes[info.thisNode]!.atoms) set.add(a);
}
const declaresMember = (cls: ts.Node, name: string): boolean =>
analysis.classChain(cls).some((c) =>
(c as ts.ClassLikeDeclaration).members.nodes.some((m) => {
const n = (m as { name?: ts.Node }).name;
return (
n !== undefined && (ts.isIdentifier(n) || ts.isPrivateIdentifier(n)) && n.text === name
);
}),
);
// `this.m(…)` in an inherited method whose class does not declare `m`
// (zod's ZodType._parseSync calls the subclasses' _parse): the receiver
// is cast to the classes the method runs on, so the call dispatches over
// them instead of through an untyped call.
const receiverCasts = (method: ts.MethodDeclaration): void => {
const cls = method.parent;
const receivers = thisAtoms.get(method);
if (cls === undefined || receivers === undefined || receivers.size === 0) return;
if (![...receivers].every((a) => a.kind === "instance")) return;
const t = typeOf(receivers, false, 0);
if (t === null) return;
const scan = (node: ts.Node): void => {
if (ts.isFunctionLike(node) && !ts.isArrowFunction(node)) return;
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
node.expression.expression.kind === ts.SyntaxKind.ThisKeyword &&
ts.isIdentifier(node.expression.name) &&
!declaresMember(cls, node.expression.name.text)
) {
const self = node.expression.expression;
edits.push({ pos: ts.tokenStartOfNode(self), text: `/** @type {${t}} ${MARK} */ (` });
edits.push({ pos: self.end, text: ")" });
annotations++;
}
ts.forEachChild(node, (c) => {
scan(c);
return undefined;
});
};
if (method.body !== undefined) for (const st of method.body.statements.nodes) scan(st);
};
// Same-named instance methods of the file's classes form a family: a
// call on a union of those classes needs one signature, so each member's
// parameters and result join the whole family's values.
const families = new Map<string, ts.MethodDeclaration[]>();
const familyVisit = (node: ts.Node): void => {
if (
ts.isMethodDeclaration(node) &&
ts.isIdentifier(node.name) &&
!list((node as { modifiers?: { nodes: readonly ts.Node[] } }).modifiers).some(
(m) => m.kind === ts.SyntaxKind.StaticKeyword,
)
) {
let l = families.get(node.name.text);
if (l === undefined) families.set(node.name.text, (l = []));
l.push(node);
}
ts.forEachChild(node, (c) => {
familyVisit(c);
return undefined;
});
};
familyVisit(sf);
// Members the program runs (they have analysis contexts); members it
// never reaches would only widen the family's types.
const reached = new Set<ts.Node>();
for (const info of analysis.functions.values())
if (info.node.getSourceFile() === sf) reached.add(info.node);
const familyOf = (fn: ts.FunctionLikeDeclaration): readonly ts.MethodDeclaration[] => {
if (!ts.isMethodDeclaration(fn) || !ts.isIdentifier(fn.name))
return [fn as ts.MethodDeclaration];
const members = (families.get(fn.name.text) ?? [fn]).filter((m) => reached.has(m));
return members.length > 0 ? members : [fn];
};
const paramValues = (fn: ts.FunctionLikeDeclaration, index: number): Set<Atom> => {
const out = new Set<Atom>();
for (const member of familyOf(fn)) {
const p = list(member.parameters)[index];
if (p === undefined) continue;
for (const a of analysis.declValues(p)) out.add(a);
}
return out;
};
const returnValues = (fn: ts.FunctionLikeDeclaration): Set<Atom> | undefined => {
let out: Set<Atom> | undefined;
for (const member of familyOf(fn)) {
const r = returns.get(member);
if (r === undefined) continue;
out ??= new Set();
for (const a of r) out.add(a);
}
return out;
};
const paramTags = (fn: ts.FunctionLikeDeclaration): string | null => {
const tags: string[] = [];
if (!ts.isConstructorDeclaration(fn) && !constructed.has(fn)) {
const ret = returnValues(fn);
const t = ret !== undefined && ret.size > 0 ? typeOf(ret, false, 0) : null;
const why = process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY_WHY"];
if (
why !== undefined &&
t === null &&
ret !== undefined &&
ts.isMethodDeclaration(fn) &&
ts.isIdentifier(fn.name) &&
fn.name.text === why
)
process.stderr.write(
`[shape-overlay-why] ${fn.name.text}: ${[...ret].map((a) => `${a.kind}:${a.label}${analysis.escaped.has(a) ? "(escaped)" : ""}`).join(" | ")}\n`,
);
if (t !== null) tags.push(`@returns {${t}}`);
}
list(fn.parameters).forEach((p, index) => {
if (
!ts.isIdentifier(p.name) ||
p.dotDotDotToken !== undefined ||
p.initializer !== undefined ||
p.questionToken !== undefined
)
return;
const t = typeOf(paramValues(fn, index), false, 0);
if (t !== null) tags.push(`@param {${t}} ${p.name.text}`);
});
return tags.length > 0 ? `${tags.join(" ")} ${MARK}` : null;
};
const visit = (node: ts.Node): void => {
if (ts.isMethodDeclaration(node)) receiverCasts(node);
if (
ts.isMethodDeclaration(node) ||
ts.isConstructorDeclaration(node) ||
ts.isFunctionDeclaration(node)
) {
if (node.body !== undefined) {
const tags = paramTags(node);
if (tags !== null) {
edits.push({ pos: ts.tokenStartOfNode(node), text: `/** ${tags} */ ` });
annotations++;
}
}
// `this.f = …` fields of a constructor's top-level statements.
if (ts.isConstructorDeclaration(node) && node.body !== undefined) {
const cls = node.parent;
const seen = new Set<string>();
for (const st of list(node.body.statements)) {
if (!ts.isExpressionStatement(st) || !ts.isBinaryExpression(st.expression)) continue;
const b = st.expression;
if (
b.operatorToken.kind !== ts.SyntaxKind.EqualsToken ||
!ts.isPropertyAccessExpression(b.left)
)
continue;
if (
b.left.expression.kind !== ts.SyntaxKind.ThisKeyword ||
!ts.isIdentifier(b.left.name)
)
continue;
const name = b.left.name.text;
if (seen.has(name)) continue;
seen.add(name);
const t = cls !== undefined ? fieldType(cls, name) : null;
if (t !== null) {
edits.push({ pos: ts.tokenStartOfNode(st), text: `/** @type {${t}} ${MARK} */ ` });
annotations++;
}
}
}
}
// Locals only: module-level bindings are initialized once (and a
// record-typed module constant can turn a return type into a union of
// overlapping records, which the lowering width-coerces).
if (
ts.isVariableStatement(node) &&
(node.parent !== sf || process.env["SCRIPTC_SHAPE_OVERLAY_MODULE_VARS"] === "1") &&
node.declarationList.declarations.nodes.length === 1
) {
const d = node.declarationList.declarations.nodes[0]!;
const init = d.initializer;
if (ts.isIdentifier(d.name) && init !== undefined && !literalInitializer(init)) {
const t = typeOf(analysis.declValues(d), false, 0);
if (t !== null) {
edits.push({ pos: ts.tokenStartOfNode(node), text: `/** @type {${t}} ${MARK} */ ` });
annotations++;
}
}
}
ts.forEachChild(node, (c) => {
visit(c);
return undefined;
});
};
const fieldType = (cls: ts.Node, name: string): string | null => {
const atoms = new Set<Atom>();
for (const a of analysis.atoms.atoms) {
if (
a.kind !== "instance" ||
a.node === undefined ||
!analysis.classChain(a.node).includes(cls)
)
continue;
const id = analysis.fields.get(a)?.get(name);
if (id !== undefined) for (const x of analysis.nodes[id]!.atoms) atoms.add(x);
}
return typeOf(atoms, false, 0);
};
visit(sf);
// Dead branches: outermost first; edits inside a replaced range drop.
const dead = (deadByFile.get(sf) ?? []).sort((a, b) => a.pos - b.pos);
const ranges: [number, number][] = [];
const removed = new Set<ts.Node>();
for (const branch of dead) {
const start = ts.tokenStartOfNode(branch);
if (ranges.some(([lo, hi]) => start >= lo && start < hi)) continue;
if (hoistedNames(branch, removed) === null) continue;
removed.add(branch);
ranges.push([start, branch.end]);
}
// A var that only removed branches declare stays declared in each.
const hoisted = new Map<number, readonly string[]>();
for (const branch of removed)
hoisted.set(ts.tokenStartOfNode(branch), hoistedNames(branch, removed)!);
if (ranges.length > 0) {
const kept = edits.filter((e) => !ranges.some(([lo, hi]) => e.pos > lo && e.pos < hi));
edits.length = 0;
edits.push(...kept);
const pkg = packageNameOf(sf.fileName);
const rel = sf.fileName.split("/node_modules/").pop() ?? sf.fileName;
for (const [lo, hi] of ranges) {
const line = sf.getLineAndCharacterOfPosition(lo).line + 1;
const message =
`scriptc internal error: shape inference proved the branch at ${rel}:${line} unreachable, but it ran. ` +
`This is a bug in scriptc's shape analysis, not in the program. Rebuild with --no-shape-overlay, ` +
`or set "scriptc": {"packages": {"${pkg}": {"shapes": false}}} in package.json.`;
// The branch's own var names stay declared in its scope, undefined.
const names = hoisted.get(lo)!;
const vars = names.length > 0 ? `var ${names.join(", ")}; ` : "";
edits.push({
pos: lo,
end: hi,
text: `{ ${vars}throw new Error(${JSON.stringify(message)}); }`,
});
deadBranches++;
}
}
// Debugging: SCRIPTC_SHAPE_OVERLAY_ONLY=a-b keeps the annotations
// numbered a..b (program order across files), to bisect a miscompile.
const only = process.env["SCRIPTC_SHAPE_OVERLAY_ONLY"];
if (only !== undefined) {
const [lo, hi] = only.split("-").map(Number) as [number, number];
edits.sort((a, b) => a.pos - b.pos);
const kept = edits.filter(() => {
const n = debugIndex++;
return n >= lo && n <= hi;
});
edits.length = 0;
edits.push(...kept);
}
if (edits.length === 0) continue;
files.set(sf.fileName, applyEdits(neutralizeDocTags(sf), edits) + typedefText.join(""));
}
return { files, annotations, deadBranches };
}
function packageNameOf(fileName: string): string {
const parts = (fileName.split("/node_modules/").pop() ?? "").split("/");
return parts[0]!.startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0]!;
}
function literalInitializer(e: ts.Expression): boolean {
return (
ts.isObjectLiteralExpression(e) ||
(ts.isArrayLiteralExpression(e) && e.elements.nodes.length > 0) ||
ts.isStringLiteral(e) ||
ts.isNumericLiteral(e) ||
ts.isNoSubstitutionTemplateLiteral(e) ||
ts.isArrowFunction(e) ||
ts.isFunctionExpression(e) ||
e.kind === ts.SyntaxKind.TrueKeyword ||
e.kind === ts.SyntaxKind.FalseKeyword
);
}
function applyEdits(text: string, edits: readonly Edit[]): string {
const sorted = [...edits].sort((a, b) => a.pos - b.pos);
let out = "";
let at = 0;
for (const e of sorted) {
out += text.slice(at, e.pos) + e.text;
at = e.end ?? e.pos;
}
return out + text.slice(at);
}
/** The file's text with the JSDoc type tags of its comments made plain
* text: package JSDoc is a hint, not a declaration. String, template and
* regular expression literals and JSX text are copied unchanged, so
* comment-like text in them keeps its runtime value. Lengths are unchanged,
* so edit positions in the source text stay valid. */
export function neutralizeDocTags(sf: ts.SourceFile): string {
const text = sf.text;
// Literal token ranges, in source order.
const literals: [number, number][] = [];
const visit = (node: ts.Node): void => {
if (ts.isJsxText(node)) {
literals.push([node.pos, node.end]);
return;
}
if (
ts.isStringLiteral(node) ||
ts.isNoSubstitutionTemplateLiteral(node) ||
ts.isTemplateHead(node) ||
ts.isTemplateMiddle(node) ||
ts.isTemplateTail(node) ||
ts.isRegularExpressionLiteral(node)
) {
literals.push([ts.tokenStartOfNode(node), node.end]);
return;
}
ts.forEachChild(node, (c) => {
visit(c);
return undefined;
});
};
visit(sf);
literals.sort((a, b) => a[0] - b[0]);
let out = "";
let copied = 0;
let next = 0;
let pos = 0;
// Outside literals, `//` and `/*` only start comments.
while (pos < text.length) {
while (next < literals.length && literals[next]![1] <= pos) next++;
const literal = literals[next];
if (literal !== undefined && literal[0] <= pos) {
pos = literal[1];
continue;
}
if (text.charCodeAt(pos) !== 47) {
pos++;
continue;
}
const second = text.charCodeAt(pos + 1);
if (second === 47) {
while (pos < text.length && !lineBreak(text.charCodeAt(pos))) pos++;
continue;
}
if (second !== 42) {
pos++;
continue;
}
const close = text.indexOf("*/", pos + 2);
const end = close < 0 ? text.length : close + 2;
const comment = text.slice(pos, end);
if (comment.startsWith("/**") && !comment.includes(MARK)) {
out += text.slice(copied, pos) + neutralizeComment(comment);
copied = end;
}
pos = end;
}
return out + text.slice(copied);
}
function lineBreak(code: number): boolean {
return code === 10 || code === 13 || code === 0x2028 || code === 0x2029;
}
function neutralizeComment(comment: string): string {
return comment.replace(
/@(param|type|typedef|returns?|template|this|callback|property|prop|arg|argument|enum|implements|extends|augments|satisfies|overload|import)\b/g,
"-$1",
);
}
/** The `var` names a dead branch declares that its function (or file)
* declares nowhere else outside the `removed` branches: replacing the
* branch must keep them bound, as undefined. Null when the branch declares
* a function in a block, whose binding outside the block depends on the
* file's strictness; such a branch is kept. */
function hoistedNames(branch: ts.Node, removed: ReadonlySet<ts.Node>): readonly string[] | null {
let scope: ts.Node | undefined = branch.parent;
while (scope !== undefined && !ts.isSourceFile(scope) && !functionScope(scope))
scope = scope.parent;
if (scope === undefined) return null;
const own = new Set<string>();
let blockFunction = false;
ts.walkPreorder(branch, (n) => {
if (n !== branch && ts.isFunctionDeclaration(n)) {
blockFunction = true;
return "skip";
}
if (n !== branch && (functionScope(n) || ts.isClassDeclaration(n) || ts.isClassExpression(n)))
return "skip";
if (ts.isVariableDeclarationList(n) && (n.flags & ts.NodeFlags.BlockScoped) === 0)
for (const d of n.declarations.nodes) addBindingNames(d.name, own);
return undefined;
});
if (blockFunction) return null;
if (own.size === 0) return [];
// Names the scope binds outside the branch: parameters, a function
// expression's name, other var declarations and function declarations.
const elsewhere = new Set<string>();
if (!ts.isSourceFile(scope)) {
for (const p of list(
(scope as { parameters?: { readonly nodes: readonly ts.ParameterDeclaration[] } }).parameters,
))
addBindingNames(p.name, elsewhere);
const name = (scope as { name?: ts.Node }).name;
if (name !== undefined && ts.isIdentifier(name)) elsewhere.add(name.text);
}
const body = ts.isSourceFile(scope) ? scope : (scope as { body?: ts.Node }).body;
if (body !== undefined)
ts.walkPreorder(body, (n) => {
if (n === branch || removed.has(n)) return "skip";
if (n !== body && ts.isFunctionDeclaration(n) && n.name !== undefined)
elsewhere.add(n.name.text);
if (n !== body && (functionScope(n) || ts.isClassDeclaration(n) || ts.isClassExpression(n)))
return "skip";
if (ts.isVariableDeclarationList(n) && (n.flags & ts.NodeFlags.BlockScoped) === 0)
for (const d of n.declarations.nodes) addBindingNames(d.name, elsewhere);
return undefined;
});
return [...own].filter((name) => !elsewhere.has(name));
}
function functionScope(n: ts.Node): boolean {
return (
ts.isFunctionDeclaration(n) ||
ts.isFunctionExpression(n) ||
ts.isArrowFunction(n) ||
ts.isMethodDeclaration(n) ||
ts.isGetAccessorDeclaration(n) ||
ts.isSetAccessorDeclaration(n) ||
ts.isConstructorDeclaration(n) ||
ts.isClassStaticBlockDeclaration(n)
);
}
function addBindingNames(name: ts.BindingName, out: Set<string>): void {
if (ts.isIdentifier(name)) {
out.add(name.text);
return;
}
for (const element of name.elements.nodes)
if (!ts.isOmittedExpression(element) && element.name !== undefined)
addBindingNames(element.name, out);
}
/** A record site's keys: its static keys, or for a literal with spreads its
* own keys plus every key a spread source contributes (optional unless
* every value of that source has it). Null when a computed key or an
* untracked spread source makes the key set unknown. */
function keysOfSite(
analysis: ShapeAnalysis,
v: SiteVerdict,
): { keys: string[]; optional: Set<string> } | null {
const site = v.site;
if (site.keys !== null) return { keys: site.keys, optional: new Set() };
if (site.hasComputedKey) return null;
const fields = analysis.fields.get(site.atom);
const any = fields?.get("*");
if (any !== undefined && analysis.nodes[any]!.atoms.size > 0) return null;
const required = new Set(site.ownKeys);
for (const spread of site.spreads) {
const sets: Set<string>[] = [];
for (const a of analysis.nodes[spread]!.atoms) {
if (a.kind === "prim" && (a.label === "undefined" || a.label === "null")) continue;
if (a.kind !== "object") return null;
const own = new Set<string>();
for (const k of analysis.fields.get(a)?.keys() ?? []) if (k !== "*") own.add(k);
sets.push(own);
}
const first = sets[0];
if (first === undefined) continue;
for (const k of first) if (sets.every((set) => set.has(k))) required.add(k);
}
const all = [...site.ownKeys];
for (const k of fields?.keys() ?? []) if (k !== "*" && !all.includes(k)) all.push(k);
return { keys: all, optional: new Set(all.filter((k) => !required.has(k))) };
}
+12
View File
@@ -1,3 +1,4 @@
import { shapeOverlayEnabled } from "./frontend/shape-inference/index.js";
import { nodeFrontendServices } from "./frontend/services-node.js";
import {
runTypePatchCommand,
@@ -481,6 +482,10 @@ export async function compile(
opts: CompileRequestOptions,
): Promise<CompileRequestResult> {
clearCompileSessionCaches();
// `shapeOverlay: false` reaches the frontend (and its child process)
// through the environment the overlay reads.
const overlayEnv = process.env["SCRIPTC_SHAPE_OVERLAY"];
if (opts.shapeOverlay === false) process.env["SCRIPTC_SHAPE_OVERLAY"] = "0";
const frontendInputs = new FrontendInputTracker();
const warnings: CompileWarningSink = { warnings: [], sourceTexts: null };
// compileTracked starts it for a build with no earlier build of the same invocation.
@@ -493,6 +498,10 @@ export async function compile(
} finally {
setLibDiagnosticsStore(null);
frontendProcess?.release();
if (opts.shapeOverlay === false) {
if (overlayEnv === undefined) delete process.env["SCRIPTC_SHAPE_OVERLAY"];
else process.env["SCRIPTC_SHAPE_OVERLAY"] = overlayEnv;
}
}
}
@@ -1303,6 +1312,8 @@ function configureLibDiagnostics(root: string | null): void {
export async function runFrontendProcess(requestPath: string, resultPath: string): Promise<void> {
const request = JSON.parse(await readFile(requestPath, "utf8")) as FrontendProcessRequest;
// The process started before the build's options were known.
if (request.opts.shapeOverlay === false) process.env["SCRIPTC_SHAPE_OVERLAY"] = "0";
clearCompileSessionCaches();
setProvenanceSources(request.provenance);
// The compile that started this process prepared the cache root.
@@ -1650,6 +1661,7 @@ async function compileTracked(
...checkerField(),
...(youngNurseryRequested() ? { youngNursery: true as const } : {}),
npmStatic: opts.npmStatic ?? null,
...(shapeOverlayEnabled() ? { shapeOverlay: true as const } : {}),
ffiProfile:
opts.ffiProfilePath === undefined || ffiProfileBytes === null
? null
@@ -100,6 +100,7 @@ const EXPR_FIELDS: Classified<IrExpr> = {
unionIsTag: { unionId: "other" },
unionEq: { unionId: "other" },
jsonStringify: { indent: "other" },
dynCheck: { inferredSite: "other" },
};
const STMT_FIELDS: Classified<IrStmt> = {
@@ -225,6 +226,8 @@ const MODULE_FIELDS: Record<StringFields<Omit<IrModule, "functions">>, Role> = {
"threadSlotKeys[]": "other",
"foreignElementKeys[]": "other",
"freezableArrays[]": "other",
"inferredFactFiles[].file": "other",
"inferredFactFiles[].pkg": "other",
"declOrigins[]": "other",
};
+9
View File
@@ -1063,6 +1063,11 @@ export interface IrModule {
* closures boxed for package code return records and arrays as live
* capsules, so a value that comes back keeps its identity. */
declBoundary?: true;
/** Package files compiled from shape inference's typed overlay
* (frontend/shape-inference/overlay.ts), each with its package name: a
* checked conversion in them checks an analysis-proven fact, so its
* failure reports a compiler analysis bug instead of a type error. */
inferredFactFiles?: { file: string; pkg: string }[];
/** Origins of package-declared types checked by `dynCheck.origin`
* (frontend/lowering/decl-origin.ts): fields separated by \x1f —
* package, version, suggested range, selector, where it is declared,
@@ -4066,6 +4071,10 @@ export type IrExpr =
preserveRefs?: true;
adopt?: true;
exact?: true;
/** Shape inference's overlay: this conversion checks an
* analysis-proven fact ("<package> <file>:<line>"); a failure is a
* compiler analysis bug and reports itself as one. */
inferredSite?: string;
origin?: number;
type: IrType;
loc: SrcLoc;
@@ -70,6 +70,7 @@ test("package settings: strings and objects", () => {
expect(c.packages.get("semver")?.run).toBe("auto");
expect(c.packages.get("proper-lockfile")).toEqual({
unloaded: false,
shapes: true,
run: "node",
evaluate: "lazy",
share: "duplicate",
@@ -78,6 +79,24 @@ test("package settings: strings and objects", () => {
});
});
test("shapes: false turns off the shape overlay for one package", () => {
const c = config({
packages: {
zod: { shapes: false },
effect: { shapes: true, run: "native" },
ms: { shapes: "no" },
},
});
expect(c.packages.get("zod")?.shapes).toBe(false);
expect(c.packages.get("zod")?.run).toBe("auto");
expect(c.packages.get("effect")?.shapes).toBe(true);
expect(c.packages.get("effect")?.run).toBe("native");
expect(c.packages.has("ms")).toBe(false);
expect(c.diagnostics.map((d) => d.message)).toEqual([
`package.json 'scriptc.packages.ms.shapes' must be true or false, not "no"`,
]);
});
test("nodeIslands settings", () => {
const c = config({
nodeIslands: {
+25 -5
View File
@@ -10,7 +10,8 @@
* "encoding": "unloaded", // never loaded (package-shims.ts)
* "dd-trace": "node", // run in a Node island
* "hot-shots": "native", // must compile natively (SC8003)
* "proper-lockfile": { "run": "node", "evaluate": "lazy" }
* "proper-lockfile": { "run": "node", "evaluate": "lazy" },
* "zod": { "shapes": false } // no shape-inference overlay
* },
* "nodeIslands": { "auto": true, "modules": ["src/plugins/*.ts"] },
* "wasm": ["build/module.wasm"],
@@ -40,9 +41,12 @@ export type PackageMarshal = "copy" | "handles";
export type PackageSchedule = "lockstep" | "free";
/** One `scriptc.packages.<name>` entry. `unloaded` packages never load;
* the other settings choose where the package runs. */
* `shapes: false` compiles the package without shape inference's typed
* overlay (shape-inference/overlay.ts); the other settings choose where the
* package runs. */
export interface PackageSetting {
unloaded: boolean;
shapes: boolean;
run: PackageRun;
evaluate: PackageEvaluate;
share: PackageShare;
@@ -117,6 +121,9 @@ const PACKAGE_OBJECT_KEYS: Record<string, string[]> = {
marshal: ["copy", "handles"],
schedule: ["lockstep", "free"],
};
/** Every key of a package setting object: the string-valued ones above and
* the boolean `shapes`. */
const PACKAGE_SETTING_KEYS = [...Object.keys(PACKAGE_OBJECT_KEYS), "shapes"];
const NODE_ISLANDS_KEYS = [
"auto",
"modules",
@@ -129,6 +136,7 @@ const NODE_ISLANDS_KEYS = [
function defaultPackageSetting(): PackageSetting {
return {
unloaded: false,
shapes: true,
run: "auto",
evaluate: "auto",
share: "auto",
@@ -223,7 +231,7 @@ export function parseProjectConfig(manifest: string, text: string): ProjectConfi
problem(
["packages", name],
`has the unknown value ${shown(value)}`,
`use one of ${quoted(PACKAGE_STRINGS)}, or an object with ${Object.keys(PACKAGE_OBJECT_KEYS).join(", ")}`,
`use one of ${quoted(PACKAGE_STRINGS)}, or an object with ${PACKAGE_SETTING_KEYS.join(", ")}`,
);
continue;
}
@@ -236,12 +244,24 @@ export function parseProjectConfig(manifest: string, text: string): ProjectConfi
problem(
["packages", name],
`must be a string or an object, not ${shown(value)}`,
`use one of ${quoted(PACKAGE_STRINGS)}, or an object with ${Object.keys(PACKAGE_OBJECT_KEYS).join(", ")}`,
`use one of ${quoted(PACKAGE_STRINGS)}, or an object with ${PACKAGE_SETTING_KEYS.join(", ")}`,
);
continue;
}
let valid = true;
for (const key of Object.keys(value)) {
if (key === "shapes") {
const v = value[key];
if (typeof v !== "boolean") {
problem(
["packages", name, key],
`must be true or false, not ${shown(v)}`,
"use a boolean",
);
valid = false;
} else setting.shapes = v;
continue;
}
const allowed = Object.hasOwn(PACKAGE_OBJECT_KEYS, key)
? PACKAGE_OBJECT_KEYS[key]
: undefined;
@@ -249,7 +269,7 @@ export function parseProjectConfig(manifest: string, text: string): ProjectConfi
problem(
["packages", name, key],
"is not a known package setting",
`the settings are ${Object.keys(PACKAGE_OBJECT_KEYS).join(", ")}`,
`the settings are ${PACKAGE_SETTING_KEYS.join(", ")}`,
);
valid = false;
continue;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8326-never-promise-instanceof.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/shape-overlay-facts/main.ts"
],
"diags": []
}
+4
View File
@@ -1621,6 +1621,10 @@ void scr_dyn_check_fail(const ScrDynPath *path, const char *want, const ScrDyn *
/* The Date object a checked-dynamic Date handle holds, +1, or NULL with
* the check failure pending. */
ScrDate *scr_dyn_native_date_check(const ScrDyn *value, const ScrDynPath *path);
/* Names the analysis-proven fact the next checked conversion checks
* (shape inference's overlay), or NULL: its failure then reports a compiler
* analysis bug ("<package> <file>:<line>"). */
void scr_dyn_inferred_site(const char *site);
/* Matchers and builders of recursive shapes (records and arrays whose type
* reaches itself) track the dyn values they visit, so cyclic values match
+46
View File
@@ -11694,7 +11694,53 @@ static const char *scr_dyn_kind_name(const ScrDyn *d) {
return "unknown";
}
/* Shape inference's overlay (compiler frontend/shape-inference): a checked
* conversion of an analysis-proven fact names its site ("<package>
* <file>:<line>") while it runs. A failure there is a compiler analysis
* bug, so it reports itself as one instead of as a type error. */
static SCR_TL const char *scr_dyn_inferred_site_name;
void scr_dyn_inferred_site(const char *site) { scr_dyn_inferred_site_name = site; }
static void scr_dyn_inferred_fact_fail(const char *site, const ScrDynPath *path, const char *want,
const ScrDyn *got) {
const char *space = strchr(site, ' ');
size_t pkg_len = space ? (size_t)(space - site) : 0;
const char *where = space ? space + 1 : site;
ScrJsonBuf b;
scr_jb_init(&b);
scr_jb_puts(&b, "scriptc internal error: an inferred type was contradicted at ");
scr_jb_puts(&b, where);
scr_jb_puts(&b, " (expected ");
scr_jb_puts(&b, want);
scr_jb_puts(&b, " at ");
scr_dyn_path_render(&b, path);
scr_jb_puts(&b, ", got ");
scr_jb_puts(&b, scr_dyn_kind_name(got));
scr_jb_puts(&b, "). This is a bug in scriptc's shape analysis, not in the program's data. "
"Rebuild with --no-shape-overlay, or set \"scriptc\": {\"packages\": {\"");
for (size_t i = 0; i < pkg_len; i++) scr_jb_putc(&b, site[i]);
scr_jb_puts(&b, "\": {\"shapes\": false}}} in package.json, to compile the package without it.");
/* Validation mode: every hit is logged, also when the program catches it. */
const char *log = getenv("SCRIPTC_SHAPE_CHECK_LOG");
if (log && *log) {
FILE *f = fopen(log, "a");
if (f) {
fwrite(b.data, 1, b.len, f);
fputc('\n', f);
fclose(f);
}
}
scr_throw_error(SCR_ERR_ERROR, scr_jb_finish(&b));
}
void scr_dyn_check_fail(const ScrDynPath *path, const char *want, const ScrDyn *got) {
const char *site = scr_dyn_inferred_site_name;
if (site) {
scr_dyn_inferred_site_name = NULL;
scr_dyn_inferred_fact_fail(site, path, want, got);
return;
}
ScrJsonBuf b;
scr_jb_init(&b);
scr_jb_puts(&b, "expected ");
@@ -0,0 +1,26 @@
// `instanceof Promise` on values that are never promises (records, arrays,
// dates, collections, regular expressions, byte arrays, null, and unions of
// them) is false, after the operand is evaluated.
let evaluated = 0;
function next(): { v: number } {
evaluated++;
return { v: evaluated };
}
const record = { v: 1 };
console.log("record", record instanceof Promise);
console.log("call", next() instanceof Promise, evaluated);
console.log("array", [1, 2] instanceof Promise);
console.log("date", new Date(0) instanceof Promise);
console.log("map", new Map<string, number>() instanceof Promise);
console.log("set", new Set<number>() instanceof Promise);
console.log("regex", /a/ instanceof Promise);
console.log("bytes", new Uint8Array(2) instanceof Promise);
function pick(i: number): { v: number } | number[] | null {
return i === 0 ? { v: 0 } : i === 1 ? [i] : null;
}
for (let i = 0; i < 3; i++) console.log("union", i, pick(i) instanceof Promise);
const promised: unknown = Promise.resolve(1);
console.log("promise", promised instanceof Promise);
@@ -26,7 +26,3 @@ console.log(maybe(true));
// must be that arm's own promise type.
const widened = Promise.resolve<string | number>(maybe(false));
console.log(typeof widened);
// `instanceof Promise` on a value that is never a promise.
const record = { v: 1 };
console.log(record instanceof Promise);
+12
View File
@@ -0,0 +1,12 @@
// The shape overlay's facts: results that include undefined, literals that
// hold comment-like text, a var declared in a branch no call takes, and
// a var declared in two such branches.
import { banner, hoisted, lookup, lookupOrReturn, shared } from "shape-facts";
console.log(lookup("b"));
console.log(lookup("z"));
console.log(lookupOrReturn("a"));
console.log(lookupOrReturn("y"));
console.log(banner(7));
console.log(hoisted("note:"));
console.log(shared("level:"));
@@ -0,0 +1,5 @@
export declare function lookup(key: string): string;
export declare function lookupOrReturn(key: string): string;
export declare function banner(n: number): string;
export declare function hoisted(label: string): string;
export declare function shared(label: string): string;
@@ -0,0 +1,70 @@
// Functions whose results include the undefined of a fall-through or a
// bare return, comment-like text in literals, a branch no call takes
// that declares a var read outside it, and two such branches that declare
// the same var.
const entries = [
{ key: "a", value: 1 },
{ key: "b", value: 2 },
];
function find(key) {
for (const entry of entries) {
if (entry.key === key) return entry;
}
}
function findOrReturn(key) {
for (const entry of entries) if (entry.key === key) return entry;
return;
}
export function lookup(key) {
const hit = find(key);
if (hit) {
return "found " + hit.key + "=" + hit.value;
} else {
return "missing " + key;
}
}
export function lookupOrReturn(key) {
const hit = findOrReturn(key);
if (hit) {
return "found " + hit.key + "=" + hit.value;
} else {
return "missing " + key;
}
}
export function banner(n) {
const quoted = "/** @type {number} */";
const template = `/** @returns {string} ${n} */`;
const pattern = /\/\*\* @param \*\//;
return quoted + " " + template + " " + pattern.source;
}
function noteOf(extra) {
if (extra) {
var note = "set";
}
return typeof note + " " + String(note);
}
export function hoisted(label) {
return label + " " + noteOf(undefined);
}
function levelOf(warn, fail) {
if (warn) {
var level = "warn";
}
if (fail) {
var level = "fail";
}
return typeof level + " " + String(level);
}
export function shared(label) {
return label + " " + levelOf(undefined, undefined);
}
@@ -0,0 +1 @@
{ "name": "shape-facts", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
{ "name": "shape-overlay-facts-case", "private": true, "type": "module" }
@@ -48,11 +48,4 @@ maybe-promise-fences.ts:27:50 - error SC2020: 'Promise.resolve over 'Promise<str
| ^~~~~~~~~~~~
28 | console.log(typeof widened);
hint: the promise arm passes through identically, so it must already be the result's promise type
maybe-promise-fences.ts:32:13 - error SC1090: 'instanceof Promise' on '{ v: number }' values (only promises, promise-armed unions, and checked-dynamic values have a lowering) are not supported yet
31 | const record = { v: 1 };
32 | console.log(record instanceof Promise);
| ^~~~~~~~~~~~~~~~~~~~~~~~~
33 |
hint: the promise arm passes through identically, so it must already be the result's promise type
@@ -0,0 +1 @@
{ "plain": 2500, "san": 5500 }
+126
View File
@@ -0,0 +1,126 @@
/* The shape overlay (shape-inference/overlay.ts): with SCRIPTC_SHAPE_OVERLAY=1
* the zod3-boundary npm case compiles zod's JavaScript with analysis-proven JSDoc
* types, reports what it typed and checks (SC6103), and prints what Node
* prints; the shapeOverlay: false option (the CLI's --no-shape-overlay)
* turns it off. */
import { execFile } from "node:child_process";
import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { expect, test } from "vitest";
import { compile } from "@scriptc/compiler";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const entry = join(repoRoot, "tests/fixtures/npm/cases/zod3-boundary/main.ts");
const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/shape-overlay");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
async function withOverlay<T>(run: () => Promise<T>): Promise<T> {
const saved = process.env["SCRIPTC_SHAPE_OVERLAY"];
process.env["SCRIPTC_SHAPE_OVERLAY"] = "1";
try {
return await run();
} finally {
if (saved === undefined) delete process.env["SCRIPTC_SHAPE_OVERLAY"];
else process.env["SCRIPTC_SHAPE_OVERLAY"] = saved;
}
}
test(
"the overlay types zod's JavaScript and keeps Node's output",
{ timeout: 900_000 },
async () => {
mkdirSync(outDir, { recursive: true });
const result = await withOverlay(() =>
compile(entry, { outPath: join(outDir, "on"), outDir, sanitize }),
);
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const report = (result.warnings ?? []).find((w) => w.code === "SC6103");
expect(report?.message).toMatch(
/shape inference typed \d+ declarations in \d+ files? of 1 package \(zod\)/,
);
const run = execFileAsync(result.binaryPath, [], { cwd: dirname(entry), encoding: "utf8" });
run.child.stdin?.end();
const node = await execFileAsync(process.execPath, [entry], {
cwd: dirname(entry),
encoding: "utf8",
});
expect((await run).stdout).toBe(node.stdout);
},
);
test("shapeOverlay: false compiles without the overlay", { timeout: 900_000 }, async () => {
mkdirSync(outDir, { recursive: true });
const result = await withOverlay(() =>
compile(entry, { outPath: join(outDir, "off"), outDir, sanitize, shapeOverlay: false }),
);
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
expect((result.warnings ?? []).some((w) => w.code === "SC6103")).toBe(false);
});
// shape-facts: results that include the undefined of a fall-through or a bare
// return keep the caller's falsy branch, comment-like text in literals keeps
// its value, and a replaced branch keeps its var declared, also when two
// replaced branches declare the same var.
const factsEntry = join(repoRoot, "tests/fixtures/npm/cases/shape-overlay-facts/main.ts");
test(
"the overlay keeps undefined results, literal text and hoisted vars",
{ timeout: 900_000 },
async () => {
mkdirSync(outDir, { recursive: true });
const result = await withOverlay(() =>
compile(factsEntry, { outPath: join(outDir, "facts"), outDir, sanitize }),
);
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const report = (result.warnings ?? []).find((w) => w.code === "SC6103");
expect(report?.message).toMatch(/of 1 package \(shape-facts\)/);
expect(report?.message).toMatch(/replaces 3 branches it proved unreachable/);
const native = await execFileAsync(result.binaryPath, [], {
cwd: dirname(factsEntry),
encoding: "utf8",
});
const node = await execFileAsync(process.execPath, [factsEntry], {
cwd: dirname(factsEntry),
encoding: "utf8",
});
expect(native.stdout).toBe(node.stdout);
},
);
// `"scriptc": { "packages": { "<name>": { "shapes": false } } }` in the
// project's package.json compiles that package without the overlay.
test(
"a package with shapes: false compiles without the overlay",
{ timeout: 900_000 },
async () => {
const project = join(outDir, "facts-shapeless");
rmSync(project, { recursive: true, force: true });
cpSync(dirname(factsEntry), project, { recursive: true });
writeFileSync(
join(project, "package.json"),
JSON.stringify({
name: "shape-overlay-facts-case",
private: true,
type: "module",
scriptc: { packages: { "shape-facts": { shapes: false } } },
}),
);
const result = await withOverlay(() =>
compile(join(project, "main.ts"), {
outPath: join(outDir, "facts-shapeless-bin"),
outDir,
sanitize,
}),
);
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
expect((result.warnings ?? []).some((w) => w.code === "SC6103" || w.code === "SC8004")).toBe(
false,
);
},
);