Merge main

This commit is contained in:
Chris Tate
2026-10-25 16:32:09 -05:00
46 changed files with 1852 additions and 49 deletions
+19
View File
@@ -172,6 +172,25 @@ Each key names a package and a semver range of the installed versions the patch
A declaration is named by its path: `Options.name` for a member of a type, `parse()` for a function's return type, `parse(text)` for a parameter, `list()[]` for an array's elements, and `Env[string]` for an index signature. `export=()` names the return type of the function the module exports, `default` its default export, and a file prefix such as `index.js:parse()` names a declaration inside one of the package's files. One patch applies to the package's own declarations, its JSDoc, and its `@types/` package. The patched type then holds everywhere, so your code has to handle the `undefined` that a widened declaration allows. An optional `"note"` string can link the upstream issue.
For larger fixes, replace a whole declaration file, or give a package without declarations its own:
```json:package.json
{
"scriptc": {
"typePatches": {
"some-client@^3.2.0": {
"files": { "dist/types/options.d.ts": "./type-patches/options.d.ts" }
},
"untyped-helper@1.x": {
"types": "./type-patches/untyped-helper.d.ts"
}
}
}
}
```
`files` maps a declaration file inside the package, or inside its `@types/` package written as `@types/<name>/<path>`, to a local `.d.ts` file. `types` supplies the package's declaration entry. Paths of local files are relative to the `package.json`, and a replaced file can import the package's other declaration files with the same relative paths as the file it replaces.
Patches change only declarations, never the package's code, and values are still checked against the patched types. A build warns when a patch doesn't apply as written: when no range covers the installed version, when a declaration path matches nothing, when the declaration already has the type, or when a path is ambiguous. Report wrong declarations to the package too, so the patch can be removed later.
## Provenance sources (experimental)
@@ -25,3 +25,10 @@ Index-signature records list declared fields before the other keys.
Optional fields and properties added through a record's `unknown` view can be deleted.
A required field has no absent state, so deleting it through the view throws `TypeError: Cannot delete property 'x' of #<Object>` and leaves the record unchanged, where Node.js removes the property.
Records inherit `toString`, `toLocaleString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf` and `propertyIsEnumerable` from `Object.prototype`.
A call of one of these methods by name runs the built-in with the record as its receiver, unless the record holds an own function under that name.
A keyed read such as `handlers[name]` of a record whose values are functions answers the built-in when the record lacks the key, so truthiness, `typeof` and calls match Node.js.
A keyed read of a record whose values are not functions, such as `counts["toString"]` on a `Record<string, number>`, answers `undefined` where Node.js answers the inherited function, so `counts[word] ?? 0` counts such a word as new.
Calling a built-in read from a record of functions throws a `TypeError` when its answer does not fit the record's function type, for example `valueOf` read from a `Record<string, (x: number) => number>`.
Other inherited names, such as `constructor`, read `undefined` from typed records.
@@ -2,11 +2,12 @@ import { emitBorrowedInput } from "./borrowed-inputs.js";
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { undefinedArmTag } from "../../ir/analysis.js";
import { isRefCounted, isUnitType, typeEquals, type IrExpr } from "../../ir/ir.js";
import { isRefCounted, isUnitType, typeEquals, type IrExpr, type IrType } from "../../ir/ir.js";
import { DYN_KIND } from "./dyn.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { f64Lit } from "./common.js";
import { emitRecordKeyGetOr } from "./expr-records.js";
import { NULLABLE_NULL, SHAPE_WORD_INDEX, shapeWordHeader } from "./nullable-unions.js";
import { emitUnionTag } from "./union-repr.js";
@@ -397,6 +398,13 @@ export function emitControlExpr(
throw new InternalCompilerError("llvm emitter bug: nullish union lacks unit arms");
const fused = emitNullishOptionalChain(host, e);
if (fused) return fused;
if (
e.left.kind === "recordKeyGet" &&
e.left.missNaN !== true &&
typeEquals(e.type, e.left.type) &&
storesNoUnit(host, e.left)
)
return emitRecordKeyGetOr(host, e.left, e.right);
if (
!typeEquals(e.type, e.left.type) &&
(e.left.kind === "varRef" ||
@@ -700,3 +708,15 @@ function emitUnionTagSet(host: LlvmEmitterContext, set: TagSet): string {
B.line(`${t} = phi i1 [ true, %${yes} ], [ false, %${no} ]`);
return t;
}
/** Every value a keyed read can hit is present (no stored undefined or
* null), so only a missing key yields the read's unit arm. */
function storesNoUnit(host: LlvmEmitterContext, e: ExprOf<"recordKeyGet">): boolean {
const shape = host.recordShape(e.shapeId);
const holdsUnit = (t: IrType): boolean =>
isUnitType(t) ||
t.kind === "dyn" ||
(t.kind === "union" && (host.unionsById.get(t.unionId)?.arms ?? []).some(isUnitType));
if (shape.indexValue !== undefined && holdsUnit(shape.indexValue)) return false;
return e.overflowOnly === true || !shape.fields.some((f) => holdsUnit(f.type));
}
@@ -379,6 +379,39 @@ export function emitRegexIntrinsic(
}
}
/** `r[k] ?? d` in its pass-through shape: a missing key evaluates `d`
* where the read would store undefined, so a hit costs nothing extra. */
export function emitRecordKeyGetOr(
host: LlvmEmitterContext,
e: IrExpr & { kind: "recordKeyGet" },
fallback: IrExpr,
): LlValue {
const B = host.B;
const obj = host.emitExpr(e.obj);
const k = host.emitExpr(e.key);
const ty = host.llType(e.type);
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ${ty}`);
const join = B.newLabel("rkg.j");
keyedRecordReadInto(
host,
slot,
join,
obj.name,
k.name,
e.shapeId,
e.type,
e.overflowOnly === true,
e.loc,
false,
() => host.emitBranchInto(slot, fallback),
);
B.startBlock(join);
const t = B.tmp();
B.line(`${t} = load ${ty}, ptr ${slot}`);
return host.own({ name: t, type: e.type });
}
export function emitRecordKeyGet(
host: LlvmEmitterContext,
e: IrExpr & { kind: "recordKeyGet" },
@@ -526,6 +559,7 @@ export function keyedRecordReadInto(
overflowOnly: boolean,
loc?: SrcLoc,
missNaN = false,
missInto: (() => void) | null = null,
): void {
const B = host.B;
const shape = host.recordShape(shapeId);
@@ -728,8 +762,10 @@ export function keyedRecordReadInto(
}
}
if (def !== null && resultType.kind === "union" && undefTag >= 0) {
// The miss path: the result union's undefined arm.
B.line(`store ptr ${host.unitInstanceRef(resultType.unionId, undefTag)}, ptr ${slot}`);
// The miss path: the result union's undefined arm, or the caller's
// own value for a miss (missInto stores it into the slot).
if (missInto !== null) missInto();
else B.line(`store ptr ${host.unitInstanceRef(resultType.unionId, undefTag)}, ptr ${slot}`);
B.br(join);
return;
}
@@ -70,6 +70,7 @@ import { InternalCompilerError } from "../../../errors.js";
import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../../ir/build.js";
import { npmStaticPackageOfPath } from "../../npm-static.js";
import { isSafeToRepeat } from "./evaluation-safety.js";
import { withInheritedMethods } from "../record-inherited.js";
import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js";
import { isClassCallback } from "../class-callbacks.js";
import { isKeyedFieldAccess } from "./keyed-field-targets.js";
@@ -1445,7 +1446,17 @@ export function lowerRecordKeyRead(
loc,
};
if (nanCoded) return read;
return includeUndefined || optionalRead ? read : lowerer.maybeNarrow(read, expr);
const answered = withInheritedMethods(lowerer, read, litKey, isCalledAccess(expr));
return includeUndefined || optionalRead ? answered : lowerer.maybeNarrow(answered, expr);
}
/** The access is the callee of a call (`o[k](…)`, `o[k]?.(…)`, `o[k]!(…)`),
* which passes the object as `this`. */
function isCalledAccess(node: ts.Expression): boolean {
let child: ts.Expression = node;
while (ts.isParenthesizedExpression(child.parent) || ts.isNonNullExpression(child.parent))
child = child.parent;
return ts.isCallExpression(child.parent) && child.parent.expression === child;
}
/** ToPropertyKey for represented record keys. Missing array-derived string
@@ -115,6 +115,7 @@ import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../.
import { typedArrayLacksMember } from "../builtins/typed-array-members.js";
import { TEMPORAL_GLOBAL } from "../../builtin-modules.js";
import { tryLowerExpression } from "./try-lower-expression.js";
import { lowerRecordInheritedMember } from "../record-inherited.js";
import { DATE_METHOD_HINT } from "../builtins/dates.js";
import {
cjsClassExprWholeExportOf,
@@ -3188,6 +3189,12 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi
loc: locOf(expr),
};
}
// Object.prototype's methods read as values: an own entry of the
// member's function type, else the built-in.
if (actual?.type.kind === "record") {
const inherited = lowerRecordInheritedMember(lowerer, actual, expr);
if (inherited) return inherited;
}
// A member the checker declares on the receiver, stored in a pure
// index-signature record (an object literal that spread one, see
// dictSpreadLiteralShape): the key is always present, so the keyed
@@ -280,6 +280,7 @@ import {
} from "./generic-functions.js";
import { codeGenerationCalleeOf, lowerCodeGenerationCall } from "./code-generation.js";
import { lowerRecordInheritedCall } from "./record-inherited.js";
const ZLIB_STREAM_METHODS: ReadonlySet<string> = new Set(["flush", "params", "close", "reset"]);
@@ -1643,6 +1644,9 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
// Union receivers whose every arm has a text — the ngrok
// `(chunk: Buffer | string) => chunk.toString()` idiom.
lowerUnionToStringCall(lowerer, expr, expr.expression) ??
// Object.prototype's methods called by name on records: an own
// callable entry, else the built-in with the record as receiver.
lowerRecordInheritedCall(lowerer, expr, expr.expression) ??
// Object.prototype.toString's default answer on records and
// override-free program classes — "[object Object]", folded.
lowerDefaultToStringCall(lowerer, expr, expr.expression) ??
@@ -5422,6 +5422,12 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration)
const scope = decl.parent?.parent?.parent;
if (symbol === undefined || (!ts.isBlock(scope) && !ts.isSourceFile(scope))) return null;
// A shorthand property's name (`{ width }`) resolves to the property
// symbol, but it reads the binding: the checker's shorthand-value symbol.
const refersToBinding = (node: ts.Identifier): boolean =>
(ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node
? lowerer.checker.getShorthandAssignmentValueSymbol(node.parent)
: lowerer.checker.getSymbolAtLocation(node)) === symbol;
const isPlainAssignmentTarget = (node: ts.Identifier): boolean => {
let target: ts.Expression = node;
while (ts.isParenthesizedExpression(target.parent) && target.parent.expression === target) {
@@ -5437,11 +5443,7 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration)
const captures = (fn: ts.Node): boolean => {
let captured = false;
ts.walkPreorder(fn, (node) => {
if (
node !== fn &&
ts.isIdentifier(node) &&
lowerer.checker.getSymbolAtLocation(node) === symbol
) {
if (node !== fn && ts.isIdentifier(node) && refersToBinding(node)) {
captured = true;
return "stop";
}
@@ -5493,7 +5495,7 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration)
if (
ts.isIdentifier(node) &&
node !== decl.name &&
lowerer.checker.getSymbolAtLocation(node) === symbol &&
refersToBinding(node) &&
!isPlainAssignmentTarget(node)
) {
reads.push(node);
@@ -5234,11 +5234,11 @@ export class Lowerer {
const sourceFiles = parts.map((p) => p.sf);
const fnDecls = parts.flatMap((p) => p.fnDecls);
// This fixed-point scan intentionally sees deferred bodies before
// reachability is known. Batch their binding queries through the
// facade's panic fence, then keep lookup free of lowering side effects:
// resolveValueSymbol also flushes deferred diagnostics and applies
// merged-namespace fences, whose authority remains ordinary lowering.
this.checker.prefetchSymbolRoots(sourceFiles, true);
// reachability is known. Its binding queries were batched through the
// facade's panic fence before collection (prefetchProgramSymbols); keep
// lookup free of lowering side effects: resolveValueSymbol also flushes
// deferred diagnostics and applies merged-namespace fences, whose
// authority remains ordinary lowering.
// The facts each fixed-point site reads, by the symbols, nodes and
// element keys they belong to (created with the site list below).
let siteDependencies: SiteDependencies | null = null;
@@ -8040,6 +8040,7 @@ export class Lowerer {
if (this.remainder) {
for (const fp of parts) this.checker.prefetchSourceFile(fp.sf);
}
this.prefetchProgramSymbols(parts);
this.collectProgram(parts);
this.speculativeTypeQueries(() => {
extendAssertedRecordMembers(
@@ -8499,6 +8500,7 @@ export class Lowerer {
this.onEdge = (name) => {
collectionEdges.add(name);
};
this.prefetchProgramSymbols(parts);
this.collectProgram(parts);
this.speculativeTypeQueries(() => {
extendAssertedRecordMembers(
@@ -12209,6 +12211,17 @@ export class Lowerer {
return lowerClassExpressionInfo(this, expr);
}
/** Batch-prefetches the symbols of every runtime identifier of the
* program's files. Collection's early-binding scan and the runtime-
* optional analysis look them up across every body, reached or not;
* one batch answers both, instead of one request per identifier. */
private prefetchProgramSymbols(parts: FileParts[]): void {
this.checker.prefetchSymbolRoots(
parts.map((fp) => fp.sf),
true,
);
}
/** Depth of the whole-program analyses that query types across every
* file before reachability is known (speculativeTypeQueries). */
private speculativeTypeQueryDepth = 0;
@@ -0,0 +1,911 @@
/* Object.prototype's methods on typed records.
*
* A record is an ordinary object, so `r.toString()`, `r.hasOwnProperty(k)`
* and the other Object.prototype methods answer from its prototype unless
* the record holds an own entry under that name. Method calls with a
* constant name call an own entry when the record's type lets one be
* callable and otherwise run the built-in natively with the record as its
* receiver. Keyed reads of records whose values are functions or checked
* values answer the built-in as a native function value on the miss path,
* so truthiness, typeof and calls behave as in Node; hits are untouched.
* Records of other value types keep reading undefined for these names
* (a documented limitation). No dynamic-tier unit is involved: every
* built-in is a small lifted function over the record. */
import * as ts from "../ts7/adapter.js";
import { dynUndefinedExpr, nodeThrowExpr, varRef } from "../../ir/build.js";
import {
BOOL,
DYN,
STRING,
UNDEFINED_T,
isUnitType,
typeEquals,
typeKey,
type IrExpr,
type IrLocal,
type IrStmt,
type IrType,
type SrcLoc,
} from "../../ir/ir.js";
import type { Lowerer } from "./lowerer.js";
import { locOf } from "../program.js";
import { keyedHelperName } from "./stable-names.js";
import { isSafeToRepeat } from "./expressions/evaluation-safety.js";
import { nullProtoRecordShape, recordNullProtoTest } from "./null-prototype.js";
import { recordHasOwnHelper } from "./lower-calls.js";
import { OTHER_ARM_FAILURE_MESSAGE } from "./coercions/unions.js";
/** Object.prototype's methods a record answers from its prototype. */
export const RECORD_INHERITED_METHODS: ReadonlySet<string> = new Set([
"toString",
"toLocaleString",
"valueOf",
"hasOwnProperty",
"isPrototypeOf",
"propertyIsEnumerable",
]);
type FuncType = IrType & { kind: "func" };
/** Declares a hidden local in the function being built: the lowerer's
* current function, or a lifted helper's own local list. */
type DeclareLocal = (name: string, type: IrType) => string;
function liftedLocals(): { declare: DeclareLocal; locals: IrLocal[] } {
const locals: IrLocal[] = [];
return {
locals,
declare: (name, type) => {
const id = `${name}.${locals.length}`;
locals.push({ id, name, type, mutable: false });
return id;
},
};
}
/** The form a read of `type` gives a built-in method value: the union's one
* function arm (beside an undefined arm), a checked value, or null when the
* type cannot hold a function. */
function methodValueForm(lowerer: Lowerer, type: IrType): FuncType | "dyn" | null {
if (type.kind === "dyn") return "dyn";
if (type.kind !== "union") return null;
const arms = lowerer.unions.get(type.unionId)?.arms ?? [];
if (!arms.some((arm) => arm.kind === "undefinedT")) return null;
const funcs = arms.filter((arm): arm is FuncType => arm.kind === "func");
if (funcs.length !== 1 || funcs[0]!.restAbi !== undefined) return null;
return funcs[0]!;
}
/** A value of `type` may be a function that a call can enter. */
function callableType(lowerer: Lowerer, type: IrType): boolean {
if (type.kind === "func" || type.kind === "dyn") return true;
if (type.kind !== "union") return false;
return (lowerer.unions.get(type.unionId)?.arms ?? []).some(
(arm) => arm.kind === "func" || arm.kind === "dyn",
);
}
/** A keyed record read (`r[k]`, `r["toString"]`) whose miss answers the
* inherited Object.prototype method for those names. Hits keep the
* ordinary read; only the miss path tests the key. `bound`: the read is
* the callee of a call (`r[k](…)`, `r[k]?.(…)`), whose `this` is the
* record; any other read yields the method as a plain value, whose calls
* see an undefined `this` as in Node. */
export function withInheritedMethods(
lowerer: Lowerer,
read: IrExpr & { kind: "recordKeyGet" },
litKey: string | null,
bound: boolean,
): IrExpr {
if (litKey !== null && !RECORD_INHERITED_METHODS.has(litKey)) return read;
if (read.missNaN === true) return read;
const form = methodValueForm(lowerer, read.type);
const shape = lowerer.shapes.get(read.shapeId);
if (form === null || !shape || shape.tuple) return read;
const loc = read.loc;
const stmts: IrStmt[] = [];
const repeatable = (value: IrExpr): boolean => isSafeToRepeat(value) || value.kind === "strLit";
const stable = (value: IrExpr, name: string, keep: boolean): IrExpr => {
if (keep) return value;
const local = lowerer.declareHiddenLocal(name, value.type);
stmts.push({ kind: "varDecl", localId: local.id, init: value, loc });
return varRef(local.id, value.type, loc);
};
// The receiver is evaluated before the key: when computing the key runs
// code (it may reassign the receiver's variable), the receiver is held
// first, even when reading it again would be safe on its own.
const keyRepeatable = repeatable(read.key);
const obj = stable(read.obj, "%inheritedRecv", repeatable(read.obj) && keyRepeatable);
const key = stable(read.key, "%inheritedKey", keyRepeatable);
const helper = inheritedReadHelper(lowerer, read.shapeId, read.type, form, bound, loc);
const fallback: IrExpr = { kind: "call", callee: helper, args: [obj, key], type: read.type, loc };
if (
form !== "dyn" &&
read.type.kind === "union" &&
!(lowerer.unions.get(read.type.unionId)?.arms ?? []).some((arm) => arm.kind === "nullT")
) {
// `r[k] ?? inherited(r, k)`: the hit is the read's own value, untouched.
const coalesced: IrExpr = {
kind: "nullish",
left: { ...read, obj, key },
right: fallback,
type: read.type,
loc,
};
return stmts.length === 0
? coalesced
: { kind: "seqExpr", stmts, result: coalesced, type: read.type, loc };
}
const value = lowerer.declareHiddenLocal("%inheritedRead", read.type);
stmts.push({ kind: "varDecl", localId: value.id, init: { ...read, obj, key }, loc });
const found = varRef(value.id, read.type, loc);
let missing: IrExpr;
if (form === "dyn") {
missing = { kind: "dynTest", test: "undefined", value: found, type: BOOL, loc } as IrExpr;
} else {
const unionId = (read.type as IrType & { kind: "union" }).unionId;
const tag = (lowerer.unions.get(unionId)?.arms ?? []).findIndex(
(arm) => arm.kind === "undefinedT",
);
missing = { kind: "unionIsTag", unionId, tag, negated: false, value: found, type: BOOL, loc };
}
return {
kind: "seqExpr",
stmts,
result: {
kind: "ternary",
cond: missing,
then: fallback,
else_: found,
type: read.type,
loc,
},
type: read.type,
loc,
};
}
/** `%rec.inherited.<n>(o, k)`: the inherited method `k` names as a value
* of `readType` (bound to `o`), or undefined. */
function inheritedReadHelper(
lowerer: Lowerer,
shapeId: string,
readType: IrType,
form: FuncType | "dyn",
bound: boolean,
loc: SrcLoc,
): string {
const key = `rec.inherited:${bound ? "bound" : "value"}:${shapeId}:${typeKey(readType)}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, "%rec.inherited", key);
lowerer.arrHofHelpers.set(key, name);
const recT: IrType = { kind: "record", shapeId };
const o = varRef("o.0", recT, loc);
const k = varRef("k.0", STRING, loc);
const fnType: FuncType =
form === "dyn" ? ({ kind: "func", params: [DYN], ret: DYN } as FuncType) : form;
const undefinedValue: IrExpr =
form === "dyn"
? dynUndefinedExpr(loc)
: lowerer.coerceToExpected(
{ kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc },
readType,
);
const named = (method: string): IrExpr => ({
kind: "strEq",
negated: false,
left: k,
right: { kind: "strLit", value: method, type: STRING, loc },
type: BOOL,
loc,
});
const methods = [...RECORD_INHERITED_METHODS];
let anyName: IrExpr = named(methods[0]!);
for (const method of methods.slice(1))
anyName = { kind: "logical", op: "||", left: anyName, right: named(method), type: BOOL, loc };
const body: IrStmt[] = [
// Most misses name no inherited method: one comparison chain, nothing else.
{
kind: "if",
cond: { kind: "unary", op: "!", operand: anyName, type: BOOL, loc },
then: [{ kind: "return", value: { ...undefinedValue }, loc }],
else_: null,
loc,
},
// An own key holding undefined shadows the prototype like any own key.
{
kind: "if",
cond: {
kind: "call",
callee: recordHasOwnHelper(lowerer, shapeId, loc),
args: [o, k],
type: BOOL,
loc,
},
then: [{ kind: "return", value: { ...undefinedValue }, loc }],
else_: null,
loc,
},
];
if (nullProtoRecordShape(lowerer, recT))
body.push({
kind: "if",
cond: recordNullProtoTest(o, shapeId, loc),
then: [{ kind: "return", value: { ...undefinedValue }, loc }],
else_: null,
loc,
});
for (const method of methods) {
const value: IrExpr = bound
? {
kind: "call",
callee: boundMethodFactory(lowerer, shapeId, method, fnType, loc),
args: [o],
type: fnType,
loc,
}
: {
kind: "closure",
fnName: unboundMethodImpl(lowerer, method, fnType, loc),
captures: [],
type: fnType,
loc,
};
body.push({
kind: "if",
cond: named(method),
then: [
{
kind: "return",
value: lowerer.coerceToExpected(value, form === "dyn" ? DYN : readType),
loc,
},
],
else_: null,
loc,
});
}
body.push({ kind: "return", value: { ...undefinedValue }, loc });
lowerer.liftedFns.push({
name,
params: [
{ localId: "o.0", name: "o", type: recT },
{ localId: "k.0", name: "k", type: STRING },
],
returnType: readType,
locals: [
{ id: "o.0", name: "o", type: recT, mutable: false },
{ id: "k.0", name: "k", type: STRING, mutable: false },
],
body,
loc,
});
return name;
}
/** `%rec.bind.<n>(o)`: the built-in `method` bound to `o` as a closure. */
function boundMethodFactory(
lowerer: Lowerer,
shapeId: string,
method: string,
fnType: FuncType,
loc: SrcLoc,
): string {
const key = `rec.bind:${shapeId}:${method}:${typeKey(fnType)}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, "%rec.bind", key);
lowerer.arrHofHelpers.set(key, name);
const recT: IrType = { kind: "record", shapeId };
lowerer.liftedFns.push({
name,
params: [{ localId: "o.0", name: "o", type: recT }],
returnType: fnType,
locals: [{ id: "o.0", name: "o", type: recT, mutable: false, boxed: true }],
body: [
{
kind: "return",
value: {
kind: "closure",
fnName: inheritedMethodImpl(lowerer, shapeId, method, fnType, loc),
captures: ["o.0"],
type: fnType,
loc,
},
loc,
},
],
loc,
});
return name;
}
/** The lifted body of one built-in bound to its record, with the read's
* function signature: the built-in's answer converted to the signature's
* return type, or the checked-conversion TypeError where it cannot be. */
function inheritedMethodImpl(
lowerer: Lowerer,
shapeId: string,
method: string,
fnType: FuncType,
loc: SrcLoc,
): string {
const key = `rec.inheritedImpl:${shapeId}:${method}:${typeKey(fnType)}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, `%rec.${method}`, key);
lowerer.arrHofHelpers.set(key, name);
const recT: IrType = { kind: "record", shapeId };
const o = varRef("o.0", recT, loc);
const params = fnType.params.map((type, i) => ({ localId: `p${i}.0`, name: `p${i}`, type }));
const args = params.map((p) => varRef(p.localId, p.type, loc));
const body: IrStmt[] = [];
const hidden = liftedLocals();
const answer = builtinAnswer(lowerer, hidden.declare, o, shapeId, method, args, fnType.ret, loc);
body.push(...returnAs(lowerer, answer, fnType.ret, loc));
lowerer.liftedFns.push({
name,
params,
returnType: fnType.ret,
captures: [{ localId: "o.0", name: "o", type: recT }],
locals: [
{ id: "o.0", name: "o", type: recT, mutable: false, boxed: true },
...params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })),
...hidden.locals,
],
body,
loc,
});
return name;
}
/** One built-in as a plain function value (no receiver): its answer for an
* undefined `this`, converted like inheritedMethodImpl's. */
function unboundMethodImpl(
lowerer: Lowerer,
method: string,
fnType: FuncType,
loc: SrcLoc,
): string {
const key = `rec.unboundImpl:${method}:${typeKey(fnType)}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, `%obj.${method}`, key);
lowerer.arrHofHelpers.set(key, name);
const params = fnType.params.map((type, i) => ({ localId: `p${i}.0`, name: `p${i}`, type }));
const arg: IrExpr | undefined = params[0] && varRef(params[0].localId, params[0].type, loc);
const coerce = "Cannot convert undefined or null to object";
let answer: IrExpr;
switch (method) {
case "toString":
answer = { kind: "strLit", value: "[object Undefined]", type: STRING, loc };
break;
case "toLocaleString":
answer = nodeThrowExpr(
1,
"",
"Object.prototype.toLocaleString called on null or undefined",
STRING,
loc,
);
break;
case "isPrototypeOf": {
// A primitive argument answers false before `this` converts.
const no: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc };
const kind = arg?.type.kind;
if (
arg === undefined ||
isUnitType(arg.type) ||
kind === "string" ||
kind === "f64" ||
kind === "bool" ||
kind === "bigint"
)
answer = no;
else if (kind === "dyn")
answer = {
kind: "ternary",
cond: {
kind: "logical",
op: "||",
left: { kind: "dynTest", test: "object", value: arg, type: BOOL, loc },
right: { kind: "dynTest", test: "function", value: arg, type: BOOL, loc },
type: BOOL,
loc,
} as IrExpr,
then: nodeThrowExpr(1, "", coerce, BOOL, loc),
else_: no,
type: BOOL,
loc,
};
else answer = nodeThrowExpr(1, "", coerce, BOOL, loc);
break;
}
default:
answer = nodeThrowExpr(1, "", coerce, method === "valueOf" ? DYN : BOOL, loc);
}
const body: IrStmt[] =
answer.kind === "libCall" && answer.fn === "error.nodeThrow"
? [
{
kind: "return",
value: nodeThrowExpr(
1,
"",
(answer.args[2] as IrExpr & { kind: "strLit" }).value,
fnType.ret,
loc,
),
loc,
},
]
: returnAs(lowerer, answer, fnType.ret, loc);
lowerer.liftedFns.push({
name,
params,
returnType: fnType.ret,
locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })),
body,
loc,
});
return name;
}
/** The built-in's answer with `o` as its receiver. `want` is the type the
* caller needs (valueOf answers the record itself). */
function builtinAnswer(
lowerer: Lowerer,
declare: DeclareLocal,
o: IrExpr,
shapeId: string,
method: string,
args: readonly IrExpr[],
want: IrType,
loc: SrcLoc,
): IrExpr {
switch (method) {
case "toString":
return { kind: "strLit", value: "[object Object]", type: STRING, loc };
case "toLocaleString":
// Object.prototype.toLocaleString calls this.toString().
return callOwnOrBuiltin(
lowerer,
declare,
o,
shapeId,
"toString",
[],
STRING,
"toString",
loc,
);
case "valueOf":
return want.kind === "dyn" ? lowerer.coerceToExpected(o, DYN) : o;
case "isPrototypeOf":
// No object is created with a typed record as its prototype.
return { kind: "boolLit", value: false, type: BOOL, loc };
default: {
// hasOwnProperty and propertyIsEnumerable: ToPropertyKey keeps a
// symbol a symbol, and a record's own keys are strings, so a symbol
// key is no entry of it.
const no: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc };
const arg = args[0];
if (arg?.type.kind === "symbol") return sequence(arg, no, loc);
const has = (key: IrExpr): IrExpr => ({
kind: "call",
callee: recordHasOwnHelper(lowerer, shapeId, loc),
args: [o, key],
type: BOOL,
loc,
});
if (arg === undefined || !mayBeSymbol(arg.type)) return has(propertyKeyOf(lowerer, arg, loc));
const id = declare("%protoKey", DYN);
const held = varRef(id, DYN, loc);
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: id, init: lowerer.coerceToExpected(arg, DYN), loc }],
result: {
kind: "ternary",
cond: { kind: "dynTest", test: "symbol", value: held, type: BOOL, loc } as IrExpr,
then: no,
else_: has(propertyKeyOf(lowerer, held, loc)),
type: BOOL,
loc,
},
type: BOOL,
loc,
};
}
}
}
/** Whether a value of `type` may be a symbol at run time. */
function mayBeSymbol(type: IrType): boolean {
return !(
isUnitType(type) ||
type.kind === "string" ||
type.kind === "f64" ||
type.kind === "bool"
);
}
/** ToPropertyKey of a built-in's key argument (absent: "undefined"), for
* keys that are not symbols. */
function propertyKeyOf(lowerer: Lowerer, arg: IrExpr | undefined, loc: SrcLoc): IrExpr {
if (arg === undefined || isUnitType(arg.type))
return {
kind: "strLit",
value: arg?.type.kind === "nullT" ? "null" : "undefined",
type: STRING,
loc,
};
if (arg.type.kind === "string") return arg;
if (arg.type.kind === "f64" || arg.type.kind === "bool")
return { kind: "toString", operand: arg, type: STRING, loc };
return {
kind: "libCall",
fn: "dyn.toStringCoerce",
args: [lowerer.coerceToExpected(arg, DYN)],
type: STRING,
loc,
};
}
/** `value` returned as `ret`: as is, wrapped into a union arm or a checked
* value, discarded for void, or the checked-conversion TypeError. */
function returnAs(lowerer: Lowerer, value: IrExpr, ret: IrType, loc: SrcLoc): IrStmt[] {
const converted = convertTo(lowerer, value, ret);
if (converted !== null) return [{ kind: "return", value: converted, loc }];
if (ret.kind === "void")
return [
{ kind: "exprStmt", expr: value, loc },
{ kind: "return", value: null, loc },
];
return [
{ kind: "exprStmt", expr: value, loc },
{ kind: "return", value: nodeThrowExpr(1, "", OTHER_ARM_FAILURE_MESSAGE, ret, loc), loc },
];
}
function convertTo(lowerer: Lowerer, value: IrExpr, want: IrType): IrExpr | null {
if (typeEquals(value.type, want)) return value;
if (want.kind === "dyn" && lowerer.dynConvertible(value.type))
return lowerer.coerceToExpected(value, DYN);
if (want.kind === "union" && lowerer.armTag(want.unionId, value.type) >= 0)
return lowerer.coerceToExpected(value, want);
return null;
}
/** `o.method(...args)` on a record: an own callable entry when the record
* holds one, else the built-in. `calleeText` names the callee for Node's
* "is not a function". */
function callOwnOrBuiltin(
lowerer: Lowerer,
declare: DeclareLocal,
o: IrExpr,
shapeId: string,
method: string,
args: readonly IrExpr[],
want: IrType,
calleeText: string,
loc: SrcLoc,
resolved?: { has: IrExpr; own: IrExpr },
): IrExpr {
const shape = lowerer.shapes.get(shapeId)!;
const field = shape.fields.find((f) => f.name === method);
const ownType = field ? field.type : shape.indexValue;
let builtin = builtinAnswer(lowerer, declare, o, shapeId, method, args, want, loc);
const recT: IrType = { kind: "record", shapeId };
if (nullProtoRecordShape(lowerer, recT))
builtin = {
kind: "ternary",
cond: recordNullProtoTest(o, shapeId, loc),
then: nodeThrowExpr(1, "", `${calleeText} is not a function`, builtin.type, loc),
else_: builtin,
type: builtin.type,
loc,
};
const builtinAsWant = convertTo(lowerer, builtin, want) ?? builtin;
if (ownType === undefined || !callableType(lowerer, ownType)) return builtinAsWant;
const own = resolved?.own ?? ownEntryRead(lowerer, o, shapeId, method, loc);
const called = callOwn(lowerer, declare, own, args, builtinAsWant.type, calleeText, loc);
return {
kind: "ternary",
cond: resolved?.has ?? ownEntryTest(lowerer, o, shapeId, method, loc),
then: called,
else_: builtinAsWant,
type: builtinAsWant.type,
loc,
};
}
/** Whether the record holds an own entry named `method`. */
function ownEntryTest(
lowerer: Lowerer,
o: IrExpr,
shapeId: string,
method: string,
loc: SrcLoc,
): IrExpr {
return {
kind: "call",
callee: recordHasOwnHelper(lowerer, shapeId, loc),
args: [o, { kind: "strLit", value: method, type: STRING, loc }],
type: BOOL,
loc,
};
}
/** The record's own entry named `method`: its field, or its overflow entry
* (undefined when absent). */
function ownEntryRead(
lowerer: Lowerer,
o: IrExpr,
shapeId: string,
method: string,
loc: SrcLoc,
): IrExpr {
const shape = lowerer.shapes.get(shapeId)!;
const field = shape.fields.find((f) => f.name === method);
return field
? { kind: "recordGet", obj: o, shapeId, field: method, type: field.type, loc }
: {
kind: "recordKeyGet",
obj: o,
shapeId,
key: { kind: "strLit", value: method, type: STRING, loc },
overflowOnly: true,
type: lowerer.withUndefinedArmOf(shape.indexValue!) ?? shape.indexValue!,
loc,
};
}
/** Call the own entry `own` (present, of any stored type) with `args`. A
* non-function throws Node's "is not a function"; a function whose
* parameters or result cannot carry the call throws the checked-conversion
* TypeError (the program's types claimed otherwise). */
function callOwn(
lowerer: Lowerer,
declare: DeclareLocal,
own: IrExpr,
args: readonly IrExpr[],
want: IrType,
calleeText: string,
loc: SrcLoc,
): IrExpr {
const notFunction = nodeThrowExpr(1, "", `${calleeText} is not a function`, want, loc);
const lie = nodeThrowExpr(1, "", OTHER_ARM_FAILURE_MESSAGE, want, loc);
const localId = declare("%ownMethod", own.type);
const value = varRef(localId, own.type, loc);
const enter = (fn: IrExpr): IrExpr => {
if (fn.type.kind === "dyn") {
const result: IrExpr = {
kind: "dynCall",
callee: fn,
calleeName: calleeText,
args: args.map((a) => lowerer.coerceToExpected(a, DYN)),
type: DYN,
loc,
} as IrExpr;
return convertTo(lowerer, result, want) ?? sequence(result, lie, loc);
}
const sig = fn.type as FuncType;
if (sig.restAbi !== undefined) return lie;
const passed: IrExpr[] = [];
for (let i = 0; i < sig.params.length; i++) {
const param = sig.params[i]!;
const arg = args[i];
const given =
arg === undefined
? convertTo(
lowerer,
{ kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc },
param,
)
: convertTo(lowerer, arg, param);
if (given === null) return lie;
passed.push(given);
}
const result: IrExpr = { kind: "callValue", callee: fn, args: passed, type: sig.ret, loc };
if (sig.ret.kind === "void") return sequence(result, lie, loc);
return convertTo(lowerer, result, want) ?? sequence(result, lie, loc);
};
let call: IrExpr;
if (own.type.kind === "func" || own.type.kind === "dyn") call = enter(value);
else if (own.type.kind === "union") {
const unionId = own.type.unionId;
const arms = lowerer.unions.get(unionId)?.arms ?? [];
call = notFunction;
for (let tag = arms.length - 1; tag >= 0; tag--) {
const arm = arms[tag]!;
if (arm.kind !== "func" && arm.kind !== "dyn") continue;
call = {
kind: "ternary",
cond: { kind: "unionIsTag", unionId, tag, negated: false, value, type: BOOL, loc },
then: enter({ kind: "unionNarrow", unionId, tag, value, type: arm, loc }),
else_: call,
type: want,
loc,
};
}
} else call = notFunction;
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId, init: own, loc }],
result: call,
type: want,
loc,
};
}
function sequence(first: IrExpr, result: IrExpr, loc: SrcLoc): IrExpr {
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: first, loc }],
result,
type: result.type,
loc,
};
}
/** `r.toString()`, `r.hasOwnProperty(k)` and the other Object.prototype
* methods called by name on a typed record (the checker resolved the
* member to Object.prototype's). Null for other calls. */
export function lowerRecordInheritedCall(
lowerer: Lowerer,
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
): IrExpr | null {
const method = access.name.text;
if (
call.questionDotToken ||
access.questionDotToken ||
!RECORD_INHERITED_METHODS.has(method) ||
call.arguments.nodes.some(ts.isSpreadElement) ||
!lowerer.isStdlibMember(access)
)
return null;
const recvT = lowerer.mapTypeOf(lowerer.typeOf(access.expression));
if (recvT?.kind !== "record") return null;
const shape = lowerer.shapes.get(recvT.shapeId);
if (!shape || shape.tuple) return null;
const want =
method === "valueOf"
? (lowerer.mapTypeOf(lowerer.typeOf(call)) ?? recvT)
: method === "toString" || method === "toLocaleString"
? STRING
: BOOL;
// Without a possible own function, toString and the own-key tests keep
// their existing lowerings (with the special record views they know).
if (
(method === "hasOwnProperty" || method === "propertyIsEnumerable" || method === "toString") &&
!shape.fields.some((f) => f.name === method && callableType(lowerer, f.type)) &&
!(shape.indexValue && callableType(lowerer, shape.indexValue))
)
return null;
const loc = locOf(call);
const stmts: IrStmt[] = [];
const receiver = lowerer.lowerExpr(access.expression);
if (receiver.type.kind !== "record") return null;
const recvLocal = lowerer.declareHiddenLocal("%protoRecv", receiver.type);
stmts.push({ kind: "varDecl", localId: recvLocal.id, init: receiver, loc });
const o = varRef(recvLocal.id, receiver.type, loc);
// `o.m(...)` reads the member before its arguments run: an argument that
// adds, deletes or replaces the own method does not change the callee.
const field = shape.fields.find((f) => f.name === method);
const ownType = field ? field.type : shape.indexValue;
let resolved: { has: IrExpr; own: IrExpr } | undefined;
if (ownType !== undefined && callableType(lowerer, ownType)) {
const shapeId = receiver.type.shapeId;
const hasLocal = lowerer.declareHiddenLocal("%protoHas", BOOL);
stmts.push({
kind: "varDecl",
localId: hasLocal.id,
init: ownEntryTest(lowerer, o, shapeId, method, loc),
loc,
});
const read = ownEntryRead(lowerer, o, shapeId, method, loc);
const ownLocal = lowerer.declareHiddenLocal("%protoOwn", read.type);
stmts.push({ kind: "varDecl", localId: ownLocal.id, init: read, loc });
resolved = { has: varRef(hasLocal.id, BOOL, loc), own: varRef(ownLocal.id, read.type, loc) };
}
const args: IrExpr[] = [];
for (const node of call.arguments.nodes) {
const arg = lowerer.lowerExpr(node);
const local = lowerer.declareHiddenLocal("%protoArg", arg.type);
stmts.push({ kind: "varDecl", localId: local.id, init: arg, loc });
args.push(varRef(local.id, arg.type, loc));
}
const result = callOwnOrBuiltin(
lowerer,
(name, type) => lowerer.declareHiddenLocal(name, type).id,
o,
receiver.type.shapeId,
method,
args,
want,
ts.getTextOfNode(access),
loc,
resolved,
);
return { kind: "seqExpr", stmts, result, type: result.type, loc };
}
/** `r.toString` and the other Object.prototype methods read by name (not
* called) on an index-signature record: an own entry of the member's
* function type when the record holds one, else the built-in as a plain
* function value. An own entry of another type than the member's (a
* function of another signature) throws the checked-conversion TypeError:
* the program's types claimed the member. Null when the checker's member
* type is not a function. */
export function lowerRecordInheritedMember(
lowerer: Lowerer,
receiver: IrExpr,
expr: ts.PropertyAccessExpression,
): IrExpr | null {
const method = expr.name.text;
if (!RECORD_INHERITED_METHODS.has(method) || !lowerer.isStdlibMember(expr)) return null;
if (receiver.type.kind !== "record") return null;
const want = lowerer.mapTypeOf(lowerer.typeOf(expr));
if (want?.kind !== "func" || want.restAbi !== undefined) return null;
const shapeId = receiver.type.shapeId;
const shape = lowerer.shapes.get(shapeId);
if (!shape || shape.tuple) return null;
const loc = locOf(expr);
const builtin: IrExpr = {
kind: "closure",
fnName: unboundMethodImpl(lowerer, method, want, loc),
captures: [],
type: want,
loc,
};
const recvLocal = lowerer.declareHiddenLocal("%protoRecv", receiver.type);
const o = varRef(recvLocal.id, receiver.type, loc);
const stmts: IrStmt[] = [{ kind: "varDecl", localId: recvLocal.id, init: receiver, loc }];
let result: IrExpr = builtin;
if (shape.indexValue && typeEquals(shape.indexValue, want)) {
const ownType = lowerer.withUndefinedArmOf(want);
if (ownType?.kind === "union") {
const read: IrExpr = {
kind: "recordKeyGet",
obj: o,
shapeId,
key: { kind: "strLit", value: method, type: STRING, loc },
overflowOnly: true,
type: ownType,
loc,
};
const tag = lowerer.armTag(ownType.unionId, want);
const ownLocal = lowerer.declareHiddenLocal("%ownMethod", ownType);
const own = varRef(ownLocal.id, ownType, loc);
stmts.push({ kind: "varDecl", localId: ownLocal.id, init: read, loc });
result = {
kind: "ternary",
cond: {
kind: "unionIsTag",
unionId: ownType.unionId,
tag,
negated: false,
value: own,
type: BOOL,
loc,
},
then: { kind: "unionNarrow", unionId: ownType.unionId, tag, value: own, type: want, loc },
else_: builtin,
type: want,
loc,
};
}
} else if (shape.indexValue && callableType(lowerer, shape.indexValue)) {
result = {
kind: "ternary",
cond: ownEntryTest(lowerer, o, shapeId, method, loc),
then: nodeThrowExpr(1, "", OTHER_ARM_FAILURE_MESSAGE, want, loc),
else_: builtin,
type: want,
loc,
};
}
return { kind: "seqExpr", stmts, result, type: want, loc };
}
+2 -1
View File
@@ -23,7 +23,7 @@ import {
trackedRealpath,
} from "./input-tracker.js";
import { npmBuiltinAliasOf } from "./builtin-modules.js";
import { packageSourceText } from "./type-patches/layer.js";
import { packageSourceText, typePatchSuppliesTypes } from "./type-patches/layer.js";
let activePackages: ReadonlySet<string> = new Set();
let declarationOverloads: ReadonlyMap<string, NpmStaticDeclarationOverloads> = new Map();
@@ -374,6 +374,7 @@ function packageIsUntyped(path: string): boolean {
/* no package.json — keep probing */
}
if (untyped && trackedExists(`${pkgDir}/index.d.ts`)) untyped = false;
if (untyped && typePatchSuppliesTypes(pkgDir)) untyped = false;
if (untyped) {
// the @types twin, hoisted anywhere up the realm chain
const mangled = mangledTypesName(dirName);
+3 -1
View File
@@ -37,9 +37,11 @@ import {
trackedRealpath,
} from "./input-tracker.js";
import { isRelativeSpecifier, packageNameOfSpecifier } from "./workspace-registry.js";
import { typePatchFileExists } from "./type-patches/layer.js";
function isFile(path: string): boolean {
return trackedFileExists(path);
// A declaration entry a project type patch supplies (type-patches/layer.ts).
return trackedFileExists(path) || typePatchFileExists(path);
}
function isDirectory(path: string): boolean {
@@ -46,7 +46,7 @@ import {
trackedRealpath,
} from "../input-tracker.js";
import { packageShimDeclarationText } from "../package-shims.js";
import { typePatchedText } from "../type-patches/layer.js";
import { typePatchedText, typePatchFileExists } from "../type-patches/layer.js";
import { ambientDtsPath, isFallbackDtsPath, overridesDtsPath } from "../dts-paths.js";
import { reusedLibDiagnostics } from "./lib-diagnostics.js";
import { ts7Diagnostic } from "./diagnostic.js";
@@ -190,7 +190,7 @@ export class Ts7Host {
fileExists: (fileName) => {
if (virtualFiles.has(tsgoPath(fileName))) return true;
if (shadow !== null && shadow.hideFile(fileName)) return false;
return trackedFileExists(fileName);
return trackedFileExists(fileName) || typePatchFileExists(fileName);
},
directoryExists: (path) => trackedDirectoryExists(path),
realpath: (path) =>
@@ -27,7 +27,7 @@ import { dirname, relative } from "node:path";
import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js";
import { typePatchDiag } from "../../diagnostics/diagnostic.js";
import { readProjectConfig, type TypePatchEntry } from "../../project-config.js";
import { trackedReadFile } from "../input-tracker.js";
import { trackedFileExists, trackedReadFile } from "../input-tracker.js";
import type { SourceFile } from "../ts7/syntax.js";
import { applyPatchEdits, patchFile, type PatchMatch, type PatchRequest } from "./locate.js";
import { parseSelector, parseTypeText, type Selector, type TypePatchOp } from "./selector.js";
@@ -49,6 +49,11 @@ interface CopyInfo {
root: string;
/** Requests whose entry's range the version satisfies. */
requests: Request[];
/** Replaced declaration files: package-relative path → local file. */
files: Map<string, string>;
/** Declaration entries a `types` patch supplies: package-relative path
* → local file (existing entries are replaced, missing ones appear). */
entries: Map<string, string>;
}
interface MatchRecord extends PatchMatch {
@@ -63,9 +68,11 @@ interface State {
projectDir: string;
manifestText: string;
parse: TypePatchParse;
byPackage: Map<string, Request[]>;
byPackage: Map<string, { entries: TypePatchEntry[]; requests: Request[] }>;
copies: Map<string, CopyInfo | null>;
files: Map<string, string | null>;
/** `files` targets that do not exist in a copy. */
missingFiles: { entry: TypePatchEntry; root: string; rel: string }[];
/** Package copies whose version no patch range covers. */
unmatchedCopies: Map<string, { pkg: string; version: string | null; entries: TypePatchEntry[] }>;
/** Requests applicable to a copy this load read files of. */
@@ -86,22 +93,23 @@ export function configureTypePatches(entryPath: string, parse: TypePatchParse):
state = null;
return;
}
const byPackage = new Map<string, Request[]>();
const byPackage = new Map<string, { entries: TypePatchEntry[]; requests: Request[] }>();
let id = 0;
for (const entry of config.typePatches) {
const pkg = byPackage.get(entry.pkg) ?? { entries: [], requests: [] };
byPackage.set(entry.pkg, pkg);
pkg.entries.push(entry);
for (const decl of entry.declarations) {
const selector = parseSelector(decl.selector);
const op = parseTypeText(decl.type);
if ("error" in selector || "error" in op) continue; // reported as SC8004
const list = byPackage.get(entry.pkg) ?? [];
list.push({
pkg.requests.push({
id: id++,
selector: selector as Selector,
op: op as TypePatchOp,
entry,
offset: decl.offset,
});
byPackage.set(entry.pkg, list);
}
}
state = {
@@ -112,6 +120,7 @@ export function configureTypePatches(entryPath: string, parse: TypePatchParse):
byPackage,
copies: new Map(),
files: new Map(),
missingFiles: [],
unmatchedCopies: new Map(),
touched: new Set(),
matches: [],
@@ -164,7 +173,7 @@ function twinRuntimeVersion(s: State, nodeModules: string, pkg: string): string
/** The installed package copy a file belongs to, with the requests that
* apply to it; null for files outside node_modules or of unpatched
* packages. */
function copyOf(s: State, path: string): { copy: CopyInfo; rel: string } | null {
function copyOf(s: State, path: string, touch = true): { copy: CopyInfo; rel: string } | null {
const marker = "/node_modules/";
const at = path.lastIndexOf(marker);
if (at < 0) return null;
@@ -178,33 +187,122 @@ function copyOf(s: State, path: string): { copy: CopyInfo; rel: string } | null
let copy = s.copies.get(root);
if (copy === undefined) {
copy = null;
const pkg = name.startsWith("@types/") ? unmangleTypesName(name) : name;
const requests = s.byPackage.get(pkg);
if (requests !== undefined) {
const twin = name.startsWith("@types/");
const pkg = twin ? unmangleTypesName(name) : name;
const patches = s.byPackage.get(pkg);
if (patches !== undefined) {
// A twin is matched by the runtime package's version, never its own.
const version = name.startsWith("@types/")
const version = twin
? twinRuntimeVersion(s, path.slice(0, at + marker.length), pkg)
: versionAt(root);
const applicable = requests.filter(
(r) => version !== null && satisfies(version, r.entry.range),
const applicable = patches.entries.filter(
(e) => version !== null && satisfies(version, e.range),
);
// Later entries win on the same selector.
const bySelector = new Map<string, Request>();
for (const r of applicable) bySelector.set(r.selector.text, r);
const chosen = [...bySelector.values()];
if (chosen.length === 0) {
const entries = [...new Set(requests.map((r) => r.entry))];
s.unmatchedCopies.set(root, { pkg, version, entries });
if (applicable.length === 0) {
s.unmatchedCopies.set(root, { pkg, version, entries: patches.entries });
} else {
// Later entries win on the same selector, file or entry.
const bySelector = new Map<string, Request>();
for (const r of patches.requests)
if (applicable.includes(r.entry)) bySelector.set(r.selector.text, r);
const files = new Map<string, string>();
let types: string | null = null;
const prefix = `${name}/`;
for (const e of applicable) {
for (const [target, local] of e.files) {
// `@types/<name>/<path>` names the twin's file; plain paths the
// package's own.
const forTwin = target.startsWith("@types/");
if (forTwin !== twin) continue;
const rel = forTwin
? target.startsWith(prefix)
? target.slice(prefix.length)
: null
: target;
if (rel === null) continue;
if (!trackedFileExists(`${root}/${rel}`)) {
s.missingFiles.push({ entry: e, root, rel });
continue;
}
files.set(rel, local);
}
if (e.types !== null && !twin) types = e.types;
}
copy = {
pkg,
version,
root,
requests: [...bySelector.values()],
files,
entries: types === null ? new Map() : declarationEntries(root, types),
};
}
copy = chosen.length === 0 ? null : { pkg, version, root, requests: chosen };
}
s.copies.set(root, copy);
}
if (copy === null) return null;
for (const r of copy.requests) s.touched.add(r);
if (touch) for (const r of copy.requests) s.touched.add(r);
return { copy, rel };
}
/** The declaration entry paths of a package a `types` patch stands for:
* its `types`/`typings` file and every declaration TypeScript probes
* beside a JavaScript entry (`main`, the `exports` targets of "."). */
function declarationEntries(root: string, local: string): Map<string, string> {
const out = new Map<string, string>();
let pkg: Record<string, unknown> = {};
try {
pkg = JSON.parse(trackedReadFile(`${root}/package.json`) ?? "{}") as Record<string, unknown>;
} catch {
pkg = {};
}
const add = (target: unknown): void => {
if (typeof target !== "string") return;
const rel = target.replace(/^\.\//, "");
if (/\.d\.[cm]?ts$/.test(rel)) out.set(rel, local);
else if (/\.[cm]?js$/.test(rel)) out.set(rel.replace(/\.([cm]?)js$/, ".d.$1ts"), local);
// TypeScript probes x.tsx and then x.d.ts for x.jsx.
else if (rel.endsWith(".jsx")) out.set(`${rel.slice(0, -".jsx".length)}.d.ts`, local);
else if (!/\.\w+$/.test(rel)) out.set(`${rel}.d.ts`, local);
};
add(pkg["types"]);
add(pkg["typings"]);
add(typeof pkg["main"] === "string" ? pkg["main"] : "index.js");
const exportsField = pkg["exports"];
const dot =
exportsField !== null && typeof exportsField === "object" && "." in exportsField
? (exportsField as Record<string, unknown>)["."]
: exportsField;
const visit = (v: unknown): void => {
if (typeof v === "string") add(v);
else if (v !== null && typeof v === "object") for (const x of Object.values(v)) visit(x);
};
// Subpath exports ("./x") are not the entry.
if (dot !== null && typeof dot === "object" && Object.keys(dot).some((k) => k.startsWith(".")))
return out;
visit(dot);
return out;
}
/** True for a declaration entry a `types` patch supplies that does not
* exist on disk (a package without declarations gains one). */
export function typePatchFileExists(fileName: string): boolean {
const s = state;
if (s === null) return false;
const path = fileName.split("\\").join("/");
if (!/\.d\.[cm]?ts$/.test(path)) return false;
const located = copyOf(s, path, false);
return located !== null && located.copy.entries.has(located.rel);
}
/** True when a `types` patch gives the package at `pkgDir` declarations. */
export function typePatchSuppliesTypes(pkgDir: string): boolean {
const s = state;
if (s === null) return false;
const located = copyOf(s, `${pkgDir.split("\\").join("/")}/package.json`, false);
return located !== null && located.copy.entries.size > 0;
}
/** Names a request's root can appear under in a file's text. */
function rootWords(sel: Selector): string[] {
if (sel.root === "export=") return ["exports", "export"];
@@ -224,7 +322,12 @@ export function typePatchedText(fileName: string): string | undefined {
let result: string | null = null;
const located = copyOf(s, path);
if (located !== null) {
const text = trackedReadFile(path);
// A replaced file or a supplied entry reads as the local file; the
// declaration patches then apply to it like to the package's own.
const local = located.copy.files.get(located.rel) ?? located.copy.entries.get(located.rel);
const replaced = local === undefined ? null : trackedReadFile(local);
const text = replaced ?? trackedReadFile(path);
if (replaced !== null) result = replaced;
const requests = located.copy.requests.filter(
(r) =>
(r.selector.file === null || r.selector.file === located.rel) &&
@@ -333,6 +436,16 @@ export function typePatchDiagnostics(): {
);
}
}
for (const missing of s.missingFiles) {
out.push(
typePatchDiag(
"SC8031",
`type patch '${missing.entry.key}': ${missing.rel} is not a file of ${missing.entry.pkg} at ${relativeToManifest(s, missing.root)}`,
"name a declaration file the installed package ships; the package may have moved it",
loc(s, missing.entry.offset),
),
);
}
for (const r of s.touched) {
const mine = s.matches.filter((m) => m.id === r.id);
// A selector that matched somewhere (the .d.ts) is not a problem where
+67 -2
View File
@@ -214,7 +214,7 @@ test("typePatches: malformed keys, ranges, selectors and types are SC8004", () =
"A.c": "string\nnumber",
"A.d": "| undefined",
},
files: {},
patches: {},
},
"other@^1.0.0": [],
},
@@ -225,7 +225,7 @@ test("typePatches: malformed keys, ranges, selectors and types are SC8004", () =
`package.json 'scriptc.typePatches.ms@^banana' has the version range "^banana", which is not a semver range`,
"package.json 'scriptc.typePatches.@types/node@*' patches @types/node, whose declarations ship with scriptc or TypeScript",
"package.json 'scriptc.typePatches.typescript@5' patches typescript, whose declarations ship with scriptc or TypeScript",
"package.json 'scriptc.typePatches.lib@^1.0.0.files' is not a known type patch setting",
"package.json 'scriptc.typePatches.lib@^1.0.0.patches' is not a known type patch setting",
"package.json 'scriptc.typePatches.lib@^1.0.0.declarations.1bad' is not a selector: it must start with a declared name, 'export=' or 'default'",
"package.json 'scriptc.typePatches.lib@^1.0.0.declarations.A.b' must map to a type string, not 3",
"package.json 'scriptc.typePatches.lib@^1.0.0.declarations.A.c' has a type that must be one line",
@@ -241,3 +241,68 @@ test("typePatches: malformed keys, ranges, selectors and types are SC8004", () =
});
expect(partial.typePatches[0]!.declarations.map((d) => d.selector)).toEqual(["A.d"]);
});
test("typePatches: files and types name local declaration files", () => {
const root = mkdtempSync(join(tmpdir(), "scriptc-project-config-"));
try {
mkdirSync(join(root, "patches"));
writeFileSync(join(root, "patches", "entry.d.ts"), "export {};\n");
const text = JSON.stringify({
scriptc: {
typePatches: {
"lib@^1.0.0": {
files: {
"dist/entry.d.ts": "./patches/entry.d.ts",
"@types/lib/index.d.ts": "patches/entry.d.ts",
"dist/index.js": "./patches/entry.d.ts",
"../outside.d.ts": "./patches/entry.d.ts",
"dist/other.d.ts": "./patches/missing.d.ts",
},
types: "./patches/entry.d.ts",
},
"other@^1.0.0": { types: "./patches/entry.ts" },
},
},
});
const c = parseProjectConfig(join(root, "package.json"), text);
expect(c.diagnostics.map((d) => d.message)).toEqual([
"package.json 'scriptc.typePatches.lib@^1.0.0.files.dist/index.js' must name a declaration file inside the package",
"package.json 'scriptc.typePatches.lib@^1.0.0.files.../outside.d.ts' must name a declaration file inside the package",
`package.json 'scriptc.typePatches.lib@^1.0.0.files.dist/other.d.ts' names "./patches/missing.d.ts", which does not exist`,
`package.json 'scriptc.typePatches.other@^1.0.0.types' names "./patches/entry.ts", which is not a declaration file`,
]);
const entry = c.typePatches.find((e) => e.pkg === "lib")!;
expect([...entry.files]).toEqual([
["dist/entry.d.ts", join(root, "patches/entry.d.ts")],
["@types/lib/index.d.ts", join(root, "patches/entry.d.ts")],
]);
expect(entry.types).toBe(join(root, "patches/entry.d.ts"));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("readProjectConfig checks local type patch files again for an unchanged package.json", () => {
const root = mkdtempSync(join(tmpdir(), "scriptc-project-config-"));
try {
writeFileSync(
join(root, "package.json"),
JSON.stringify({ scriptc: { typePatches: { "lib@^1.0.0": { types: "./lib.d.ts" } } } }),
);
const entry = join(root, "main.ts");
const missing = readProjectConfig(entry);
expect(missing.diagnostics.map((d) => d.message)).toEqual([
`package.json 'scriptc.typePatches.lib@^1.0.0.types' names "./lib.d.ts", which does not exist`,
]);
expect(readProjectConfig(entry)).toBe(missing);
writeFileSync(join(root, "lib.d.ts"), "export {};\n");
const created = readProjectConfig(entry);
expect(created.diagnostics).toEqual([]);
expect(created.typePatches[0]!.types).toBe(join(root, "lib.d.ts"));
expect(readProjectConfig(entry)).toBe(created);
rmSync(join(root, "lib.d.ts"));
expect(readProjectConfig(entry).diagnostics.map((d) => d.code)).toEqual(["SC8004"]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+79 -3
View File
@@ -27,7 +27,7 @@
* to their own readers. */
import { dirname, join, resolve } from "node:path";
import { projectConfigDiag, type ScrDiagnostic } from "./diagnostics/diagnostic.js";
import { trackedReadFile } from "./frontend/input-tracker.js";
import { trackedFileExists, trackedReadFile } from "./frontend/input-tracker.js";
import { parseSelector, parseTypeText } from "./frontend/type-patches/selector.js";
import { isValidRange } from "./frontend/type-patches/semver.js";
@@ -79,6 +79,13 @@ export interface TypePatchEntry {
pkg: string;
range: string;
declarations: TypePatchDeclaration[];
/** `files`: declaration files of the package (or of its `@types/` twin,
* written `@types/<name>/<path>`) replaced by local files, by
* package-relative path. The local paths are absolute. */
files: Map<string, string>;
/** `types`: a local declaration file that becomes the package's
* declaration entry (absolute), or null. */
types: string | null;
/** Offset of the key in the manifest. */
offset: number;
}
@@ -94,6 +101,10 @@ export interface ProjectConfig {
wasm: string[];
/** `scriptc.typePatches`, in manifest order. */
typePatches: TypePatchEntry[];
/** The local files the configuration checked for existence, with the
* answer. A configuration read again from an unchanged package.json is
* reused only while every answer still holds. */
localFiles: { path: string; exists: boolean }[];
/** SC8004 for every unknown key or value. */
diagnostics: ScrDiagnostic[];
}
@@ -146,6 +157,7 @@ export function emptyProjectConfig(): ProjectConfig {
nodeIslands: defaultNodeIslands(),
wasm: [],
typePatches: [],
localFiles: [],
diagnostics: [],
};
}
@@ -341,7 +353,8 @@ export function parseProjectConfig(manifest: string, text: string): ProjectConfi
return config;
}
const TYPE_PATCH_KEYS = ["declarations", "note"];
const TYPE_PATCH_KEYS = ["declarations", "files", "types", "note"];
const DECLARATION_FILE = /\.d\.[cm]?ts$/;
/** Packages whose declarations ship with scriptc or TypeScript: a wrong
* one is a bug to report, not a project-local patch. */
const UNPATCHABLE = /^(?:@types\/node|typescript|@typescript\/.*)$/;
@@ -404,8 +417,66 @@ function readTypePatches(
pkg,
range,
declarations: [],
files: new Map(),
types: null,
offset: locate.offsetOf(["scriptc", "typePatches", key]),
};
const base = dirname(config.manifest!);
const localFile = (path: string[], v: unknown): string | null => {
if (typeof v !== "string" || v === "") {
problem(
path,
`must be the path of a local declaration file, not ${shown(v)}`,
`write "./type-patches/${pkg}.d.ts"`,
);
return null;
}
const abs = resolve(base, v);
if (!DECLARATION_FILE.test(abs)) {
problem(
path,
`names "${v}", which is not a declaration file`,
"use a .d.ts, .d.mts or .d.cts file",
);
return null;
}
const exists = trackedFileExists(abs);
config.localFiles.push({ path: abs, exists });
if (!exists) {
problem(
path,
`names "${v}", which does not exist`,
"give the path relative to this package.json",
);
return null;
}
return abs;
};
const files = value["files"];
if (files !== undefined && !isRecord(files)) {
problem(
["typePatches", key, "files"],
`must be an object mapping declaration files of the package to local files, not ${shown(files)}`,
`write "files": { "dist/index.d.ts": "./type-patches/${pkg}.d.ts" }`,
);
} else if (files !== undefined) {
for (const target of Object.keys(files)) {
const path = ["typePatches", key, "files", target];
const rel = target.replace(/^\.\//, "");
if (!DECLARATION_FILE.test(rel) || rel.startsWith("/") || rel.split("/").includes("..")) {
problem(
path,
"must name a declaration file inside the package",
`write a package-relative .d.ts path such as "dist/index.d.ts", or "@types/<name>/index.d.ts" for its @types package`,
);
continue;
}
const abs = localFile(path, files[target]);
if (abs !== null) entry.files.set(rel, abs);
}
}
if (value["types"] !== undefined)
entry.types = localFile(["typePatches", key, "types"], value["types"]);
let valid = true;
for (const k of Object.keys(value)) {
if (!TYPE_PATCH_KEYS.includes(k)) {
@@ -482,7 +553,12 @@ export function readProjectConfig(entryPath: string): ProjectConfig {
const manifest = join(dir, "package.json");
const text = trackedReadFile(manifest);
if (text !== null) {
if (cachedConfig !== null && cachedManifest === manifest && cachedText === text)
if (
cachedConfig !== null &&
cachedManifest === manifest &&
cachedText === text &&
cachedConfig.localFiles.every((file) => trackedFileExists(file.path) === file.exists)
)
return cachedConfig;
// A manifest without a `scriptc` key (none spelled out, none behind a
// \u escape) has the empty configuration; builds of such projects
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8360-record-prototype-method-calls.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8361-record-function-table-inherited.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8362-record-inherited-order.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/evolving-let-shorthand-reads.js"
],
"diags": []
}
@@ -0,0 +1,25 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/type-patch-files/main.ts"
],
"diags": [
{
"code": "SC0001",
"message": "Property 'toUpperCase' does not exist on type 'number'.",
"loc": {
"file": "<repo>/tests/fixtures/npm/cases/type-patch-files/main.ts",
"start": 346,
"end": 357
}
},
{
"code": "SC0001",
"message": "This comparison appears to be unintentional because the types 'number' and 'string' have no overlap.",
"loc": {
"file": "<repo>/tests/fixtures/npm/cases/type-patch-files/main.ts",
"start": 429,
"end": 447
}
}
]
}
@@ -0,0 +1,16 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/type-patch-types-entry/main.ts"
],
"diags": [
{
"code": "SC0001",
"message": "Could not find a declaration file for module 'pointjs'. '<repo>/tests/fixtures/npm/cases/type-patch-types-entry/node_modules/pointjs/lib/point.js' implicitly has an 'any' type.\n Try `npm i --save-dev @types/pointjs` if it exists or add a new declaration (.d.ts) file containing `declare module 'pointjs';`",
"loc": {
"file": "<repo>/tests/fixtures/npm/cases/type-patch-types-entry/main.ts",
"start": 246,
"end": 255
}
}
]
}
@@ -12,7 +12,9 @@ import {
typePatchDiagnostics,
typePatchedSpans,
typePatchedText,
typePatchFileExists,
typePatchMatches,
typePatchSuppliesTypes,
} from "../src/frontend/type-patches/layer.js";
const fixtures = join(import.meta.dirname, "../../../tests/fixtures");
@@ -217,3 +219,82 @@ test("an export before its hoisted function is patched, and string text that loo
rmSync(root, { recursive: true, force: true });
}
});
test("files replaces a declaration file and declarations still apply on top", () => {
const root = join(fixtures, "npm/cases/type-patch-files");
configureTypePatches(join(root, "main.ts"), parse);
const entry = typePatchedText(join(root, "node_modules/eventlog/dist/entry.d.ts"));
expect(entry).toBe(readFileSync(join(root, "type-patches/entry.d.ts"), "utf8"));
expect(typePatchedText(join(root, "node_modules/eventlog/dist/index.d.ts"))).toContain(
"{ count: number; last: string | undefined }",
);
expect(typePatchDiagnostics()!.diagnostics).toEqual([]);
});
test("types gives a package without declarations an entry beside its main", () => {
const root = join(fixtures, "npm/cases/type-patch-types-entry");
configureTypePatches(join(root, "main.ts"), parse);
const pkg = join(root, "node_modules/pointjs");
expect(typePatchFileExists(join(pkg, "lib/point.d.ts"))).toBe(true);
expect(typePatchFileExists(join(pkg, "index.d.ts"))).toBe(false);
expect(typePatchFileExists(join(pkg, "lib/other.d.ts"))).toBe(false);
expect(typePatchSuppliesTypes(pkg)).toBe(true);
expect(typePatchedText(join(pkg, "lib/point.d.ts"))).toBe(
readFileSync(join(root, "type-patches/pointjs.d.ts"), "utf8"),
);
// Without patches nothing exists that is not on disk.
configureTypePatches(join(tmpdir(), "none", "main.ts"), parse);
expect(typePatchFileExists(join(pkg, "lib/point.d.ts"))).toBe(false);
expect(typePatchSuppliesTypes(pkg)).toBe(false);
});
test("types gives a package whose main is a .jsx file an entry beside it", () => {
const root = mkdtempSync(join(tmpdir(), "scriptc-type-patches-"));
try {
const pkg = join(root, "node_modules/viewjs");
mkdirSync(join(pkg, "lib"), { recursive: true });
writeFileSync(
join(pkg, "package.json"),
JSON.stringify({ name: "viewjs", version: "1.0.0", main: "lib/view.jsx" }),
);
writeFileSync(join(pkg, "lib/view.jsx"), "export const view = 1;\n");
writeFileSync(join(root, "viewjs.d.ts"), "export declare const view: number;\n");
writeFileSync(
join(root, "package.json"),
JSON.stringify({ scriptc: { typePatches: { "viewjs@^1.0.0": { types: "./viewjs.d.ts" } } } }),
);
configureTypePatches(join(root, "main.ts"), parse);
expect(typePatchFileExists(join(pkg, "lib/view.d.ts"))).toBe(true);
expect(typePatchSuppliesTypes(pkg)).toBe(true);
expect(typePatchedText(join(pkg, "lib/view.d.ts"))).toBe(
"export declare const view: number;\n",
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a files target the installed package does not ship warns", () => {
const root = mkdtempSync(join(tmpdir(), "scriptc-type-patches-"));
try {
const twin = join(fixtures, "npm/cases/type-patch-files");
writeFileSync(join(root, "local.d.ts"), "export type Entry = {};\n");
writeFileSync(
join(root, "package.json"),
JSON.stringify({
scriptc: {
typePatches: { "eventlog@^2.0.0": { files: { "dist/gone.d.ts": "./local.d.ts" } } },
},
}),
);
configureTypePatches(join(root, "main.ts"), parse);
expect(typePatchedText(join(twin, "node_modules/eventlog/dist/index.d.ts"))).toBeUndefined();
const diags = typePatchDiagnostics()!.diagnostics;
expect(diags.map((d) => d.code)).toEqual(["SC8031"]);
expect(diags[0]!.message).toMatch(
/^type patch 'eventlog@\^2\.0\.0': dist\/gone\.d\.ts is not a file of eventlog at .*type-patch-files\/node_modules\/eventlog$/,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,56 @@
// Object.prototype's methods called by name on typed records: the built-in
// with the record as its receiver, or an own entry that shadows it.
interface Point {
x: number;
y: number;
}
const counts: Record<string, number> = { apple: 2, pear: 0 };
const point: Point = { x: 1, y: 2 };
const handlers: Record<string, (n: number) => number> = { double: (n) => n * 2 };
const own: Record<string, () => string> = {
toString: () => "own toString",
valueOf: () => "own valueOf",
};
function line(...parts: unknown[]): void {
console.log(parts.map(String).join(" | "));
}
line("toString", counts.toString(), point.toString(), handlers.toString(), String(counts));
line("toLocaleString", counts.toLocaleString(), point.toLocaleString());
line("valueOf", counts.valueOf() === counts, point.valueOf() === point);
line(
"hasOwnProperty",
counts.hasOwnProperty("apple"),
counts.hasOwnProperty("pear"),
counts.hasOwnProperty("plum"),
counts.hasOwnProperty("toString"),
point.hasOwnProperty("x"),
point.hasOwnProperty("z"),
handlers.hasOwnProperty("double"),
handlers.hasOwnProperty("valueOf"),
);
line("propertyIsEnumerable", counts.propertyIsEnumerable("apple"), point.propertyIsEnumerable("toString"));
line("isPrototypeOf", counts.isPrototypeOf(point), point.isPrototypeOf(counts));
// Own entries shadow the built-ins; toLocaleString calls the own toString.
line("own", own.toString(), own.toLocaleString(), own.valueOf(), own.hasOwnProperty("toString"));
delete own["toString"];
line("own deleted", own.toString(), own.toLocaleString());
own.toString = () => "own again";
line("own again", own.toString(), `${String(own.toString())}`);
// Keys added at run time are own keys too.
const later: Record<string, number> = {};
for (const word of "toString valueOf plain toString".split(" ")) {
later[word] = (later.hasOwnProperty(word) ? later[word]! : 0) + 1;
}
line("later", JSON.stringify(later), later.hasOwnProperty("toString"), later.hasOwnProperty("constructor"));
// A null-prototype record has no inherited methods.
const groups = Object.groupBy([1, 2, 3], (n): string => (n % 2 ? "odd" : "even"));
try {
line("groups", groups.toString());
} catch (e) {
line("groups threw", (e as Error).name + ": " + (e as Error).message);
}
@@ -0,0 +1,40 @@
// Records of functions used as dispatch tables meet Object.prototype's method
// names: a keyed read answers the inherited method, bound to the record when
// the read is called at once and a plain function value otherwise.
type Command = (arg: string) => unknown;
const commands: Record<string, Command> = {
echo: (arg) => arg,
shout: (arg) => arg.toUpperCase(),
};
const inputs = process.argv.length > 99 ? [] : ["echo", "toString", "valueOf", "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable", "toLocaleString", "shout", "nope"];
function show(v: unknown): string {
return v === commands ? "<commands>" : typeof v + ":" + String(v);
}
for (const name of inputs) {
const handler = commands[name];
if (!handler) {
console.log(name, "unknown handler");
continue;
}
console.log(name, typeof handler, "direct", show(commands[name](name === "hasOwnProperty" ? "echo" : "x")));
console.log(name, "optional", show(commands[name]?.("shout")));
try {
console.log(name, "detached", show(handler("echo")));
} catch (e) {
console.log(name, "detached threw", (e as Error).name + ": " + (e as Error).message);
}
}
console.log(typeof commands["toString"], typeof commands["valueOf"], commands["toString"] === undefined, commands["toString"] === commands["toString"]);
console.log(String(commands["toString"]("x")), commands["hasOwnProperty"]("shout"), commands["hasOwnProperty"]("nope"));
const literal: Record<string, Command> = {};
console.log(Object.keys(literal).length, literal["isPrototypeOf"]?.("x"), typeof literal.toString);
// A word count whose words are not Object.prototype names keeps `?? 0`.
const freq: Record<string, number> = {};
for (const w of "a rose is a rose is a rose".split(" ")) freq[w] = (freq[w] ?? 0) + 1;
console.log(JSON.stringify(freq), freq["tulip"] ?? 0);
// Read by name without a call: a plain function value.
const counts: Record<string, number> = { a: 1 };
const named = counts.toString;
console.log(typeof counts.toString, typeof commands.valueOf, String(named()));
@@ -0,0 +1,40 @@
// Object.prototype's methods on typed records keep JavaScript's evaluation
// order and key conversions.
// A function-table key that reassigns the receiver reads the table
// evaluated before the key.
let table: Record<string, () => string> = { a: () => "first" };
const other: Record<string, () => string> = { a: () => "second" };
function key(): string {
table = other;
return "a";
}
console.log(String(table[key()]()));
// hasOwnProperty and propertyIsEnumerable with a symbol key on a record of
// functions test the symbol key.
const fns: Record<string, (n: number) => number> = { double: (n) => n * 2 };
const sym = Symbol("s");
console.log(fns.hasOwnProperty(sym as unknown as string), fns.propertyIsEnumerable(sym as unknown as string));
// A call by name reads the method before its arguments run: an argument
// that adds, replaces or deletes the own method does not change the callee.
type Has = Record<string, (key: string) => boolean>;
function add(r: Has): string {
r.hasOwnProperty = () => true;
return "zzz";
}
function swap(r: Has): string {
r.hasOwnProperty = (key) => key === "b";
return "a";
}
function drop(r: Has): string {
delete r["hasOwnProperty"];
return "zzz";
}
const added: Has = {};
console.log(added.hasOwnProperty(add(added)), added.hasOwnProperty("zzz"));
const swapped: Has = { hasOwnProperty: (key) => key === "a" };
console.log(swapped.hasOwnProperty(swap(swapped)), swapped.hasOwnProperty("a"));
const removed: Has = { hasOwnProperty: () => true };
console.log(removed.hasOwnProperty(drop(removed)), removed.hasOwnProperty("zzz"));
@@ -0,0 +1,41 @@
// An initializer-less JavaScript `let` read through a shorthand property
// (`{ width }`) still reads undefined on the paths that never assign it, as
// npm packages do when they build an options or size object.
function size(tty) {
let width;
if (tty) width = 80;
return { width };
}
/**
* @param {boolean} tty
* @returns {{ width: number | undefined }}
*/
function documented(tty) {
/** @type {number | undefined} */
let width;
if (tty) width = 80;
return { width };
}
function captured(tty) {
let width;
const read = () => ({ width });
const before = read().width;
if (tty) width = 80;
return [before, read().width];
}
function nested(flags) {
let columns;
let rows;
if (flags.columns) columns = 120;
if (flags.rows) rows = 40;
return { size: { columns, rows } };
}
console.log(size(true).width, size(false).width);
console.log(documented(true).width, documented(false).width);
console.log(JSON.stringify(size(false)), "width" in size(false));
console.log(captured(true), captured(false));
console.log(nested({ columns: true }), nested({ rows: true }), nested({}));
+10
View File
@@ -0,0 +1,10 @@
// A package declaration file replaced by a local one (scriptc.typePatches
// files), plus a declaration patch on another file of the same package.
import { parseLog, summarize } from "eventlog";
const entries = parseLog("10 info ada started\n12 warn - disk low\n15 error lin crashed hard\n");
for (const e of entries) console.log(e.at, e.level.toUpperCase(), e.user ?? "(system)", e.message);
console.log(entries.filter((e) => e.level !== "info").length, JSON.stringify(entries[1]));
const all = summarize(entries);
const none = summarize([]);
console.log(all.count, all.last ?? "-", none.count, none.last ?? "-");
@@ -0,0 +1,8 @@
// Out of date: the JavaScript reports levels as strings and has no
// `user` for system entries.
export type Entry = {
at: number;
level: number;
user: string;
message: string;
};
@@ -0,0 +1,4 @@
export type { Entry } from "./entry";
import type { Entry } from "./entry";
export declare function parseLog(text: string): Entry[];
export declare function summarize(entries: Entry[]): { count: number; last: string };
@@ -0,0 +1,17 @@
"use strict";
function parseLog(text) {
const rows = String(text)
.split("\n")
.filter(Boolean)
.map((line) => {
const [at, level, user, ...rest] = line.split(" ");
return { at: Number(at), level: level, user: user === "-" ? undefined : user, message: rest.join(" ") };
});
// Entries travel as JSON (the log format's own wire form).
return JSON.parse(JSON.stringify(rows));
}
function summarize(entries) {
return { count: entries.length, last: entries.length === 0 ? undefined : entries[entries.length - 1].message };
}
exports.parseLog = parseLog;
exports.summarize = summarize;
@@ -0,0 +1 @@
{ "name": "eventlog", "version": "2.3.1", "main": "dist/index.js", "types": "dist/index.d.ts" }
+13
View File
@@ -0,0 +1,13 @@
{
"name": "type-patch-files-case",
"private": true,
"type": "module",
"scriptc": {
"typePatches": {
"eventlog@^2.0.0": {
"files": { "dist/entry.d.ts": "./type-patches/entry.d.ts" },
"declarations": { "summarize().last": "| undefined" }
}
}
}
}
@@ -0,0 +1,7 @@
// The project's replacement for eventlog's dist/entry.d.ts.
export type Entry = {
at: number;
level: "info" | "warn" | "error";
user?: string;
message: string;
};
@@ -0,0 +1,9 @@
// A package without declarations, typed by a local declaration file
// (scriptc.typePatches types): user code is checked against it, and the
// values cross the declaration boundary as native records.
import { centroid, point, type Point } from "pointjs";
const points: Point[] = [point(0, 0), point(4, 0), point(4, 3)];
const c = centroid(points);
console.log(c === null ? "none" : `${c.x.toFixed(2)},${c.y.toFixed(2)}`);
console.log(centroid([]) === null, JSON.stringify(points.map((p) => p.x + p.y)));
@@ -0,0 +1,17 @@
"use strict";
// Plain JavaScript without declarations.
function point(x, y) {
return { x: x, y: y };
}
function centroid(points) {
if (points.length === 0) return null;
var sx = 0;
var sy = 0;
for (var i = 0; i < points.length; i++) {
sx += points[i].x;
sy += points[i].y;
}
return point(sx / points.length, sy / points.length);
}
exports.point = point;
exports.centroid = centroid;
@@ -0,0 +1 @@
{ "name": "pointjs", "version": "1.4.0", "main": "lib/point.js" }
@@ -0,0 +1,10 @@
{
"name": "type-patch-types-entry-case",
"private": true,
"type": "module",
"scriptc": {
"typePatches": {
"pointjs@1.x": { "types": "./type-patches/pointjs.d.ts" }
}
}
}
@@ -0,0 +1,4 @@
// The project's declarations for pointjs, which ships none.
export type Point = { x: number; y: number };
export declare function point(x: number, y: number): Point;
export declare function centroid(points: Point[]): Point | null;
+12
View File
@@ -0,0 +1,12 @@
// A dot read of a prototype-named member whose own entry has another
// signature than the member's (tests/harness/record-inherited-member.test.ts).
const plain: Record<string, (x: number) => number> = { double: (x) => x * 2 };
const builtin = plain.toString;
console.log(typeof builtin);
const numeric: Record<string, (x: number) => number> = { toString: (x: number) => x + 1 };
try {
const own = numeric.toString;
console.log(typeof own);
} catch (e) {
console.log(e instanceof TypeError, (e as Error).message);
}
@@ -0,0 +1 @@
{ "plain": 3200, "san": 3900 }
@@ -0,0 +1 @@
{ "plain": 3200, "san": 3900 }
@@ -0,0 +1 @@
{ "plain": 2000, "san": 2000 }
@@ -0,0 +1 @@
{ "plain": 3700, "san": 2800 }
@@ -0,0 +1,32 @@
/* Object.prototype's methods on typed records (frontend/lowering/
* record-inherited.ts): a dot read of a prototype-named member answers the
* built-in when the record has no own entry, and throws the checked
* conversion's TypeError when its own entry is a function of another
* signature than the member's, which the program's types claimed. Node reads
* the own function there, so the program cannot be a corpus case. */
import { execFile } from "node:child_process";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, test } from "vitest";
import { compile } from "@scriptc/compiler";
import { OTHER_ARM_FAILURE_MESSAGE } from "../../packages/compiler/src/frontend/lowering/coercions/unions.js";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const entry = join(repoRoot, "tests/fixtures/record-inherited/member-signature.ts");
const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/record-inherited-member");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
test(
"a prototype-named member read with an own function of another signature throws",
{ timeout: 300_000 },
async () => {
mkdirSync(outDir, { recursive: true });
const result = await compile(entry, { outPath: join(outDir, "main"), outDir, sanitize });
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const { stdout } = await execFileAsync(result.binaryPath, [], { encoding: "utf8" });
expect(stdout).toBe(`function\ntrue ${OTHER_ARM_FAILURE_MESSAGE}\n`);
},
);
+46 -2
View File
@@ -4,8 +4,10 @@
* value (the shape of commander's HelpContext.helpWidth), and ms's @return
* tags, compile natively and byte-match Node once patched; without the
* patches the same declarations throw where Node runs. Patches that do not
* apply as written warn (SC8030-SC8033). The `.d.ts` and `@types` cases
* run in npm.test.ts (type-patch-dts, type-patch-types-twin), and
* apply as written warn (SC8030-SC8033). The `.d.ts`, `@types`, replaced
* file and supplied entry cases run in npm.test.ts (type-patch-dts,
* type-patch-types-twin, type-patch-files, type-patch-types-entry); here
* their unpatched copies show the patches are what makes them compile. And
* commander-calc carries the HelpContext.helpWidth patch. */
import { execFile } from "node:child_process";
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -121,6 +123,48 @@ describe("type patches", () => {
},
);
test(
"without its patch, code using the out-of-date declaration file does not type-check",
{ timeout: 600_000 },
async () => {
const copy = unpatched(join(repoRoot, "tests/fixtures/npm/cases/type-patch-files"));
try {
mkdirSync(outDir, { recursive: true });
const result = await compile(join(copy, "main.ts"), {
outPath: join(outDir, "files-unpatched"),
outDir,
sanitize,
});
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.diagnostics.map((d) => d.message)).toContain(
"Property 'toUpperCase' does not exist on type 'number'.",
);
} finally {
rmSync(copy, { recursive: true, force: true });
}
},
);
test(
"without its patch, a package without declarations is not compiled for a typed import",
{ timeout: 600_000 },
async () => {
const copy = unpatched(join(repoRoot, "tests/fixtures/npm/cases/type-patch-types-entry"));
try {
mkdirSync(outDir, { recursive: true });
const result = await compile(join(copy, "main.ts"), {
outPath: join(outDir, "types-entry-unpatched"),
outDir,
sanitize,
});
expect(result.ok).toBe(false);
} finally {
rmSync(copy, { recursive: true, force: true });
}
},
);
test(
"patches that do not apply as written warn and the build goes on",
{ timeout: 600_000 },