mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
Add the shape overlay: typed JavaScript from analysis-proven facts (opt-in) (#2176)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))) };
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8326-never-promise-instanceof.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/fixtures/npm/cases/shape-overlay-facts/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:"));
|
||||
+5
@@ -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;
|
||||
+70
@@ -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);
|
||||
}
|
||||
Generated
Vendored
+1
@@ -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 }
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user