From a78b08c3c3f05673c2ecab862d0a179df4f378e3 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 7 Nov 2026 02:06:22 -0800 Subject: [PATCH 01/11] Read the value a called function assigned where TypeScript still says undefined TypeScript types a read by the last assignment it sees in the read's own function, and a call does not reset that. After `var re; compile(); scan(re)` or `let n: number | undefined = undefined; set(); s.slice(n)` it types the read `undefined`. Every lowering of an optional or defaulted argument, and the dynamic method call (how a constructor function's method is called), took that as "the argument is absent" and dropped the read. mustache@4.2.0 rendered the empty string for every template; typed programs sliced from 0. The stale-narrowing analysis already decides when a call may have undone a narrowing, and restored the declared type in the positions that can take it, leaving the rest to a checked extraction. A narrowing to nothing but `undefined` or `null` has no payload to check, so it now reads as declared in every position, whatever the declared type is (`any` for a binding typed by flow alone, as the checker answers for a read from a nested function). Two places used the checker's type of what surrounds such a read: - an argument whose value can be the read (`s.slice(c ? n : undefined)`) is no longer folded to "absent"; - a function whose return type the checker inferred as `undefined` from the read returned nothing. Its callers' types come from the same inference, so it is refused by name (SC1090, "declare the function's return type"); with the declaration the return carries the stored value. Co-Authored-By: Claude Code --- docs/content/limitations/type-checking.mdx | 4 + .../src/frontend/lowering/lower-functions.ts | 3 + .../compiler/src/frontend/lowering/lowerer.ts | 17 + .../lowering/narrowing-stability.test.ts | 75 ++ .../frontend/lowering/narrowing-stability.ts | 71 +- .../frontend/lowering/optional-arguments.ts | 4 + .../9641-callee-assigned-reads.mjs.json | 6 + ...42-callee-assigned-reads-commonjs.cjs.json | 6 + .../9643-callee-assigned-reads-typed.ts.json | 6 + .../9645-mustache-templates/main.mjs.json | 6 + .../stale-undefined-inferred-return.ts.json | 6 + tests/corpus/9641-callee-assigned-reads.mjs | 209 +++++ .../9642-callee-assigned-reads-commonjs.cjs | 82 ++ .../9643-callee-assigned-reads-typed.ts | 159 ++++ tests/corpus/9645-mustache-templates/main.mjs | 55 ++ .../node_modules/@types/mustache/LICENSE | 21 + .../node_modules/@types/mustache/index.d.mts | 22 + .../node_modules/@types/mustache/index.d.ts | 421 ++++++++++ .../node_modules/@types/mustache/package.json | 45 + .../node_modules/mustache/LICENSE | 11 + .../node_modules/mustache/mustache.js | 772 ++++++++++++++++++ .../node_modules/mustache/mustache.mjs | 764 +++++++++++++++++ .../node_modules/mustache/package.json | 67 ++ .../stale-undefined-inferred-return.ts | 35 + .../stale-undefined-inferred-return.ts.txt | 35 + .../9641-callee-assigned-reads.mjs.json | 1 + ...42-callee-assigned-reads-commonjs.cjs.json | 1 + .../9643-callee-assigned-reads-typed.ts.json | 1 + .../9645-mustache-templates/main.mjs.json | 1 + .../island-conformance/known-failures.json | 6 + 30 files changed, 2910 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9641-callee-assigned-reads.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9642-callee-assigned-reads-commonjs.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9643-callee-assigned-reads-typed.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9645-mustache-templates/main.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stale-undefined-inferred-return.ts.json create mode 100644 tests/corpus/9641-callee-assigned-reads.mjs create mode 100644 tests/corpus/9642-callee-assigned-reads-commonjs.cjs create mode 100644 tests/corpus/9643-callee-assigned-reads-typed.ts create mode 100644 tests/corpus/9645-mustache-templates/main.mjs create mode 100644 tests/corpus/9645-mustache-templates/node_modules/@types/mustache/LICENSE create mode 100644 tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.mts create mode 100644 tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.ts create mode 100644 tests/corpus/9645-mustache-templates/node_modules/@types/mustache/package.json create mode 100644 tests/corpus/9645-mustache-templates/node_modules/mustache/LICENSE create mode 100644 tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.js create mode 100644 tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.mjs create mode 100644 tests/corpus/9645-mustache-templates/node_modules/mustache/package.json create mode 100644 tests/diagnostics/stale-undefined-inferred-return.ts create mode 100644 tests/harness/__snapshots__/stale-undefined-inferred-return.ts.txt create mode 100644 tests/harness/corpus-costs/9641-callee-assigned-reads.mjs.json create mode 100644 tests/harness/corpus-costs/9642-callee-assigned-reads-commonjs.cjs.json create mode 100644 tests/harness/corpus-costs/9643-callee-assigned-reads-typed.ts.json create mode 100644 tests/harness/corpus-costs/9645-mustache-templates/main.mjs.json diff --git a/docs/content/limitations/type-checking.mdx b/docs/content/limitations/type-checking.mdx index e3dc54aef23..8632882686e 100644 --- a/docs/content/limitations/type-checking.mdx +++ b/docs/content/limitations/type-checking.mdx @@ -4,3 +4,7 @@ Some declarations differ from TypeScript's standard library or `@types/node`: `J A program that passes its project's TypeScript check may still use unsupported operations. A second preflight using the project's declarations distinguishes those sites from ordinary type errors and reports scriptc diagnostics with rewrite hints. Declared APIs without a compiler implementation, such as unsupported console members, produce `SC2020`. + +TypeScript keeps the type of an assignment across a call, so after `x = undefined` a read of `x` is typed `undefined` even when a function called in between assigns `x` again. +scriptc reads the stored value there. +A function without a declared return type that returns such a read is inferred to return `undefined`; scriptc refuses that return with `SC1090`, and a declared return type compiles. diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index b39587483c5..573f1b826bd 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -575,6 +575,9 @@ export function lowerLambda( // statement branches instead of a forbidden void-valued ternary. let stripped: ts.Expression = bodyExpr; while (ts.isParenthesizedExpression(stripped)) stripped = stripped.expression; + // A body whose value the checker typed `undefined` from a + // narrowing a call may have undone would be dropped here. + lowerer.refuseDroppedStaleReturn(bodyExpr); if (ts.isVoidExpression(stripped) || ts.isConditionalExpression(stripped)) { body = [lowerer.lowerExprStatement(stripped)]; } else { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 69b57f2409a..c5f2739f463 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -16220,6 +16220,7 @@ export class Lowerer { private lowerReturnOf(node: ts.Expression, loc: SrcLoc): IrStmt { const expected = this.ctx.returnType; if (expected.kind === "void") { + this.refuseDroppedStaleReturn(node); let e = this.lowerExpr(node); const flattened = this.maybePromiseReturn(node, e, expected); if (flattened) return flattened; @@ -16264,6 +16265,22 @@ export class Lowerer { return { kind: "return", value: this.lowerReturnValue(node), loc }; } + /** A function returns nothing when the checker inferred its return type + * from a read it narrowed to `undefined`. A call may have stored a value + * there since (widenedType restored the read), and returning nothing + * would drop it; the caller's types were inferred from the same + * narrowing, so only a declared return type can carry the value. */ + refuseDroppedStaleReturn(node: ts.Expression): void { + const stale = this.narrowing.restoredNothingRead(node); + if (stale === null) return; + this.unsupported( + "SC1090", + node, + `returning '${ts.getTextOfNode(stale)}' from a function whose return type TypeScript infers as 'undefined' (it types the read by the last assignment in this function, and a call made since can have assigned it from another function)`, + "declare the function's return type, so the result is the value stored at the return", + ); + } + maybeNarrow(expr: IrExpr, node: ts.Node): IrExpr { return maybeNarrow(this, expr, node); } diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts index 577356fc6e0..d6fdd9e6c85 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts @@ -153,3 +153,78 @@ formatted({}, []); `); expect(thrownMessages(module, "formatted")).toEqual([]); }); + +/** Whether a function's body reads the module-level binding `name`. */ +function readsGlobal(module: IrModule, fn: string, name: string): boolean { + const found = module.functions.find((f) => f.name === fn || f.name.endsWith(`.${fn}`)); + if (!found) throw new Error(`no function ${fn}`); + let reads = false; + JSON.stringify(found.body, (_key, value: unknown) => { + const node = value as { kind?: string; localId?: string }; + if (node?.kind === "varRef" && node.localId?.endsWith(`.${name}`)) reads = true; + return value; + }); + return reads; +} + +test("a narrowing to undefined that a call may have undone reads the stored value", async () => { + // TypeScript types `start` as `undefined` at both slices. In `stale` the + // closure ran since, so the argument is the binding; in `current` nothing + // ran since the assignment, so the argument is absent. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale(): string { + start = undefined; + set(); + return "abcd".slice(start); +} +function current(): string { + start = undefined; + return "abcd".slice(start); +} +console.log(stale(), current()); +`); + expect(readsGlobal(module, "stale", "start")).toBe(true); + expect(readsGlobal(module, "current", "start")).toBe(false); +}); + +test("a binding typed by flow alone reads as declared once another function may have assigned it", async () => { + // `let text;` is `undefined` to the checker until this function assigns it. + const module = await lower(` +let text; +let other; +function assign(): void { + text = "t"; + other = "o"; +} +console.log("x".padStart(3, other)); +assign(); +console.log("x".padStart(3, text)); +`); + const init = module.functions.find((f) => f.name.startsWith("%init"))!.name; + expect(readsGlobal(module, init, "text")).toBe(true); + expect(readsGlobal(module, init, "other")).toBe(false); +}); + +test("a function inferred to return undefined from a stale narrowing is refused", async () => { + const source = (returnType: string): string => ` +let found: string | undefined = undefined; +function search(): void { + found = "hit"; +} +function result()${returnType} { + found = undefined; + search(); + return found; +} +console.log(result()); +`; + await expect(lower(source(""))).rejects.toThrow( + /SC1090: returning 'found' from a function whose return type TypeScript infers as 'undefined'/, + ); + const module = await lower(source(": string | undefined")); + expect(readsGlobal(module, "result", "found")).toBe(true); +}); diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index c4184d2359f..0866723fe30 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -74,13 +74,45 @@ export class NarrowingStability { const position = widePosition(node); let result: ts.Type | null = null; try { - if (position !== null) result = this.computeWidened(node, position); + result = this.computeWidened(node, position); } finally { this.widened.set(node, result); } return result; } + /** A read whose value `expression` can evaluate to (the expression + * itself, an arm of a conditional, an operand of `&&`, `||`, `??`, the + * right side of a comma or an assignment), that the checker types as + * nothing but `undefined` or `null` and widenedType restored to its + * declared type; or null. The checker computed the type of the whole + * expression, and of a function that returns it, from that narrowing, so + * a lowering that discards a value because its type is `undefined` + * consults this first. */ + restoredNothingRead(expression: ts.Expression): ts.Expression | null { + if (this.files === null) return null; + const e = skipOuter(expression); + if (ts.isIdentifier(e) || ts.isPropertyAccessExpression(e)) { + return this.widenedType(e) !== null && isNothing(this.lowerer.checker.getTypeAtLocation(e)) + ? e + : null; + } + if (ts.isConditionalExpression(e)) + return this.restoredNothingRead(e.whenTrue) ?? this.restoredNothingRead(e.whenFalse); + if (ts.isBinaryExpression(e)) { + switch (e.operatorToken.kind) { + case ts.SyntaxKind.AmpersandAmpersandToken: + case ts.SyntaxKind.BarBarToken: + case ts.SyntaxKind.QuestionQuestionToken: + return this.restoredNothingRead(e.left) ?? this.restoredNothingRead(e.right); + case ts.SyntaxKind.CommaToken: + case ts.SyntaxKind.EqualsToken: + return this.restoredNothingRead(e.right); + } + } + return null; + } + /** The checker narrows this reference below its declared type, and the * narrowing may be stale at runtime: its read must check the stored * value instead of trusting the narrowed type. */ @@ -112,9 +144,13 @@ export class NarrowingStability { private computeWidened( node: ts.PropertyAccessExpression | ts.Identifier, - position: WidePosition, + position: WidePosition | null, ): ts.Type | null { const checker = this.lowerer.checker; + // A narrowing to nothing but `undefined` or `null` is restored in every + // position, so every read reaches the declared-type lookup; the other + // narrowings only in the positions that can take the declared type. + if (position === null && !this.mayReadNothing(node)) return null; let declared: ts.Type | null; if (ts.isPropertyAccessExpression(node)) { declared = this.declaredPropertyType(node); @@ -126,6 +162,18 @@ export class NarrowingStability { if (declared === null) return null; const narrowedTs = checker.getTypeAtLocation(node); if (narrowedTs === declared) return null; + // A narrowing to nothing but `undefined` or `null` has no payload a + // checked read could validate: a lowering that takes the checker's word + // drops the read and continues with "absent" (`text.slice(start)` with + // `start` assigned by a callback slices from 0). When that narrowing may + // be stale the read has its declared type wherever it stands, so its + // consumer lowers for the value that is stored. The declared type of a + // binding the checker types by flow alone (`var re;`, which is + // `undefined` to it until the read's own function assigns it) is `any`, + // as the checker itself answers for a read from a nested function + // (`var re; compile(); scan(re)` passes the expression `compile` stored). + if (isNothing(narrowedTs)) return this.trusted(node) ? null : declared; + if (position === null) return null; const declaredIr = this.lowerer.mapTypeOf(declared); const narrowedIr = this.lowerer.mapTypeOf(narrowedTs); if (declaredIr === null || narrowedIr === null || typeEquals(declaredIr, narrowedIr)) @@ -175,6 +223,18 @@ export class NarrowingStability { return this.trusted(node) ? null : declared; } + /** Whether the checker types this read as nothing but `undefined` or + * `null`. Asked for every identifier and property read outside the wide + * positions, so the write census answers by name first: a binding no code + * in its file assigns keeps whatever narrowing it has. */ + private mayReadNothing(node: ts.PropertyAccessExpression | ts.Identifier): boolean { + if (ts.isIdentifier(node)) { + if (!this.writesIn(ts.getSourceFileOfNode(node)).has(node.text)) return false; + if (!isValueIdentifier(node)) return false; + } + return isNothing(this.lowerer.checker.getTypeAtLocation(node)); + } + /** A read in a call or construction argument (directly or inside a literal * argument) reaches the callee's own lowering with the declared type. User * callees coerce any value of their parameter type, and so do the @@ -918,6 +978,13 @@ const INERT_CONSTRUCTORS = new Set([ "BigUint64Array", ]); +/** `undefined`, `null`, `void`, or a union of only those. */ +function isNothing(type: ts.Type): boolean { + const nothing = ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void; + if (type.flags & nothing) return true; + return type.isUnionType() && ts.constituentTypes(type).every((part) => part.flags & nothing); +} + /** The receiver whose type widenedType reads for `node`: a property read in * a slot that accepts its declared type (an argument, a return, a typed * binding or literal). Whole-program analyses batch these receivers. */ diff --git a/packages/compiler/src/frontend/lowering/optional-arguments.ts b/packages/compiler/src/frontend/lowering/optional-arguments.ts index e23eb030865..de2e582f72e 100644 --- a/packages/compiler/src/frontend/lowering/optional-arguments.ts +++ b/packages/compiler/src/frontend/lowering/optional-arguments.ts @@ -26,6 +26,10 @@ export function lowerStaticallyUndefinedArgument( while (ts.isParenthesizedExpression(expr)) expr = expr.expression; if ((lowerer.typeOf(expr).flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) === 0) return null; + // The checker typed the argument `undefined` from a narrowing that a call + // may have undone (`s.slice(done ? start : undefined)` with `start` + // assigned by a callback): the argument carries the stored value. + if (lowerer.narrowing.restoredNothingRead(expr) !== null) return null; // A caller may already have evaluated the entire argument (including a // void operand) before receiver coercion. Do not evaluate it again. const stabilized = lowerer.chainRecvByNode.get(node); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9641-callee-assigned-reads.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9641-callee-assigned-reads.mjs.json new file mode 100644 index 00000000000..898b74a1a82 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9641-callee-assigned-reads.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/9641-callee-assigned-reads.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9642-callee-assigned-reads-commonjs.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9642-callee-assigned-reads-commonjs.cjs.json new file mode 100644 index 00000000000..8b77fac09a5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9642-callee-assigned-reads-commonjs.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/9642-callee-assigned-reads-commonjs.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9643-callee-assigned-reads-typed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9643-callee-assigned-reads-typed.ts.json new file mode 100644 index 00000000000..86be1f0bae5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9643-callee-assigned-reads-typed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/9643-callee-assigned-reads-typed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9645-mustache-templates/main.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9645-mustache-templates/main.mjs.json new file mode 100644 index 00000000000..a19fb9fe097 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9645-mustache-templates/main.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/9645-mustache-templates/main.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stale-undefined-inferred-return.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stale-undefined-inferred-return.ts.json new file mode 100644 index 00000000000..b7b4539a2e0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stale-undefined-inferred-return.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/stale-undefined-inferred-return.ts" + ], + "diags": [] +} diff --git a/tests/corpus/9641-callee-assigned-reads.mjs b/tests/corpus/9641-callee-assigned-reads.mjs new file mode 100644 index 00000000000..f45323af4fa --- /dev/null +++ b/tests/corpus/9641-callee-assigned-reads.mjs @@ -0,0 +1,209 @@ +// A binding that a function assigns, read after the call. TypeScript types +// such a read by the last assignment it sees in the read's own function +// (`undefined` for `var text;`), and a call does not change that. The read +// is the value the callee stored, wherever it stands: an argument of a +// constructor function's method, of a class method, of a function, of +// `new`, of a library function with an optional parameter, an operand, a +// test, a returned value. + +function show(value) { + return typeof value + " " + String(value); +} +function withDefault(value = "default") { + return typeof value + " " + String(value); +} +function Holder(value) { + this.value = value; +} +Holder.prototype.show = function (value) { + return typeof value + " " + String(value); +}; +class Klass { + constructor(value) { + this.value = value; + } + show(value) { + return typeof value + " " + String(value); + } +} +const holder = new Holder(); +const klass = new Klass(); + +// Module-level bindings, assigned by a hoisted function declaration. +var text; +let count; +var separator = undefined; +var radix = null; +function assign() { + text = "t"; + count = 2; + separator = "-"; + radix = 2; +} +assign(); +console.log("constructor function method:", holder.show(text), "|", new Holder().show(count)); +console.log("class method:", klass.show(text), "|", new Klass().show(count)); +console.log("function:", show(text), "|", withDefault(text), "|", show(separator)); +console.log("new:", show(new Holder(text).value), "|", show(new Klass(count).value)); +console.log("console:", text, count, separator, radix); +console.log("operands:", `${text}/${count}`, "" + text + count, typeof text, typeof count); +console.log("tests:", text === undefined, count == null, text ?? "absent", text || "empty", text ? "yes" : "no"); +console.log("Number, String:", Number(count), String(text), String(count)); +console.log("strings:", "abcd".slice(count), "abab".indexOf("b", count), "x".padStart(3, text)); +console.log("split, join:", JSON.stringify("a-b".split(separator)), [1, 2].join(separator)); +console.log("radix:", parseInt("11", radix), (5).toString(radix), JSON.stringify({ a: 1 }, null, count)); +console.log("arithmetic:", count + 1, count * 2, count > 1, [5, 6, 7][count]); + +// The shape of an old-style parser: `var`s of a function that a nested +// function assigns, handed to methods of a constructor function's instance. +function Scanner(string) { + this.tail = string; + this.pos = 0; +} +Scanner.prototype.eos = function eos() { + return this.tail === ""; +}; +Scanner.prototype.scan = function scan(re) { + var match = this.tail.match(re); + if (!match || match.index !== 0) return ""; + var string = match[0]; + this.tail = this.tail.substring(string.length); + this.pos += string.length; + return string; +}; +Scanner.prototype.scanUntil = function scanUntil(re) { + var index = this.tail.search(re), + match; + switch (index) { + case -1: + match = this.tail; + this.tail = ""; + break; + case 0: + match = ""; + break; + default: + match = this.tail.substring(0, index); + this.tail = this.tail.substring(index); + } + this.pos += match.length; + return match; +}; +function parse(template, tags) { + var openingTagRe, closingTagRe; + function compileTags(pair) { + openingTagRe = new RegExp(pair[0] + "\\s*"); + closingTagRe = new RegExp("\\s*" + pair[1]); + } + compileTags(tags); + var scanner = new Scanner(template); + var tokens = []; + while (!scanner.eos()) { + var value = scanner.scanUntil(openingTagRe); + if (value) tokens.push(["text", value, scanner.pos]); + if (!scanner.scan(openingTagRe)) break; + tokens.push(["name", scanner.scanUntil(closingTagRe), scanner.pos]); + if (!scanner.scan(closingTagRe)) break; + } + return tokens; +} +console.log("parse:", JSON.stringify(parse("hi {{who}}, {{ you }}!", ["\\{\\{", "\\}\\}"]))); +console.log("parse:", JSON.stringify(parse("plain", ["<%", "%>"]))); + +// Who assigns: a function expression, an arrow, a method, a constructor +// function's method, a callback a library function calls, a closure handed +// to another function. Each result goes through the method of a +// constructor function's instance and through `slice`. +function callIt(f) { + f(); +} +function assigners() { + var a, b, c, d, e, f; + var na, nb, nc, nd, ne, nf; + var byExpression = function () { + a = "expression"; + na = 1; + }; + const byArrow = () => { + b = "arrow"; + nb = 2; + }; + var byMethod = { + set() { + c = "method"; + nc = 3; + }, + }; + function Setter() {} + Setter.prototype.set = function () { + d = "prototype method"; + nd = 4; + }; + byExpression(); + byArrow(); + byMethod.set(); + new Setter().set(); + [0].forEach(function () { + e = "callback"; + ne = 5; + }); + callIt(function () { + f = "passed closure"; + nf = 6; + }); + return [ + holder.show(a) + " " + "abcdefgh".slice(na), + holder.show(b) + " " + "abcdefgh".slice(nb), + holder.show(c) + " " + "abcdefgh".slice(nc), + holder.show(d) + " " + "abcdefgh".slice(nd), + holder.show(e) + " " + "abcdefgh".slice(ne), + holder.show(f) + " " + "abcdefgh".slice(nf), + ].join("; "); +} +console.log("assigners:", assigners()); + +// A function called where it stands is part of the enclosing flow: its +// assignment types the read, and stays the value. +function inPlace() { + var n, s; + (function () { + n = 2; + s = "in place"; + })(); + return "abcd".slice(n) + " " + holder.show(s) + " " + "abab".indexOf("b", n); +} +console.log("called in place:", inPlace()); + +// A parameter set to undefined and then assigned by a closure; the value +// returned, stored in a property, thrown, kept in a collection. +function later(value, into) { + value = undefined; + var fill = function () { + value = "filled"; + }; + fill(); + into.property = value; + const map = new Map(); + map.set("key", value); + let caught; + try { + throw value; + } catch (thrown) { + caught = thrown; + } + console.log("stored:", show(into.property), "|", show(map.get("key")), "|", show(caught)); + return value; +} +console.log("returned:", show(later("first", { property: "initial" }))); + +// A binding no call can have changed keeps its narrowing. +function untouched() { + var start; + var size; + function never() { + start = 1; + size = 2; + } + return "abcd".slice(start) + " " + holder.show(size) + " " + typeof never; +} +console.log("before any call:", untouched()); diff --git a/tests/corpus/9642-callee-assigned-reads-commonjs.cjs b/tests/corpus/9642-callee-assigned-reads-commonjs.cjs new file mode 100644 index 00000000000..30d506a45df --- /dev/null +++ b/tests/corpus/9642-callee-assigned-reads-commonjs.cjs @@ -0,0 +1,82 @@ +// CommonJS: module-level state that an initializer function assigns, and +// locals a nested function assigns, read after the call. The read is the +// stored value in every position, including arguments of a constructor +// function's methods and of library functions with optional parameters. +"use strict"; + +var path = require("node:path"); + +function Table(name) { + this.name = name; + this.rows = []; +} +Table.prototype.add = function (key, value) { + this.rows.push(String(key) + "=" + typeof value + ":" + String(value)); + return this; +}; +Table.prototype.describe = function (separator) { + return this.name + "[" + this.rows.join(separator) + "]"; +}; + +var settings; +var limit; +var prefix = undefined; +var fallback = null; +function configure() { + settings = { width: 4, pad: "." }; + limit = 2; + prefix = "p"; + fallback = "f"; +} +configure(); + +function widthOf(value) { + return value.width; +} +function padOf(value) { + return value.pad; +} + +var table = new Table("module"); +table.add("settings", widthOf(settings)).add("limit", limit).add("prefix", prefix).add("fallback", fallback); +console.log(table.describe()); +console.log(table.describe(prefix)); +console.log("abcdef".slice(limit), "abab".lastIndexOf("b", limit), "7".padStart(widthOf(settings), padOf(settings))); +console.log(Number(limit) + 1, String(prefix) + String(fallback), parseInt("101", limit)); +console.log(path.join("a", prefix, fallback), path.basename("dir/file.txt", prefix)); + +function collect(words) { + var longest, total, first; + function visit(word, index) { + if (index === 0) first = word; + if (longest === undefined || word.length > longest.length) longest = word; + total = (total || 0) + word.length; + } + words.forEach(visit); + var local = new Table("collect"); + local.add("longest", longest).add("total", total).add("first", first); + return local.describe("; ") + " " + "0123456789".slice(total) + " " + String(longest) + (longest ? "/" : "?") + String(first); +} +console.log(collect(["bb", "a", "ccc"])); +console.log(collect([])); + +function lazy() { + var cache; + var hits = 0; + function load() { + cache = ["x", "y", "z"]; + } + function get(index) { + if (cache === undefined) load(); + hits = hits + 1; + return cache[index]; + } + var a = get(1); + var b = get(2); + return new Table("lazy").add("a", a).add("b", b).add("hits", hits).describe(a); +} +console.log(lazy()); + +exports.Table = Table; +exports.collect = collect; +console.log(typeof exports.collect, typeof exports.Table); diff --git a/tests/corpus/9643-callee-assigned-reads-typed.ts b/tests/corpus/9643-callee-assigned-reads-typed.ts new file mode 100644 index 00000000000..1d719d18c8e --- /dev/null +++ b/tests/corpus/9643-callee-assigned-reads-typed.ts @@ -0,0 +1,159 @@ +// A typed binding or property is assigned `undefined` (or `null`), a +// function called afterwards assigns it a value, and it is read. TypeScript +// keeps the type of the last assignment in the read's own function across +// the call, so it types the read `undefined`. The read is the stored value: +// a library function with an optional parameter gets it, not "absent". + +function show(value: unknown): string { + return typeof value + " " + String(value); +} + +// A module-level binding and a closure. +let start: number | undefined = undefined; +let filler: string | undefined = undefined; +let label: string | null = null; +const fill = (): void => { + start = 2; + filler = "-"; + label = "named"; +}; +fill(); +console.log("strings:", "abcdef".slice(start), "abab".indexOf("b", start), "x".padStart(3, filler)); +console.log("arrays:", [1, 2, 3, 4].slice(start).join(), [1, 2].join(filler), [5, 6, 7].includes(7, start)); +console.log("numbers:", Number(start), parseInt("11", start), (5).toString(start), (1.2345).toFixed(start)); +console.log("json:", JSON.stringify({ a: 1 }, null, start), JSON.stringify({ a: 1 }, null, filler)); +console.log("values:", show(start), show(filler), show(label), String(start), `${filler}${start}`); +console.log("tests:", start === undefined, filler ?? "absent", label || "empty", start ? "set" : "unset", typeof label); +console.log("parenthesized:", "abcdef".slice((start)), [1, 2].join((filler))); + +// Locals of a function, assigned by the callback of a library function. +function widest(words: string[]): string { + let width: number | undefined = undefined; + let pad: string | undefined = undefined; + words.forEach((word) => { + if (width === undefined || word.length > width) width = word.length; + pad = word[0]; + }); + return words.map((word) => word.padStart(width ?? 0, pad)).join("|") + " " + "0123456789".slice(width); +} +console.log("widest:", widest(["a", "bcd", "ef"]), "/", widest([])); + +// A parameter set to undefined and assigned by a nested function. +function reset(from?: number, mark?: string): string { + from = undefined; + mark = undefined; + function restore(): void { + from = 1; + mark = "*"; + } + restore(); + return "abcd".slice(from) + " " + "x".padEnd(3, mark) + " " + show(from); +} +console.log("parameter:", reset(3, "?")); + +// Class fields cleared in a method and refilled by another method, a +// function and a closure. +class Cursor { + offset: number | undefined = undefined; + mark: string | undefined = undefined; + refill(): void { + this.offset = 3; + this.mark = "#"; + } + viaMethod(): string { + this.offset = undefined; + this.mark = undefined; + this.refill(); + return "abcdef".slice(this.offset) + " " + [1, 2].join(this.mark) + " " + show(this.offset); + } + viaFunction(): string { + this.offset = undefined; + this.mark = undefined; + refillCursor(this); + return "abcdef".slice(this.offset) + " " + [1, 2].join(this.mark) + " " + Number(this.offset); + } + viaClosure(): string { + this.offset = undefined; + const set = (): void => { + this.offset = 1; + }; + set(); + return "abcdef".slice(this.offset) + " " + "abab".indexOf("b", this.offset); + } +} +function refillCursor(cursor: Cursor): void { + cursor.offset = 4; + cursor.mark = "+"; +} +const cursor = new Cursor(); +console.log("fields:", cursor.viaMethod(), "/", cursor.viaFunction(), "/", cursor.viaClosure()); + +// An object's properties. +const options: { indent: number | undefined; separator: string | undefined } = { + indent: undefined, + separator: undefined, +}; +function choose(): void { + options.indent = 1; + options.separator = ":"; +} +function formatted(): string { + options.indent = undefined; + options.separator = undefined; + choose(); + return JSON.stringify([1], null, options.indent) + " " + ["a", "b"].join(options.separator); +} +console.log("properties:", formatted()); + +// A declared return type carries the stored value; so do a typed binding, +// a collection and a thrown value. +function found(items: string[], wanted: string): string | undefined { + let match: string | undefined = undefined; + items.forEach((item) => { + if (item.startsWith(wanted)) match = item; + }); + return match; +} +function kept(): string { + let value: string | undefined = undefined; + const set = (): void => { + value = "kept"; + }; + set(); + const typed: string | undefined = value; + const list: unknown[] = []; + list.push(value); + const map = new Map(); + map.set("key", value); + let caught: unknown = "nothing"; + try { + throw value; + } catch (thrown) { + caught = thrown; + } + return [show(typed), show(list[0]), show(map.get("key")), show(caught)].join(" | "); +} +console.log("returned:", show(found(["apple", "banana"], "b")), "/", show(found(["apple"], "b"))); +console.log("kept:", kept()); + +// A function expression called where it stands is part of the flow. +function inPlace(): string { + let size: number | undefined = undefined; + (() => { + size = 2; + })(); + return "abcd".slice(size); +} +console.log("called in place:", inPlace()); + +// Nothing ran since the assignment: the narrowing holds. +function current(): string { + let from: number | undefined = undefined; + const later = (): void => { + from = 3; + }; + const head = "abcd".slice(from); + later(); + return head + " " + "abcd".slice(from); +} +console.log("before the call:", current()); diff --git a/tests/corpus/9645-mustache-templates/main.mjs b/tests/corpus/9645-mustache-templates/main.mjs new file mode 100644 index 00000000000..0fd9a1a1699 --- /dev/null +++ b/tests/corpus/9645-mustache-templates/main.mjs @@ -0,0 +1,55 @@ +// The published mustache@4.2.0, compiled natively, rendering templates. +// node_modules/mustache holds the package's two entry files, its license and +// its package.json, unmodified; node_modules/@types/mustache@4.2.6 is the +// declaration surface a native build of the package needs. The package is +// pre-class JavaScript: its parser keeps the tag expressions in `var`s that +// a nested function assigns and hands them to methods of its Scanner +// constructor function, and its Context and Writer are constructor +// functions with prototype methods. +import Mustache from "mustache"; + +const view = { + title: "Joe", + calc: function () { + return 2 + 4; + }, + items: [ + { name: "a", price: 1.5 }, + { name: "b", price: 2 }, + ], + empty: [], + html: "&", + nested: { deep: { value: "x" } }, + on: true, + off: false, +}; + +console.log(JSON.stringify(Mustache.render("plain", {}))); +console.log(Mustache.render("hi {{who}}", { who: "ada" })); +console.log(Mustache.render("{{title}} spends {{calc}}", view)); +console.log(Mustache.render("{{#items}}{{name}}={{price}};{{/items}}{{^empty}}none{{/empty}}", view)); +console.log(Mustache.render("{{html}} {{{html}}} {{&html}}", view)); +console.log(Mustache.render("{{nested.deep.value}}{{! a comment }}{{missing}}|", view)); +console.log(Mustache.render("{{#on}}on{{/on}}{{#off}}off{{/off}}{{^off}}not off{{/off}}", view)); +console.log(Mustache.render("{{#items}}{{> row}}{{/items}}", view, { row: "[{{name}}]" })); +console.log(Mustache.render("<% title %> {{title}}", view, {}, ["<%", "%>"])); +console.log(Mustache.render("{{=<% %>=}}<% title %> {{title}}", view)); +console.log(Mustache.render("a\n {{#items}}\n - {{name}}\n {{/items}}\nz", view)); +console.log(JSON.stringify(Mustache.parse("hi {{who}}{{#a}}x{{/a}}"))); +console.log(Mustache.escape("&\"`=/")); +console.log(Mustache.name, Mustache.version, JSON.stringify(Mustache.tags)); +try { + Mustache.render("{{#open}}never closed", {}); +} catch (e) { + console.log(e.message); +} +try { + Mustache.render("{{/stray}}", {}); +} catch (e) { + console.log(e.message); +} +try { + Mustache.render(5, {}); +} catch (e) { + console.log(e.name + ": " + e.message); +} diff --git a/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/LICENSE b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.mts b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.mts new file mode 100644 index 00000000000..4742609a9ee --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.mts @@ -0,0 +1,22 @@ +import * as mustache from "./index.js"; + +export { + COMMENT, + EQUAL, + ESCAPED_VALUE, + EscapeFunction, + INVERTED, + OpeningAndClosingTags, + PARTIAL, + PartialLookupFn, + PartialsOrLookupFn, + RAW_VALUE, + RenderOptions, + SECTION, + TemplateCache, + TemplateSpans, + TemplateSpanType, + UNESCAPED_VALUE, +} from "./index.js"; + +export default mustache; diff --git a/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.ts b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.ts new file mode 100644 index 00000000000..58086dc17ff --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/index.d.ts @@ -0,0 +1,421 @@ +/** + * The name of the module. + */ +export const name: string; + +/** + * The version of the module. + */ +export const version: string; + +/** + * The default opening and closing tags used while parsing the templates. + * + * Different default tags can be overridden by setting this field. They will have effect on all subsequent + * calls to {@link render `.render()`} or {@link parse `.parse()`}, unless custom tags are given as arguments to those functions. + * + * Default value is `[ "{{", "}}" ]`. + */ +export let tags: OpeningAndClosingTags; + +/** + * Customise the template caching behaviour by either: + * + * disable it completely by setting it to `undefined` + * + * -- or -- + * + * provide a custom cache strategy that satisfies the {@link TemplateCache `TemplateCache`} interface + */ +export let templateCache: TemplateCache | undefined; + +/** + * A simple string scanner that is used by the template parser to find tokens in template strings. + */ +export class Scanner { + string: string; + tail: string; + pos: number; + + /** + * Initializes a new instance of the {@link Scanner `Scanner`} class. + */ + constructor(string: string); + + /** + * Returns `true` if the tail is empty (end of string). + */ + eos(): boolean; + + /** + * Tries to match the given regular expression at the current position. + * + * @param re + * The regex-pattern to match. + * + * @returns + * The matched text if it can match, the empty string otherwise. + */ + scan(re: RegExp): string; + + /** + * Skips all text until the given regular expression can be matched. + * + * @param re + * The regex-pattern to match. + * + * @returns + * Returns the skipped string, which is the entire tail if no match can be made. + */ + scanUntil(re: RegExp): string; +} + +/** + * Represents a rendering context by wrapping a view object and maintaining a reference to the parent context. + */ +export class Context { + view: any; + parent: Context | undefined; + + /** + * Initializes a new instance of the {@link Context `Context`} class. + */ + constructor(view: any, parentContext?: Context); + + /** + * Creates a new context using the given view with this context as the parent. + * + * @param view + * The view to create the new context with. + */ + push(view: any): Context; + + /** + * Returns the value of the given name in this context, traversing up the context hierarchy if the value is absent in this context's view. + * + * @param name + * The name to look up. + */ + lookup(name: string): any; +} + +/** + * A Writer knows how to take a stream of tokens and render them to a {@link String `string`}, given a context. + * + * It also maintains a cache of templates to avoid the need to parse the same template twice. + */ +export class Writer { + /** + * Initializes a new instance of the {@link Writer `Writer`} class. + */ + constructor(); + + /** + * Clears all cached templates in this writer. + */ + clearCache(): void; + + /** + * Parses and caches the given {@link template `template`} and returns the array of tokens that is generated from the parse. + * + * @param template + * The template to parse. + * + * @param tags + * The tags to use. + */ + parse(template: string, tags?: OpeningAndClosingTags): any; + + /** + * High-level method that is used to render the given {@link template `template`} with the given {@link view `view`}. + * + * @param template + * The template to render. + * + * @param view + * The view to render the template with. + * + * @param partials + * Either an object that contains the names and templates of partials that are used in a template + * + * -- or -- + * + * A function that is used to load partial template on the fly that takes a single argument: the name of the partial. + * + * @param config + * The options for the rendering process. + */ + render( + template: string, + view: any | Context, + partials?: PartialsOrLookupFn, + config?: OpeningAndClosingTags | RenderOptions, + ): string; + + /** + * Low-level method that renders the given array of {@link tokens `tokens`} using the given {@link context `context`} and {@link partials `partials`}. + * + * @param tokens + * The tokens to render. + * + * @param context + * The context to use for rendering the tokens. + * + * @param partials + * The partials to use for rendering the tokens. + * + * @param originalTemplate + * An object used to extract the portion of the original template that was contained in a higher-order section. + * + * If the template doesn't use higher-order sections, this argument may be omitted. + * + * @param config + * The options for the rendering process. + */ + renderTokens( + tokens: string[][], + context: Context, + partials?: PartialsOrLookupFn, + originalTemplate?: string, + config?: RenderOptions, + ): string; + + /** + * Renders a section block. + * + * @param token + * The token to render. + * + * @param context + * The context to use for rendering the token. + * + * @param partials + * The partials to use for rendering the token. + * + * @param originalTemplate + * An object used to extract the portion of the original template that was contained in a higher-order section. + * + * @param config + * The options for the rendering process. + */ + renderSection( + token: string[], + context: Context, + partials?: PartialsOrLookupFn, + originalTemplate?: string, + config?: RenderOptions, + ): string; + + /** + * Renders an inverted section block. + * + * @param token + * The token to render. + * + * @param context + * The context to use for rendering the token. + * + * @param partials + * The partials to use for rendering the token. + * + * @param originalTemplate + * An object used to extract the portion of the original template that was contained in a higher-order section. + * + * @param config + * The options for the rendering process. + */ + renderInverted( + token: string[], + context: Context, + partials?: PartialsOrLookupFn, + originalTemplate?: string, + config?: RenderOptions, + ): string; + + /** + * Adds indentation to each line of the given partial. + * + * @param partial + * The partial to indent. + * + * @param indentation + * String containing a combination of spaces and tabs to use as indentation. + * + * @param lineHasNonSpace + * Whether to indent lines that are empty. + */ + indentPartial(partial: string, indentation: string, lineHasNonSpace: boolean): string; + + /** + * Renders a partial. + * + * @param token + * The token to render. + * + * @param context + * The context to use for rendering the token. + * + * @param partials + * The partials to use for rendering the token. + * + * @param config + * The options for the rendering process. + */ + renderPartial( + token: string[], + context: Context, + partials?: PartialsOrLookupFn, + config?: OpeningAndClosingTags | RenderOptions, + ): string; + + /** + * Renders an unescaped value. + * + * @param token + * The token to render. + * + * @param context + * The context to use for rendering the token. + */ + unescapedValue(token: string[], context: Context): string; + + /** + * Renders an escaped value. + * + * @param token + * The token to render. + * + * @param context + * The context to use for rendering the token. + * + * @param config + * The options for the rendering process. + */ + escapedValue( + token: string[], + context: Context, + config?: RenderOptions, + ): string; + + /** + * Renders a raw token. + * + * @param token + * The token to render. + */ + rawValue(token: string[]): string; +} + +/** + * HTML escaping by default, can be overridden by setting Mustache.escape explicitly or providing the `options` + * argument with an `escape` function when invoking {@link render `Mustache.render()`}. + * + * Escaping can be avoided when needed by using `{{{ value }}}` or `{{& value }}` in templates. + * + * @param value + * The value to escape into a string. + */ +export let escape: EscapeFunction; + +/** + * Clears all cached templates in this writer. + */ +export function clearCache(): void; + +/** + * Parses and caches the given template in the default writer and returns the array of tokens it contains. + * + * Doing this ahead of time avoids the need to parse templates on the fly as they are rendered. + * + * @param template + * The template to parse. + * + * @param tags + * The tags to use. + */ +export function parse(template: string, tags?: OpeningAndClosingTags): TemplateSpans; + +/** + * Renders the {@link template `template`} with the given {@link view `view`} and {@link partials `partials`} using the default writer. + * + * @param template + * The template to render. + * + * @param view + * The view to render the template with. + * + * @param partials + * Either an object that contains the names and templates of partials that are used in a template + * + * -- or -- + * + * A function that is used to load partial template on the fly that takes a single argument: the name of the partial. + * + * @param tagsOrOptions + * The delimiter tags to use or options overriding global defaults. + */ +export function render( + template: string, + view: any | Context, + partials?: PartialsOrLookupFn, + tagsOrOptions?: OpeningAndClosingTags | RenderOptions, +): string; + +export type RAW_VALUE = "text"; +export type ESCAPED_VALUE = "name"; +export type UNESCAPED_VALUE = "&"; +export type SECTION = "#"; +export type INVERTED = "^"; +export type COMMENT = "!"; +export type PARTIAL = ">"; +export type EQUAL = "="; + +export type TemplateSpanType = + | RAW_VALUE + | ESCAPED_VALUE + | SECTION + | UNESCAPED_VALUE + | INVERTED + | COMMENT + | PARTIAL + | EQUAL; + +export type TemplateSpans = Array< + | [TemplateSpanType, string, number, number] + | [TemplateSpanType, string, number, number, TemplateSpans, number] + | [TemplateSpanType, string, number, number, string, number, boolean] +>; + +/** + * Function responsible for escaping values from the view into the rendered output when templates + * has `{{ value }}` in them. + */ +export type EscapeFunction = (value: any) => string; + +/** + * An array of two strings, representing the opening and closing tags respectively, to be used in the templates being rendered. + */ +export type OpeningAndClosingTags = [string, string]; + +/** + * Whenever partials are provided, it can either be an object that contains the names and templates of partials that are used in templates + * + * -- or -- + * + * A function that is used to load partial template on the fly that takes a single argument: the name of the partial. + */ +export type PartialsOrLookupFn = Record | PartialLookupFn; +export type PartialLookupFn = (partialName: string) => string | undefined; + +export interface RenderOptions { + escape?: EscapeFunction | undefined; + tags?: OpeningAndClosingTags | undefined; +} + +export interface TemplateCache { + set(cacheKey: string, value: string): void; + get(cacheKey: string): string | undefined; + clear(): void; +} + +export as namespace Mustache; diff --git a/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/package.json b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/package.json new file mode 100644 index 00000000000..10b580700e1 --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/@types/mustache/package.json @@ -0,0 +1,45 @@ +{ + "name": "@types/mustache", + "version": "4.2.6", + "description": "TypeScript definitions for mustache", + "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/mustache", + "license": "MIT", + "contributors": [ + { + "name": "Mark Ashley Bell", + "githubUsername": "markashleybell", + "url": "https://github.com/markashleybell" + }, + { + "name": "Phillip Johnsen", + "githubUsername": "phillipj", + "url": "https://github.com/phillipj" + }, + { + "name": "Steve Dignam", + "githubUsername": "sbdchd", + "url": "https://github.com/sbdchd" + } + ], + "main": "", + "types": "index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": { + "import": "./index.d.mts", + "default": "./index.d.ts" + } + } + }, + "repository": { + "type": "git", + "url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git", + "directory": "types/mustache" + }, + "scripts": {}, + "dependencies": {}, + "peerDependencies": {}, + "typesPublisherContentHash": "1bcf802ee7b66f5a4d9ea5510555598f8661648e7874d53fcf00cf14c65a0ebf", + "typeScriptVersion": "5.1" +} \ No newline at end of file diff --git a/tests/corpus/9645-mustache-templates/node_modules/mustache/LICENSE b/tests/corpus/9645-mustache-templates/node_modules/mustache/LICENSE new file mode 100644 index 00000000000..4df7d1aae6f --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/mustache/LICENSE @@ -0,0 +1,11 @@ +The MIT License + +Copyright (c) 2009 Chris Wanstrath (Ruby) +Copyright (c) 2010-2014 Jan Lehnardt (JavaScript) +Copyright (c) 2010-2015 The mustache.js community + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.js b/tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.js new file mode 100644 index 00000000000..62fac4d6326 --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.js @@ -0,0 +1,772 @@ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global = global || self, global.Mustache = factory()); +}(this, (function () { 'use strict'; + + /*! + * mustache.js - Logic-less {{mustache}} templates with JavaScript + * http://github.com/janl/mustache.js + */ + + var objectToString = Object.prototype.toString; + var isArray = Array.isArray || function isArrayPolyfill (object) { + return objectToString.call(object) === '[object Array]'; + }; + + function isFunction (object) { + return typeof object === 'function'; + } + + /** + * More correct typeof string handling array + * which normally returns typeof 'object' + */ + function typeStr (obj) { + return isArray(obj) ? 'array' : typeof obj; + } + + function escapeRegExp (string) { + return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&'); + } + + /** + * Null safe way of checking whether or not an object, + * including its prototype, has a given property + */ + function hasProperty (obj, propName) { + return obj != null && typeof obj === 'object' && (propName in obj); + } + + /** + * Safe way of detecting whether or not the given thing is a primitive and + * whether it has the given property + */ + function primitiveHasOwnProperty (primitive, propName) { + return ( + primitive != null + && typeof primitive !== 'object' + && primitive.hasOwnProperty + && primitive.hasOwnProperty(propName) + ); + } + + // Workaround for https://issues.apache.org/jira/browse/COUCHDB-577 + // See https://github.com/janl/mustache.js/issues/189 + var regExpTest = RegExp.prototype.test; + function testRegExp (re, string) { + return regExpTest.call(re, string); + } + + var nonSpaceRe = /\S/; + function isWhitespace (string) { + return !testRegExp(nonSpaceRe, string); + } + + var entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' + }; + + function escapeHtml (string) { + return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) { + return entityMap[s]; + }); + } + + var whiteRe = /\s*/; + var spaceRe = /\s+/; + var equalsRe = /\s*=/; + var curlyRe = /\s*\}/; + var tagRe = /#|\^|\/|>|\{|&|=|!/; + + /** + * Breaks up the given `template` string into a tree of tokens. If the `tags` + * argument is given here it must be an array with two string values: the + * opening and closing tags used in the template (e.g. [ "<%", "%>" ]). Of + * course, the default is to use mustaches (i.e. mustache.tags). + * + * A token is an array with at least 4 elements. The first element is the + * mustache symbol that was used inside the tag, e.g. "#" or "&". If the tag + * did not contain a symbol (i.e. {{myValue}}) this element is "name". For + * all text that appears outside a symbol this element is "text". + * + * The second element of a token is its "value". For mustache tags this is + * whatever else was inside the tag besides the opening symbol. For text tokens + * this is the text itself. + * + * The third and fourth elements of the token are the start and end indices, + * respectively, of the token in the original template. + * + * Tokens that are the root node of a subtree contain two more elements: 1) an + * array of tokens in the subtree and 2) the index in the original template at + * which the closing tag for that section begins. + * + * Tokens for partials also contain two more elements: 1) a string value of + * indendation prior to that tag and 2) the index of that tag on that line - + * eg a value of 2 indicates the partial is the third tag on this line. + */ + function parseTemplate (template, tags) { + if (!template) + return []; + var lineHasNonSpace = false; + var sections = []; // Stack to hold section tokens + var tokens = []; // Buffer to hold the tokens + var spaces = []; // Indices of whitespace tokens on the current line + var hasTag = false; // Is there a {{tag}} on the current line? + var nonSpace = false; // Is there a non-space char on the current line? + var indentation = ''; // Tracks indentation for tags that use it + var tagIndex = 0; // Stores a count of number of tags encountered on a line + + // Strips all whitespace tokens array for the current line + // if there was a {{#tag}} on it and otherwise only space. + function stripSpace () { + if (hasTag && !nonSpace) { + while (spaces.length) + delete tokens[spaces.pop()]; + } else { + spaces = []; + } + + hasTag = false; + nonSpace = false; + } + + var openingTagRe, closingTagRe, closingCurlyRe; + function compileTags (tagsToCompile) { + if (typeof tagsToCompile === 'string') + tagsToCompile = tagsToCompile.split(spaceRe, 2); + + if (!isArray(tagsToCompile) || tagsToCompile.length !== 2) + throw new Error('Invalid tags: ' + tagsToCompile); + + openingTagRe = new RegExp(escapeRegExp(tagsToCompile[0]) + '\\s*'); + closingTagRe = new RegExp('\\s*' + escapeRegExp(tagsToCompile[1])); + closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tagsToCompile[1])); + } + + compileTags(tags || mustache.tags); + + var scanner = new Scanner(template); + + var start, type, value, chr, token, openSection; + while (!scanner.eos()) { + start = scanner.pos; + + // Match any text between tags. + value = scanner.scanUntil(openingTagRe); + + if (value) { + for (var i = 0, valueLength = value.length; i < valueLength; ++i) { + chr = value.charAt(i); + + if (isWhitespace(chr)) { + spaces.push(tokens.length); + indentation += chr; + } else { + nonSpace = true; + lineHasNonSpace = true; + indentation += ' '; + } + + tokens.push([ 'text', chr, start, start + 1 ]); + start += 1; + + // Check for whitespace on the current line. + if (chr === '\n') { + stripSpace(); + indentation = ''; + tagIndex = 0; + lineHasNonSpace = false; + } + } + } + + // Match the opening tag. + if (!scanner.scan(openingTagRe)) + break; + + hasTag = true; + + // Get the tag type. + type = scanner.scan(tagRe) || 'name'; + scanner.scan(whiteRe); + + // Get the tag value. + if (type === '=') { + value = scanner.scanUntil(equalsRe); + scanner.scan(equalsRe); + scanner.scanUntil(closingTagRe); + } else if (type === '{') { + value = scanner.scanUntil(closingCurlyRe); + scanner.scan(curlyRe); + scanner.scanUntil(closingTagRe); + type = '&'; + } else { + value = scanner.scanUntil(closingTagRe); + } + + // Match the closing tag. + if (!scanner.scan(closingTagRe)) + throw new Error('Unclosed tag at ' + scanner.pos); + + if (type == '>') { + token = [ type, value, start, scanner.pos, indentation, tagIndex, lineHasNonSpace ]; + } else { + token = [ type, value, start, scanner.pos ]; + } + tagIndex++; + tokens.push(token); + + if (type === '#' || type === '^') { + sections.push(token); + } else if (type === '/') { + // Check section nesting. + openSection = sections.pop(); + + if (!openSection) + throw new Error('Unopened section "' + value + '" at ' + start); + + if (openSection[1] !== value) + throw new Error('Unclosed section "' + openSection[1] + '" at ' + start); + } else if (type === 'name' || type === '{' || type === '&') { + nonSpace = true; + } else if (type === '=') { + // Set the tags for the next time around. + compileTags(value); + } + } + + stripSpace(); + + // Make sure there are no open sections when we're done. + openSection = sections.pop(); + + if (openSection) + throw new Error('Unclosed section "' + openSection[1] + '" at ' + scanner.pos); + + return nestTokens(squashTokens(tokens)); + } + + /** + * Combines the values of consecutive text tokens in the given `tokens` array + * to a single token. + */ + function squashTokens (tokens) { + var squashedTokens = []; + + var token, lastToken; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + token = tokens[i]; + + if (token) { + if (token[0] === 'text' && lastToken && lastToken[0] === 'text') { + lastToken[1] += token[1]; + lastToken[3] = token[3]; + } else { + squashedTokens.push(token); + lastToken = token; + } + } + } + + return squashedTokens; + } + + /** + * Forms the given array of `tokens` into a nested tree structure where + * tokens that represent a section have two additional items: 1) an array of + * all tokens that appear in that section and 2) the index in the original + * template that represents the end of that section. + */ + function nestTokens (tokens) { + var nestedTokens = []; + var collector = nestedTokens; + var sections = []; + + var token, section; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + token = tokens[i]; + + switch (token[0]) { + case '#': + case '^': + collector.push(token); + sections.push(token); + collector = token[4] = []; + break; + case '/': + section = sections.pop(); + section[5] = token[2]; + collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens; + break; + default: + collector.push(token); + } + } + + return nestedTokens; + } + + /** + * A simple string scanner that is used by the template parser to find + * tokens in template strings. + */ + function Scanner (string) { + this.string = string; + this.tail = string; + this.pos = 0; + } + + /** + * Returns `true` if the tail is empty (end of string). + */ + Scanner.prototype.eos = function eos () { + return this.tail === ''; + }; + + /** + * Tries to match the given regular expression at the current position. + * Returns the matched text if it can match, the empty string otherwise. + */ + Scanner.prototype.scan = function scan (re) { + var match = this.tail.match(re); + + if (!match || match.index !== 0) + return ''; + + var string = match[0]; + + this.tail = this.tail.substring(string.length); + this.pos += string.length; + + return string; + }; + + /** + * Skips all text until the given regular expression can be matched. Returns + * the skipped string, which is the entire tail if no match can be made. + */ + Scanner.prototype.scanUntil = function scanUntil (re) { + var index = this.tail.search(re), match; + + switch (index) { + case -1: + match = this.tail; + this.tail = ''; + break; + case 0: + match = ''; + break; + default: + match = this.tail.substring(0, index); + this.tail = this.tail.substring(index); + } + + this.pos += match.length; + + return match; + }; + + /** + * Represents a rendering context by wrapping a view object and + * maintaining a reference to the parent context. + */ + function Context (view, parentContext) { + this.view = view; + this.cache = { '.': this.view }; + this.parent = parentContext; + } + + /** + * Creates a new context using the given view with this context + * as the parent. + */ + Context.prototype.push = function push (view) { + return new Context(view, this); + }; + + /** + * Returns the value of the given name in this context, traversing + * up the context hierarchy if the value is absent in this context's view. + */ + Context.prototype.lookup = function lookup (name) { + var cache = this.cache; + + var value; + if (cache.hasOwnProperty(name)) { + value = cache[name]; + } else { + var context = this, intermediateValue, names, index, lookupHit = false; + + while (context) { + if (name.indexOf('.') > 0) { + intermediateValue = context.view; + names = name.split('.'); + index = 0; + + /** + * Using the dot notion path in `name`, we descend through the + * nested objects. + * + * To be certain that the lookup has been successful, we have to + * check if the last object in the path actually has the property + * we are looking for. We store the result in `lookupHit`. + * + * This is specially necessary for when the value has been set to + * `undefined` and we want to avoid looking up parent contexts. + * + * In the case where dot notation is used, we consider the lookup + * to be successful even if the last "object" in the path is + * not actually an object but a primitive (e.g., a string, or an + * integer), because it is sometimes useful to access a property + * of an autoboxed primitive, such as the length of a string. + **/ + while (intermediateValue != null && index < names.length) { + if (index === names.length - 1) + lookupHit = ( + hasProperty(intermediateValue, names[index]) + || primitiveHasOwnProperty(intermediateValue, names[index]) + ); + + intermediateValue = intermediateValue[names[index++]]; + } + } else { + intermediateValue = context.view[name]; + + /** + * Only checking against `hasProperty`, which always returns `false` if + * `context.view` is not an object. Deliberately omitting the check + * against `primitiveHasOwnProperty` if dot notation is not used. + * + * Consider this example: + * ``` + * Mustache.render("The length of a football field is {{#length}}{{length}}{{/length}}.", {length: "100 yards"}) + * ``` + * + * If we were to check also against `primitiveHasOwnProperty`, as we do + * in the dot notation case, then render call would return: + * + * "The length of a football field is 9." + * + * rather than the expected: + * + * "The length of a football field is 100 yards." + **/ + lookupHit = hasProperty(context.view, name); + } + + if (lookupHit) { + value = intermediateValue; + break; + } + + context = context.parent; + } + + cache[name] = value; + } + + if (isFunction(value)) + value = value.call(this.view); + + return value; + }; + + /** + * A Writer knows how to take a stream of tokens and render them to a + * string, given a context. It also maintains a cache of templates to + * avoid the need to parse the same template twice. + */ + function Writer () { + this.templateCache = { + _cache: {}, + set: function set (key, value) { + this._cache[key] = value; + }, + get: function get (key) { + return this._cache[key]; + }, + clear: function clear () { + this._cache = {}; + } + }; + } + + /** + * Clears all cached templates in this writer. + */ + Writer.prototype.clearCache = function clearCache () { + if (typeof this.templateCache !== 'undefined') { + this.templateCache.clear(); + } + }; + + /** + * Parses and caches the given `template` according to the given `tags` or + * `mustache.tags` if `tags` is omitted, and returns the array of tokens + * that is generated from the parse. + */ + Writer.prototype.parse = function parse (template, tags) { + var cache = this.templateCache; + var cacheKey = template + ':' + (tags || mustache.tags).join(':'); + var isCacheEnabled = typeof cache !== 'undefined'; + var tokens = isCacheEnabled ? cache.get(cacheKey) : undefined; + + if (tokens == undefined) { + tokens = parseTemplate(template, tags); + isCacheEnabled && cache.set(cacheKey, tokens); + } + return tokens; + }; + + /** + * High-level method that is used to render the given `template` with + * the given `view`. + * + * The optional `partials` argument may be an object that contains the + * names and templates of partials that are used in the template. It may + * also be a function that is used to load partial templates on the fly + * that takes a single argument: the name of the partial. + * + * If the optional `config` argument is given here, then it should be an + * object with a `tags` attribute or an `escape` attribute or both. + * If an array is passed, then it will be interpreted the same way as + * a `tags` attribute on a `config` object. + * + * The `tags` attribute of a `config` object must be an array with two + * string values: the opening and closing tags used in the template (e.g. + * [ "<%", "%>" ]). The default is to mustache.tags. + * + * The `escape` attribute of a `config` object must be a function which + * accepts a string as input and outputs a safely escaped string. + * If an `escape` function is not provided, then an HTML-safe string + * escaping function is used as the default. + */ + Writer.prototype.render = function render (template, view, partials, config) { + var tags = this.getConfigTags(config); + var tokens = this.parse(template, tags); + var context = (view instanceof Context) ? view : new Context(view, undefined); + return this.renderTokens(tokens, context, partials, template, config); + }; + + /** + * Low-level method that renders the given array of `tokens` using + * the given `context` and `partials`. + * + * Note: The `originalTemplate` is only ever used to extract the portion + * of the original template that was contained in a higher-order section. + * If the template doesn't use higher-order sections, this argument may + * be omitted. + */ + Writer.prototype.renderTokens = function renderTokens (tokens, context, partials, originalTemplate, config) { + var buffer = ''; + + var token, symbol, value; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + value = undefined; + token = tokens[i]; + symbol = token[0]; + + if (symbol === '#') value = this.renderSection(token, context, partials, originalTemplate, config); + else if (symbol === '^') value = this.renderInverted(token, context, partials, originalTemplate, config); + else if (symbol === '>') value = this.renderPartial(token, context, partials, config); + else if (symbol === '&') value = this.unescapedValue(token, context); + else if (symbol === 'name') value = this.escapedValue(token, context, config); + else if (symbol === 'text') value = this.rawValue(token); + + if (value !== undefined) + buffer += value; + } + + return buffer; + }; + + Writer.prototype.renderSection = function renderSection (token, context, partials, originalTemplate, config) { + var self = this; + var buffer = ''; + var value = context.lookup(token[1]); + + // This function is used to render an arbitrary template + // in the current context by higher-order sections. + function subRender (template) { + return self.render(template, context, partials, config); + } + + if (!value) return; + + if (isArray(value)) { + for (var j = 0, valueLength = value.length; j < valueLength; ++j) { + buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate, config); + } + } else if (typeof value === 'object' || typeof value === 'string' || typeof value === 'number') { + buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate, config); + } else if (isFunction(value)) { + if (typeof originalTemplate !== 'string') + throw new Error('Cannot use higher-order sections without the original template'); + + // Extract the portion of the original template that the section contains. + value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender); + + if (value != null) + buffer += value; + } else { + buffer += this.renderTokens(token[4], context, partials, originalTemplate, config); + } + return buffer; + }; + + Writer.prototype.renderInverted = function renderInverted (token, context, partials, originalTemplate, config) { + var value = context.lookup(token[1]); + + // Use JavaScript's definition of falsy. Include empty arrays. + // See https://github.com/janl/mustache.js/issues/186 + if (!value || (isArray(value) && value.length === 0)) + return this.renderTokens(token[4], context, partials, originalTemplate, config); + }; + + Writer.prototype.indentPartial = function indentPartial (partial, indentation, lineHasNonSpace) { + var filteredIndentation = indentation.replace(/[^ \t]/g, ''); + var partialByNl = partial.split('\n'); + for (var i = 0; i < partialByNl.length; i++) { + if (partialByNl[i].length && (i > 0 || !lineHasNonSpace)) { + partialByNl[i] = filteredIndentation + partialByNl[i]; + } + } + return partialByNl.join('\n'); + }; + + Writer.prototype.renderPartial = function renderPartial (token, context, partials, config) { + if (!partials) return; + var tags = this.getConfigTags(config); + + var value = isFunction(partials) ? partials(token[1]) : partials[token[1]]; + if (value != null) { + var lineHasNonSpace = token[6]; + var tagIndex = token[5]; + var indentation = token[4]; + var indentedValue = value; + if (tagIndex == 0 && indentation) { + indentedValue = this.indentPartial(value, indentation, lineHasNonSpace); + } + var tokens = this.parse(indentedValue, tags); + return this.renderTokens(tokens, context, partials, indentedValue, config); + } + }; + + Writer.prototype.unescapedValue = function unescapedValue (token, context) { + var value = context.lookup(token[1]); + if (value != null) + return value; + }; + + Writer.prototype.escapedValue = function escapedValue (token, context, config) { + var escape = this.getConfigEscape(config) || mustache.escape; + var value = context.lookup(token[1]); + if (value != null) + return (typeof value === 'number' && escape === mustache.escape) ? String(value) : escape(value); + }; + + Writer.prototype.rawValue = function rawValue (token) { + return token[1]; + }; + + Writer.prototype.getConfigTags = function getConfigTags (config) { + if (isArray(config)) { + return config; + } + else if (config && typeof config === 'object') { + return config.tags; + } + else { + return undefined; + } + }; + + Writer.prototype.getConfigEscape = function getConfigEscape (config) { + if (config && typeof config === 'object' && !isArray(config)) { + return config.escape; + } + else { + return undefined; + } + }; + + var mustache = { + name: 'mustache.js', + version: '4.2.0', + tags: [ '{{', '}}' ], + clearCache: undefined, + escape: undefined, + parse: undefined, + render: undefined, + Scanner: undefined, + Context: undefined, + Writer: undefined, + /** + * Allows a user to override the default caching strategy, by providing an + * object with set, get and clear methods. This can also be used to disable + * the cache by setting it to the literal `undefined`. + */ + set templateCache (cache) { + defaultWriter.templateCache = cache; + }, + /** + * Gets the default or overridden caching object from the default writer. + */ + get templateCache () { + return defaultWriter.templateCache; + } + }; + + // All high-level mustache.* functions use this writer. + var defaultWriter = new Writer(); + + /** + * Clears all cached templates in the default writer. + */ + mustache.clearCache = function clearCache () { + return defaultWriter.clearCache(); + }; + + /** + * Parses and caches the given template in the default writer and returns the + * array of tokens it contains. Doing this ahead of time avoids the need to + * parse templates on the fly as they are rendered. + */ + mustache.parse = function parse (template, tags) { + return defaultWriter.parse(template, tags); + }; + + /** + * Renders the `template` with the given `view`, `partials`, and `config` + * using the default writer. + */ + mustache.render = function render (template, view, partials, config) { + if (typeof template !== 'string') { + throw new TypeError('Invalid template! Template should be a "string" ' + + 'but "' + typeStr(template) + '" was given as the first ' + + 'argument for mustache#render(template, view, partials)'); + } + + return defaultWriter.render(template, view, partials, config); + }; + + // Export the escaping function so that the user may override it. + // See https://github.com/janl/mustache.js/issues/244 + mustache.escape = escapeHtml; + + // Export these mainly for testing, but also for advanced usage. + mustache.Scanner = Scanner; + mustache.Context = Context; + mustache.Writer = Writer; + + return mustache; + +}))); diff --git a/tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.mjs b/tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.mjs new file mode 100644 index 00000000000..ed0cd6d7162 --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/mustache/mustache.mjs @@ -0,0 +1,764 @@ +/*! + * mustache.js - Logic-less {{mustache}} templates with JavaScript + * http://github.com/janl/mustache.js + */ + +var objectToString = Object.prototype.toString; +var isArray = Array.isArray || function isArrayPolyfill (object) { + return objectToString.call(object) === '[object Array]'; +}; + +function isFunction (object) { + return typeof object === 'function'; +} + +/** + * More correct typeof string handling array + * which normally returns typeof 'object' + */ +function typeStr (obj) { + return isArray(obj) ? 'array' : typeof obj; +} + +function escapeRegExp (string) { + return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&'); +} + +/** + * Null safe way of checking whether or not an object, + * including its prototype, has a given property + */ +function hasProperty (obj, propName) { + return obj != null && typeof obj === 'object' && (propName in obj); +} + +/** + * Safe way of detecting whether or not the given thing is a primitive and + * whether it has the given property + */ +function primitiveHasOwnProperty (primitive, propName) { + return ( + primitive != null + && typeof primitive !== 'object' + && primitive.hasOwnProperty + && primitive.hasOwnProperty(propName) + ); +} + +// Workaround for https://issues.apache.org/jira/browse/COUCHDB-577 +// See https://github.com/janl/mustache.js/issues/189 +var regExpTest = RegExp.prototype.test; +function testRegExp (re, string) { + return regExpTest.call(re, string); +} + +var nonSpaceRe = /\S/; +function isWhitespace (string) { + return !testRegExp(nonSpaceRe, string); +} + +var entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' +}; + +function escapeHtml (string) { + return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) { + return entityMap[s]; + }); +} + +var whiteRe = /\s*/; +var spaceRe = /\s+/; +var equalsRe = /\s*=/; +var curlyRe = /\s*\}/; +var tagRe = /#|\^|\/|>|\{|&|=|!/; + +/** + * Breaks up the given `template` string into a tree of tokens. If the `tags` + * argument is given here it must be an array with two string values: the + * opening and closing tags used in the template (e.g. [ "<%", "%>" ]). Of + * course, the default is to use mustaches (i.e. mustache.tags). + * + * A token is an array with at least 4 elements. The first element is the + * mustache symbol that was used inside the tag, e.g. "#" or "&". If the tag + * did not contain a symbol (i.e. {{myValue}}) this element is "name". For + * all text that appears outside a symbol this element is "text". + * + * The second element of a token is its "value". For mustache tags this is + * whatever else was inside the tag besides the opening symbol. For text tokens + * this is the text itself. + * + * The third and fourth elements of the token are the start and end indices, + * respectively, of the token in the original template. + * + * Tokens that are the root node of a subtree contain two more elements: 1) an + * array of tokens in the subtree and 2) the index in the original template at + * which the closing tag for that section begins. + * + * Tokens for partials also contain two more elements: 1) a string value of + * indendation prior to that tag and 2) the index of that tag on that line - + * eg a value of 2 indicates the partial is the third tag on this line. + */ +function parseTemplate (template, tags) { + if (!template) + return []; + var lineHasNonSpace = false; + var sections = []; // Stack to hold section tokens + var tokens = []; // Buffer to hold the tokens + var spaces = []; // Indices of whitespace tokens on the current line + var hasTag = false; // Is there a {{tag}} on the current line? + var nonSpace = false; // Is there a non-space char on the current line? + var indentation = ''; // Tracks indentation for tags that use it + var tagIndex = 0; // Stores a count of number of tags encountered on a line + + // Strips all whitespace tokens array for the current line + // if there was a {{#tag}} on it and otherwise only space. + function stripSpace () { + if (hasTag && !nonSpace) { + while (spaces.length) + delete tokens[spaces.pop()]; + } else { + spaces = []; + } + + hasTag = false; + nonSpace = false; + } + + var openingTagRe, closingTagRe, closingCurlyRe; + function compileTags (tagsToCompile) { + if (typeof tagsToCompile === 'string') + tagsToCompile = tagsToCompile.split(spaceRe, 2); + + if (!isArray(tagsToCompile) || tagsToCompile.length !== 2) + throw new Error('Invalid tags: ' + tagsToCompile); + + openingTagRe = new RegExp(escapeRegExp(tagsToCompile[0]) + '\\s*'); + closingTagRe = new RegExp('\\s*' + escapeRegExp(tagsToCompile[1])); + closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tagsToCompile[1])); + } + + compileTags(tags || mustache.tags); + + var scanner = new Scanner(template); + + var start, type, value, chr, token, openSection; + while (!scanner.eos()) { + start = scanner.pos; + + // Match any text between tags. + value = scanner.scanUntil(openingTagRe); + + if (value) { + for (var i = 0, valueLength = value.length; i < valueLength; ++i) { + chr = value.charAt(i); + + if (isWhitespace(chr)) { + spaces.push(tokens.length); + indentation += chr; + } else { + nonSpace = true; + lineHasNonSpace = true; + indentation += ' '; + } + + tokens.push([ 'text', chr, start, start + 1 ]); + start += 1; + + // Check for whitespace on the current line. + if (chr === '\n') { + stripSpace(); + indentation = ''; + tagIndex = 0; + lineHasNonSpace = false; + } + } + } + + // Match the opening tag. + if (!scanner.scan(openingTagRe)) + break; + + hasTag = true; + + // Get the tag type. + type = scanner.scan(tagRe) || 'name'; + scanner.scan(whiteRe); + + // Get the tag value. + if (type === '=') { + value = scanner.scanUntil(equalsRe); + scanner.scan(equalsRe); + scanner.scanUntil(closingTagRe); + } else if (type === '{') { + value = scanner.scanUntil(closingCurlyRe); + scanner.scan(curlyRe); + scanner.scanUntil(closingTagRe); + type = '&'; + } else { + value = scanner.scanUntil(closingTagRe); + } + + // Match the closing tag. + if (!scanner.scan(closingTagRe)) + throw new Error('Unclosed tag at ' + scanner.pos); + + if (type == '>') { + token = [ type, value, start, scanner.pos, indentation, tagIndex, lineHasNonSpace ]; + } else { + token = [ type, value, start, scanner.pos ]; + } + tagIndex++; + tokens.push(token); + + if (type === '#' || type === '^') { + sections.push(token); + } else if (type === '/') { + // Check section nesting. + openSection = sections.pop(); + + if (!openSection) + throw new Error('Unopened section "' + value + '" at ' + start); + + if (openSection[1] !== value) + throw new Error('Unclosed section "' + openSection[1] + '" at ' + start); + } else if (type === 'name' || type === '{' || type === '&') { + nonSpace = true; + } else if (type === '=') { + // Set the tags for the next time around. + compileTags(value); + } + } + + stripSpace(); + + // Make sure there are no open sections when we're done. + openSection = sections.pop(); + + if (openSection) + throw new Error('Unclosed section "' + openSection[1] + '" at ' + scanner.pos); + + return nestTokens(squashTokens(tokens)); +} + +/** + * Combines the values of consecutive text tokens in the given `tokens` array + * to a single token. + */ +function squashTokens (tokens) { + var squashedTokens = []; + + var token, lastToken; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + token = tokens[i]; + + if (token) { + if (token[0] === 'text' && lastToken && lastToken[0] === 'text') { + lastToken[1] += token[1]; + lastToken[3] = token[3]; + } else { + squashedTokens.push(token); + lastToken = token; + } + } + } + + return squashedTokens; +} + +/** + * Forms the given array of `tokens` into a nested tree structure where + * tokens that represent a section have two additional items: 1) an array of + * all tokens that appear in that section and 2) the index in the original + * template that represents the end of that section. + */ +function nestTokens (tokens) { + var nestedTokens = []; + var collector = nestedTokens; + var sections = []; + + var token, section; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + token = tokens[i]; + + switch (token[0]) { + case '#': + case '^': + collector.push(token); + sections.push(token); + collector = token[4] = []; + break; + case '/': + section = sections.pop(); + section[5] = token[2]; + collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens; + break; + default: + collector.push(token); + } + } + + return nestedTokens; +} + +/** + * A simple string scanner that is used by the template parser to find + * tokens in template strings. + */ +function Scanner (string) { + this.string = string; + this.tail = string; + this.pos = 0; +} + +/** + * Returns `true` if the tail is empty (end of string). + */ +Scanner.prototype.eos = function eos () { + return this.tail === ''; +}; + +/** + * Tries to match the given regular expression at the current position. + * Returns the matched text if it can match, the empty string otherwise. + */ +Scanner.prototype.scan = function scan (re) { + var match = this.tail.match(re); + + if (!match || match.index !== 0) + return ''; + + var string = match[0]; + + this.tail = this.tail.substring(string.length); + this.pos += string.length; + + return string; +}; + +/** + * Skips all text until the given regular expression can be matched. Returns + * the skipped string, which is the entire tail if no match can be made. + */ +Scanner.prototype.scanUntil = function scanUntil (re) { + var index = this.tail.search(re), match; + + switch (index) { + case -1: + match = this.tail; + this.tail = ''; + break; + case 0: + match = ''; + break; + default: + match = this.tail.substring(0, index); + this.tail = this.tail.substring(index); + } + + this.pos += match.length; + + return match; +}; + +/** + * Represents a rendering context by wrapping a view object and + * maintaining a reference to the parent context. + */ +function Context (view, parentContext) { + this.view = view; + this.cache = { '.': this.view }; + this.parent = parentContext; +} + +/** + * Creates a new context using the given view with this context + * as the parent. + */ +Context.prototype.push = function push (view) { + return new Context(view, this); +}; + +/** + * Returns the value of the given name in this context, traversing + * up the context hierarchy if the value is absent in this context's view. + */ +Context.prototype.lookup = function lookup (name) { + var cache = this.cache; + + var value; + if (cache.hasOwnProperty(name)) { + value = cache[name]; + } else { + var context = this, intermediateValue, names, index, lookupHit = false; + + while (context) { + if (name.indexOf('.') > 0) { + intermediateValue = context.view; + names = name.split('.'); + index = 0; + + /** + * Using the dot notion path in `name`, we descend through the + * nested objects. + * + * To be certain that the lookup has been successful, we have to + * check if the last object in the path actually has the property + * we are looking for. We store the result in `lookupHit`. + * + * This is specially necessary for when the value has been set to + * `undefined` and we want to avoid looking up parent contexts. + * + * In the case where dot notation is used, we consider the lookup + * to be successful even if the last "object" in the path is + * not actually an object but a primitive (e.g., a string, or an + * integer), because it is sometimes useful to access a property + * of an autoboxed primitive, such as the length of a string. + **/ + while (intermediateValue != null && index < names.length) { + if (index === names.length - 1) + lookupHit = ( + hasProperty(intermediateValue, names[index]) + || primitiveHasOwnProperty(intermediateValue, names[index]) + ); + + intermediateValue = intermediateValue[names[index++]]; + } + } else { + intermediateValue = context.view[name]; + + /** + * Only checking against `hasProperty`, which always returns `false` if + * `context.view` is not an object. Deliberately omitting the check + * against `primitiveHasOwnProperty` if dot notation is not used. + * + * Consider this example: + * ``` + * Mustache.render("The length of a football field is {{#length}}{{length}}{{/length}}.", {length: "100 yards"}) + * ``` + * + * If we were to check also against `primitiveHasOwnProperty`, as we do + * in the dot notation case, then render call would return: + * + * "The length of a football field is 9." + * + * rather than the expected: + * + * "The length of a football field is 100 yards." + **/ + lookupHit = hasProperty(context.view, name); + } + + if (lookupHit) { + value = intermediateValue; + break; + } + + context = context.parent; + } + + cache[name] = value; + } + + if (isFunction(value)) + value = value.call(this.view); + + return value; +}; + +/** + * A Writer knows how to take a stream of tokens and render them to a + * string, given a context. It also maintains a cache of templates to + * avoid the need to parse the same template twice. + */ +function Writer () { + this.templateCache = { + _cache: {}, + set: function set (key, value) { + this._cache[key] = value; + }, + get: function get (key) { + return this._cache[key]; + }, + clear: function clear () { + this._cache = {}; + } + }; +} + +/** + * Clears all cached templates in this writer. + */ +Writer.prototype.clearCache = function clearCache () { + if (typeof this.templateCache !== 'undefined') { + this.templateCache.clear(); + } +}; + +/** + * Parses and caches the given `template` according to the given `tags` or + * `mustache.tags` if `tags` is omitted, and returns the array of tokens + * that is generated from the parse. + */ +Writer.prototype.parse = function parse (template, tags) { + var cache = this.templateCache; + var cacheKey = template + ':' + (tags || mustache.tags).join(':'); + var isCacheEnabled = typeof cache !== 'undefined'; + var tokens = isCacheEnabled ? cache.get(cacheKey) : undefined; + + if (tokens == undefined) { + tokens = parseTemplate(template, tags); + isCacheEnabled && cache.set(cacheKey, tokens); + } + return tokens; +}; + +/** + * High-level method that is used to render the given `template` with + * the given `view`. + * + * The optional `partials` argument may be an object that contains the + * names and templates of partials that are used in the template. It may + * also be a function that is used to load partial templates on the fly + * that takes a single argument: the name of the partial. + * + * If the optional `config` argument is given here, then it should be an + * object with a `tags` attribute or an `escape` attribute or both. + * If an array is passed, then it will be interpreted the same way as + * a `tags` attribute on a `config` object. + * + * The `tags` attribute of a `config` object must be an array with two + * string values: the opening and closing tags used in the template (e.g. + * [ "<%", "%>" ]). The default is to mustache.tags. + * + * The `escape` attribute of a `config` object must be a function which + * accepts a string as input and outputs a safely escaped string. + * If an `escape` function is not provided, then an HTML-safe string + * escaping function is used as the default. + */ +Writer.prototype.render = function render (template, view, partials, config) { + var tags = this.getConfigTags(config); + var tokens = this.parse(template, tags); + var context = (view instanceof Context) ? view : new Context(view, undefined); + return this.renderTokens(tokens, context, partials, template, config); +}; + +/** + * Low-level method that renders the given array of `tokens` using + * the given `context` and `partials`. + * + * Note: The `originalTemplate` is only ever used to extract the portion + * of the original template that was contained in a higher-order section. + * If the template doesn't use higher-order sections, this argument may + * be omitted. + */ +Writer.prototype.renderTokens = function renderTokens (tokens, context, partials, originalTemplate, config) { + var buffer = ''; + + var token, symbol, value; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + value = undefined; + token = tokens[i]; + symbol = token[0]; + + if (symbol === '#') value = this.renderSection(token, context, partials, originalTemplate, config); + else if (symbol === '^') value = this.renderInverted(token, context, partials, originalTemplate, config); + else if (symbol === '>') value = this.renderPartial(token, context, partials, config); + else if (symbol === '&') value = this.unescapedValue(token, context); + else if (symbol === 'name') value = this.escapedValue(token, context, config); + else if (symbol === 'text') value = this.rawValue(token); + + if (value !== undefined) + buffer += value; + } + + return buffer; +}; + +Writer.prototype.renderSection = function renderSection (token, context, partials, originalTemplate, config) { + var self = this; + var buffer = ''; + var value = context.lookup(token[1]); + + // This function is used to render an arbitrary template + // in the current context by higher-order sections. + function subRender (template) { + return self.render(template, context, partials, config); + } + + if (!value) return; + + if (isArray(value)) { + for (var j = 0, valueLength = value.length; j < valueLength; ++j) { + buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate, config); + } + } else if (typeof value === 'object' || typeof value === 'string' || typeof value === 'number') { + buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate, config); + } else if (isFunction(value)) { + if (typeof originalTemplate !== 'string') + throw new Error('Cannot use higher-order sections without the original template'); + + // Extract the portion of the original template that the section contains. + value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender); + + if (value != null) + buffer += value; + } else { + buffer += this.renderTokens(token[4], context, partials, originalTemplate, config); + } + return buffer; +}; + +Writer.prototype.renderInverted = function renderInverted (token, context, partials, originalTemplate, config) { + var value = context.lookup(token[1]); + + // Use JavaScript's definition of falsy. Include empty arrays. + // See https://github.com/janl/mustache.js/issues/186 + if (!value || (isArray(value) && value.length === 0)) + return this.renderTokens(token[4], context, partials, originalTemplate, config); +}; + +Writer.prototype.indentPartial = function indentPartial (partial, indentation, lineHasNonSpace) { + var filteredIndentation = indentation.replace(/[^ \t]/g, ''); + var partialByNl = partial.split('\n'); + for (var i = 0; i < partialByNl.length; i++) { + if (partialByNl[i].length && (i > 0 || !lineHasNonSpace)) { + partialByNl[i] = filteredIndentation + partialByNl[i]; + } + } + return partialByNl.join('\n'); +}; + +Writer.prototype.renderPartial = function renderPartial (token, context, partials, config) { + if (!partials) return; + var tags = this.getConfigTags(config); + + var value = isFunction(partials) ? partials(token[1]) : partials[token[1]]; + if (value != null) { + var lineHasNonSpace = token[6]; + var tagIndex = token[5]; + var indentation = token[4]; + var indentedValue = value; + if (tagIndex == 0 && indentation) { + indentedValue = this.indentPartial(value, indentation, lineHasNonSpace); + } + var tokens = this.parse(indentedValue, tags); + return this.renderTokens(tokens, context, partials, indentedValue, config); + } +}; + +Writer.prototype.unescapedValue = function unescapedValue (token, context) { + var value = context.lookup(token[1]); + if (value != null) + return value; +}; + +Writer.prototype.escapedValue = function escapedValue (token, context, config) { + var escape = this.getConfigEscape(config) || mustache.escape; + var value = context.lookup(token[1]); + if (value != null) + return (typeof value === 'number' && escape === mustache.escape) ? String(value) : escape(value); +}; + +Writer.prototype.rawValue = function rawValue (token) { + return token[1]; +}; + +Writer.prototype.getConfigTags = function getConfigTags (config) { + if (isArray(config)) { + return config; + } + else if (config && typeof config === 'object') { + return config.tags; + } + else { + return undefined; + } +}; + +Writer.prototype.getConfigEscape = function getConfigEscape (config) { + if (config && typeof config === 'object' && !isArray(config)) { + return config.escape; + } + else { + return undefined; + } +}; + +var mustache = { + name: 'mustache.js', + version: '4.2.0', + tags: [ '{{', '}}' ], + clearCache: undefined, + escape: undefined, + parse: undefined, + render: undefined, + Scanner: undefined, + Context: undefined, + Writer: undefined, + /** + * Allows a user to override the default caching strategy, by providing an + * object with set, get and clear methods. This can also be used to disable + * the cache by setting it to the literal `undefined`. + */ + set templateCache (cache) { + defaultWriter.templateCache = cache; + }, + /** + * Gets the default or overridden caching object from the default writer. + */ + get templateCache () { + return defaultWriter.templateCache; + } +}; + +// All high-level mustache.* functions use this writer. +var defaultWriter = new Writer(); + +/** + * Clears all cached templates in the default writer. + */ +mustache.clearCache = function clearCache () { + return defaultWriter.clearCache(); +}; + +/** + * Parses and caches the given template in the default writer and returns the + * array of tokens it contains. Doing this ahead of time avoids the need to + * parse templates on the fly as they are rendered. + */ +mustache.parse = function parse (template, tags) { + return defaultWriter.parse(template, tags); +}; + +/** + * Renders the `template` with the given `view`, `partials`, and `config` + * using the default writer. + */ +mustache.render = function render (template, view, partials, config) { + if (typeof template !== 'string') { + throw new TypeError('Invalid template! Template should be a "string" ' + + 'but "' + typeStr(template) + '" was given as the first ' + + 'argument for mustache#render(template, view, partials)'); + } + + return defaultWriter.render(template, view, partials, config); +}; + +// Export the escaping function so that the user may override it. +// See https://github.com/janl/mustache.js/issues/244 +mustache.escape = escapeHtml; + +// Export these mainly for testing, but also for advanced usage. +mustache.Scanner = Scanner; +mustache.Context = Context; +mustache.Writer = Writer; + +export default mustache; diff --git a/tests/corpus/9645-mustache-templates/node_modules/mustache/package.json b/tests/corpus/9645-mustache-templates/node_modules/mustache/package.json new file mode 100644 index 00000000000..81ce69d7e0d --- /dev/null +++ b/tests/corpus/9645-mustache-templates/node_modules/mustache/package.json @@ -0,0 +1,67 @@ +{ + "name": "mustache", + "version": "4.2.0", + "description": "Logic-less {{mustache}} templates with JavaScript", + "author": "mustache.js Authors ", + "homepage": "https://github.com/janl/mustache.js", + "repository": { + "type": "git", + "url": "https://github.com/janl/mustache.js.git" + }, + "keywords": [ + "mustache", + "template", + "templates", + "ejs" + ], + "main": "mustache.js", + "bin": { + "mustache": "./bin/mustache" + }, + "files": [ + "bin/", + "mustache.mjs", + "mustache.min.js", + "wrappers/" + ], + "exports": { + ".": { + "import": "./mustache.mjs", + "require": "./mustache.js" + }, + "./*": "./*" + }, + "volo": { + "url": "https://raw.github.com/janl/mustache.js/{version}/mustache.js" + }, + "scripts": { + "build": "cp mustache.js mustache.mjs && rollup mustache.mjs --file mustache.js --format umd --name Mustache && uglifyjs mustache.js > mustache.min.js", + "test": "npm run test-lint && npm run test-unit", + "test-lint": "eslint mustache.js bin/mustache test/**/*.js", + "test-unit": "mocha --reporter spec test/*-test.js", + "test-render": "mocha --reporter spec test/render-test", + "pre-test-browser": "node test/create-browser-suite.js", + "test-browser": "npm run pre-test-browser && zuul -- test/context-test.js test/parse-test.js test/scanner-test.js test/render-test-browser.js", + "test-browser-local": "npm run pre-test-browser && zuul --local 8080 -- test/context-test.js test/scanner-test.js test/parse-test.js test/render-test-browser.js", + "postversion": "scripts/bump-version-in-source", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "chai": "^3.4.0", + "eslint": "^6.5.1", + "esm": "^3.2.25", + "jshint": "^2.9.5", + "mocha": "^3.0.2", + "puppeteer": "^2.0.0", + "rollup": "^1.26.3", + "uglify-js": "^3.4.6", + "zuul": "^3.11.0", + "zuul-ngrok": "nolanlawson/zuul-ngrok#patch-1" + }, + "greenkeeper": { + "ignore": [ + "eslint" + ] + }, + "license": "MIT" +} diff --git a/tests/diagnostics/stale-undefined-inferred-return.ts b/tests/diagnostics/stale-undefined-inferred-return.ts new file mode 100644 index 00000000000..fa0d74c005d --- /dev/null +++ b/tests/diagnostics/stale-undefined-inferred-return.ts @@ -0,0 +1,35 @@ +// TypeScript types a read by the last assignment in the read's own function +// and keeps that across a call, so each of these functions is inferred to +// return `undefined` although `search()` assigned `found` since. Returning +// nothing would drop the value, and the callers' types come from the same +// inference: the return needs a declared type. +let found: string | undefined = undefined; +function search(): void { + found = "hit"; +} +function result() { + found = undefined; + search(); + return found; +} +function either(first: boolean) { + found = undefined; + search(); + return first ? found : undefined; +} +class Finder { + hit: string | undefined = undefined; + look(): void { + this.hit = "hit"; + } + result() { + this.hit = undefined; + this.look(); + return this.hit; + } +} +result(); +either(true); +new Finder().result(); +search(); +(() => found)(); diff --git a/tests/harness/__snapshots__/stale-undefined-inferred-return.ts.txt b/tests/harness/__snapshots__/stale-undefined-inferred-return.ts.txt new file mode 100644 index 00000000000..13714c87fb0 --- /dev/null +++ b/tests/harness/__snapshots__/stale-undefined-inferred-return.ts.txt @@ -0,0 +1,35 @@ +stale-undefined-inferred-return.ts:13:10 - error SC1090: returning 'found' from a function whose return type TypeScript infers as 'undefined' (it types the read by the last assignment in this function, and a call made since can have assigned it from another function) is not supported yet + + 12 | search(); + 13 | return found; + | ^~~~~ + 14 | } + + hint: declare the function's return type, so the result is the value stored at the return + +stale-undefined-inferred-return.ts:18:10 - error SC1090: returning 'found' from a function whose return type TypeScript infers as 'undefined' (it types the read by the last assignment in this function, and a call made since can have assigned it from another function) is not supported yet + + 17 | search(); + 18 | return first ? found : undefined; + | ^~~~~~~~~~~~~~~~~~~~~~~~~ + 19 | } + + hint: declare the function's return type, so the result is the value stored at the return + +stale-undefined-inferred-return.ts:28:12 - error SC1090: returning 'this.hit' from a function whose return type TypeScript infers as 'undefined' (it types the read by the last assignment in this function, and a call made since can have assigned it from another function) is not supported yet + + 27 | this.look(); + 28 | return this.hit; + | ^~~~~~~~ + 29 | } + + hint: declare the function's return type, so the result is the value stored at the return + +stale-undefined-inferred-return.ts:35:8 - error SC1090: returning 'found' from a function whose return type TypeScript infers as 'undefined' (it types the read by the last assignment in this function, and a call made since can have assigned it from another function) is not supported yet + + 34 | search(); + 35 | (() => found)(); + | ^~~~~ + 36 | + + hint: declare the function's return type, so the result is the value stored at the return \ No newline at end of file diff --git a/tests/harness/corpus-costs/9641-callee-assigned-reads.mjs.json b/tests/harness/corpus-costs/9641-callee-assigned-reads.mjs.json new file mode 100644 index 00000000000..dda2ef21cb8 --- /dev/null +++ b/tests/harness/corpus-costs/9641-callee-assigned-reads.mjs.json @@ -0,0 +1 @@ +{ "plain": 3800, "san": 4500 } diff --git a/tests/harness/corpus-costs/9642-callee-assigned-reads-commonjs.cjs.json b/tests/harness/corpus-costs/9642-callee-assigned-reads-commonjs.cjs.json new file mode 100644 index 00000000000..97b8950e002 --- /dev/null +++ b/tests/harness/corpus-costs/9642-callee-assigned-reads-commonjs.cjs.json @@ -0,0 +1 @@ +{ "plain": 1900, "san": 4500 } diff --git a/tests/harness/corpus-costs/9643-callee-assigned-reads-typed.ts.json b/tests/harness/corpus-costs/9643-callee-assigned-reads-typed.ts.json new file mode 100644 index 00000000000..4203ad9793f --- /dev/null +++ b/tests/harness/corpus-costs/9643-callee-assigned-reads-typed.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 1900 } diff --git a/tests/harness/corpus-costs/9645-mustache-templates/main.mjs.json b/tests/harness/corpus-costs/9645-mustache-templates/main.mjs.json new file mode 100644 index 00000000000..d91726f3202 --- /dev/null +++ b/tests/harness/corpus-costs/9645-mustache-templates/main.mjs.json @@ -0,0 +1 @@ +{ "plain": 4100, "san": 4300 } diff --git a/tests/harness/island-conformance/known-failures.json b/tests/harness/island-conformance/known-failures.json index 47004b48bd6..9981f0341bc 100644 --- a/tests/harness/island-conformance/known-failures.json +++ b/tests/harness/island-conformance/known-failures.json @@ -123,6 +123,12 @@ "signature": "SC8006" } }, + "corpus/9645-mustache-templates": { + "island:mustache": { + "category": "exit", + "signature": "exit 1, Node 0: Uncaught Error: JSON.stringify of a runtime handle is not supported yet" + } + }, "corpus/fs-extra-package": { "island:fs-extra": { "category": "build", From 55a27b7d721c396912e3b204622df5cf58f0134a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 7 Nov 2026 02:41:25 -0800 Subject: [PATCH 02/11] Carry the stored value through an untyped copy of such a read `let y = x` after a call that may have assigned `x`: TypeScript infers the copy's own type from the narrowing, so `y` is "nothing" to it as well, and `s.slice(y)` sliced from 0 in JavaScript (in TypeScript the declaration was refused). A binding without a declared type that the checker inferred as nothing from restored reads now has those reads' declared type, at its declaration and at every read. The question "is this read nothing to the checker?" is now asked of the type itself, which is the one the lowering asks for next from the checker's per-node memo, instead of a write census that an initialized copy never appears in. The mustache program prints its tokens by element, so its Node island way matches Node too and needs no entry in the conformance lane's list. Co-Authored-By: Claude Code --- .../lowering/narrowing-stability.test.ts | 38 ++++++++++-- .../frontend/lowering/narrowing-stability.ts | 60 +++++++++++++++++-- tests/corpus/9641-callee-assigned-reads.mjs | 21 +++++++ .../9643-callee-assigned-reads-typed.ts | 5 ++ tests/corpus/9645-mustache-templates/main.mjs | 7 ++- .../9641-callee-assigned-reads.mjs.json | 2 +- ...42-callee-assigned-reads-commonjs.cjs.json | 2 +- .../9643-callee-assigned-reads-typed.ts.json | 2 +- .../9645-mustache-templates/main.mjs.json | 2 +- .../island-conformance/known-failures.json | 6 -- 10 files changed, 121 insertions(+), 24 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts index d6fdd9e6c85..6c017f6a6ce 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts @@ -154,17 +154,27 @@ formatted({}, []); expect(thrownMessages(module, "formatted")).toEqual([]); }); -/** Whether a function's body reads the module-level binding `name`. */ -function readsGlobal(module: IrModule, fn: string, name: string): boolean { +/** Whether a function's body reads a binding whose id `matches`. */ +function reads(module: IrModule, fn: string, matches: (id: string) => boolean): boolean { const found = module.functions.find((f) => f.name === fn || f.name.endsWith(`.${fn}`)); if (!found) throw new Error(`no function ${fn}`); - let reads = false; + let seen = false; JSON.stringify(found.body, (_key, value: unknown) => { const node = value as { kind?: string; localId?: string }; - if (node?.kind === "varRef" && node.localId?.endsWith(`.${name}`)) reads = true; + if (node?.kind === "varRef" && node.localId !== undefined && matches(node.localId)) seen = true; return value; }); - return reads; + return seen; +} + +/** Whether a function's body reads the module-level binding `name`. */ +function readsGlobal(module: IrModule, fn: string, name: string): boolean { + return reads(module, fn, (id) => id.endsWith(`.${name}`)); +} + +/** Whether a function's body reads its local `name`. */ +function readsLocal(module: IrModule, fn: string, name: string): boolean { + return reads(module, fn, (id) => id.startsWith(`${name}.`)); } test("a narrowing to undefined that a call may have undone reads the stored value", async () => { @@ -209,6 +219,24 @@ console.log("x".padStart(3, text)); expect(readsGlobal(module, init, "other")).toBe(false); }); +test("an untyped binding initialized from such a read holds the stored value", async () => { + // The checker infers `copy: undefined` from the narrowing of `start`. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale(): string { + start = undefined; + set(); + const copy = start; + return "abcd".slice(copy); +} +console.log(stale()); +`); + expect(readsLocal(module, "stale", "copy")).toBe(true); +}); + test("a function inferred to return undefined from a stale narrowing is refused", async () => { const source = (returnType: string): string => ` let found: string | undefined = undefined; diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index 0866723fe30..66a3ddf5242 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -50,6 +50,7 @@ export class NarrowingStability { private readonly effects = new Map(); private readonly capturedMutable = new Map(); private readonly staleBindings = new Map(); + private readonly inherited = new Map(); private readonly identifierWrites = new Map>(); private readonly widened = new Map(); private readonly plainContainers = new Map(); @@ -156,6 +157,15 @@ export class NarrowingStability { declared = this.declaredPropertyType(node); } else { const symbol = this.bindingOf(node); + // `let y = x` with `x` such a read: the checker inferred the binding's + // own type from the narrowing, so `y` is nothing to it as well, and it + // holds what `x` held. + if ( + symbol && + isNothing(checker.getTypeAtLocation(node)) && + isNothing(checker.getTypeOfSymbol(symbol)) + ) + return this.inheritedType(symbol); declared = symbol && this.mayHoldStaleNarrowing(symbol) ? checker.getTypeOfSymbol(symbol) : null; } @@ -225,16 +235,54 @@ export class NarrowingStability { /** Whether the checker types this read as nothing but `undefined` or * `null`. Asked for every identifier and property read outside the wide - * positions, so the write census answers by name first: a binding no code - * in its file assigns keeps whatever narrowing it has. */ + * positions; the type is the one `Lowerer.typeOf` asks for next, from + * the checker's per-node memo. */ private mayReadNothing(node: ts.PropertyAccessExpression | ts.Identifier): boolean { - if (ts.isIdentifier(node)) { - if (!this.writesIn(ts.getSourceFileOfNode(node)).has(node.text)) return false; - if (!isValueIdentifier(node)) return false; - } + if (ts.isIdentifier(node) && !isValueIdentifier(node)) return false; return isNothing(this.lowerer.checker.getTypeAtLocation(node)); } + /** The type an untyped binding holds when the checker inferred "nothing" + * for it from reads widenedType restored: the declared type of those + * reads (`let y = x` after a call that may have assigned `x`), `any` + * when they disagree. Null when no such read initializes or assigns it. */ + private inheritedType(symbol: ts.Symbol): ts.Type | null { + const cached = this.inherited.get(symbol); + if (cached !== undefined) return cached; + // A cycle of bindings assigned from each other contributes nothing. + this.inherited.set(symbol, null); + let result: ts.Type | null = null; + try { + const decl = symbol.valueDeclaration; + if (decl !== undefined && ts.isVariableDeclaration(decl) && decl.type === undefined) { + const sources: ts.Expression[] = []; + if (decl.initializer !== undefined) sources.push(decl.initializer); + for (const write of this.writesIn(ts.getSourceFileOfNode(decl)).get( + ts.symbolName(symbol), + ) ?? []) { + const assignment = write.parent; + if ( + ts.isBinaryExpression(assignment) && + assignment.left === write && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken && + this.lowerer.resolveValueSymbol(write) === symbol + ) + sources.push(assignment.right); + } + for (const source of sources) { + const read = this.restoredNothingRead(source); + const restored = read === null ? null : this.widenedType(read); + if (restored === null) continue; + result = + result === null || result === restored ? restored : this.lowerer.checker.getAnyType(); + } + } + } finally { + this.inherited.set(symbol, result); + } + return result; + } + /** A read in a call or construction argument (directly or inside a literal * argument) reaches the callee's own lowering with the declared type. User * callees coerce any value of their parameter type, and so do the diff --git a/tests/corpus/9641-callee-assigned-reads.mjs b/tests/corpus/9641-callee-assigned-reads.mjs index f45323af4fa..da0fa792149 100644 --- a/tests/corpus/9641-callee-assigned-reads.mjs +++ b/tests/corpus/9641-callee-assigned-reads.mjs @@ -53,6 +53,9 @@ console.log("strings:", "abcd".slice(count), "abab".indexOf("b", count), "x".pad console.log("split, join:", JSON.stringify("a-b".split(separator)), [1, 2].join(separator)); console.log("radix:", parseInt("11", radix), (5).toString(radix), JSON.stringify({ a: 1 }, null, count)); console.log("arithmetic:", count + 1, count * 2, count > 1, [5, 6, 7][count]); +console.log("positions:", "abcdef".slice(1, count), "abcd".substring(count), "abcd".charAt(count), "abcd".at(count)); +console.log("searches:", "abab".startsWith("ab", count), "abab".lastIndexOf("ab", count), [1, 2, 1].indexOf(1, count)); +console.log("strings:", "ab".repeat(count), "x".concat(text), String.fromCharCode(count + 63), String(count).padEnd(3, text)); // The shape of an old-style parser: `var`s of a function that a nested // function assigns, handed to methods of a constructor function's instance. @@ -162,6 +165,24 @@ function assigners() { } console.log("assigners:", assigners()); +// A copy of such a binding holds the value too: TypeScript infers the +// copy's type from the same narrowing. +function copies() { + var first, size; + function fill() { + first = "first"; + size = 2; + } + fill(); + var copy = size; + var again = copy; + var either = again ? copy : undefined; + var later; + later = first; + return ["abcd".slice(copy), "abcd".slice(again), "abcd".slice(either), holder.show(later), show(again)].join(" | "); +} +console.log("copies:", copies()); + // A function called where it stands is part of the enclosing flow: its // assignment types the read, and stays the value. function inPlace() { diff --git a/tests/corpus/9643-callee-assigned-reads-typed.ts b/tests/corpus/9643-callee-assigned-reads-typed.ts index 1d719d18c8e..f9c7acdab7e 100644 --- a/tests/corpus/9643-callee-assigned-reads-typed.ts +++ b/tests/corpus/9643-callee-assigned-reads-typed.ts @@ -25,6 +25,11 @@ console.log("json:", JSON.stringify({ a: 1 }, null, start), JSON.stringify({ a: console.log("values:", show(start), show(filler), show(label), String(start), `${filler}${start}`); console.log("tests:", start === undefined, filler ?? "absent", label || "empty", start ? "set" : "unset", typeof label); console.log("parenthesized:", "abcdef".slice((start)), [1, 2].join((filler))); +console.log("ends:", "abcdef".slice(1, start), [1, 2, 3, 4].slice(1, start).join(), [1, 2, 1].indexOf(1, start)); +console.log("more:", "abab".startsWith("ab", start), (1.5).toExponential(start), [1, 2, 3, 4].fill(0, start).join()); +const copy = start; +const fillerCopy = filler; +console.log("copies:", "abcdef".slice(copy), [1, 2].join(fillerCopy), show(copy), show(fillerCopy)); // Locals of a function, assigned by the callback of a library function. function widest(words: string[]): string { diff --git a/tests/corpus/9645-mustache-templates/main.mjs b/tests/corpus/9645-mustache-templates/main.mjs index 0fd9a1a1699..e1e8c4e3daa 100644 --- a/tests/corpus/9645-mustache-templates/main.mjs +++ b/tests/corpus/9645-mustache-templates/main.mjs @@ -24,7 +24,7 @@ const view = { off: false, }; -console.log(JSON.stringify(Mustache.render("plain", {}))); +console.log("[" + Mustache.render("plain", {}) + "]"); console.log(Mustache.render("hi {{who}}", { who: "ada" })); console.log(Mustache.render("{{title}} spends {{calc}}", view)); console.log(Mustache.render("{{#items}}{{name}}={{price}};{{/items}}{{^empty}}none{{/empty}}", view)); @@ -35,9 +35,10 @@ console.log(Mustache.render("{{#items}}{{> row}}{{/items}}", view, { row: "[{{na console.log(Mustache.render("<% title %> {{title}}", view, {}, ["<%", "%>"])); console.log(Mustache.render("{{=<% %>=}}<% title %> {{title}}", view)); console.log(Mustache.render("a\n {{#items}}\n - {{name}}\n {{/items}}\nz", view)); -console.log(JSON.stringify(Mustache.parse("hi {{who}}{{#a}}x{{/a}}"))); +const tokens = Mustache.parse("hi {{who}}{{#a}}x{{/a}}"); +console.log(tokens.length, tokens[0][0], tokens[0][1], tokens[1][0], tokens[1][1], tokens[1][3], tokens[2][0], tokens[2][5]); console.log(Mustache.escape("&\"`=/")); -console.log(Mustache.name, Mustache.version, JSON.stringify(Mustache.tags)); +console.log(Mustache.name, Mustache.version, Mustache.tags.join(" ")); try { Mustache.render("{{#open}}never closed", {}); } catch (e) { diff --git a/tests/harness/corpus-costs/9641-callee-assigned-reads.mjs.json b/tests/harness/corpus-costs/9641-callee-assigned-reads.mjs.json index dda2ef21cb8..6097618ae59 100644 --- a/tests/harness/corpus-costs/9641-callee-assigned-reads.mjs.json +++ b/tests/harness/corpus-costs/9641-callee-assigned-reads.mjs.json @@ -1 +1 @@ -{ "plain": 3800, "san": 4500 } +{ "plain": 6400, "san": 9600 } diff --git a/tests/harness/corpus-costs/9642-callee-assigned-reads-commonjs.cjs.json b/tests/harness/corpus-costs/9642-callee-assigned-reads-commonjs.cjs.json index 97b8950e002..f7767dbc162 100644 --- a/tests/harness/corpus-costs/9642-callee-assigned-reads-commonjs.cjs.json +++ b/tests/harness/corpus-costs/9642-callee-assigned-reads-commonjs.cjs.json @@ -1 +1 @@ -{ "plain": 1900, "san": 4500 } +{ "plain": 1700, "san": 1900 } diff --git a/tests/harness/corpus-costs/9643-callee-assigned-reads-typed.ts.json b/tests/harness/corpus-costs/9643-callee-assigned-reads-typed.ts.json index 4203ad9793f..1f8ff72b2c5 100644 --- a/tests/harness/corpus-costs/9643-callee-assigned-reads-typed.ts.json +++ b/tests/harness/corpus-costs/9643-callee-assigned-reads-typed.ts.json @@ -1 +1 @@ -{ "plain": 1800, "san": 1900 } +{ "plain": 2100, "san": 2000 } diff --git a/tests/harness/corpus-costs/9645-mustache-templates/main.mjs.json b/tests/harness/corpus-costs/9645-mustache-templates/main.mjs.json index d91726f3202..f0ed6cbc2a0 100644 --- a/tests/harness/corpus-costs/9645-mustache-templates/main.mjs.json +++ b/tests/harness/corpus-costs/9645-mustache-templates/main.mjs.json @@ -1 +1 @@ -{ "plain": 4100, "san": 4300 } +{ "plain": 4300, "san": 4400 } diff --git a/tests/harness/island-conformance/known-failures.json b/tests/harness/island-conformance/known-failures.json index 9981f0341bc..47004b48bd6 100644 --- a/tests/harness/island-conformance/known-failures.json +++ b/tests/harness/island-conformance/known-failures.json @@ -123,12 +123,6 @@ "signature": "SC8006" } }, - "corpus/9645-mustache-templates": { - "island:mustache": { - "category": "exit", - "signature": "exit 1, Node 0: Uncaught Error: JSON.stringify of a runtime handle is not supported yet" - } - }, "corpus/fs-extra-package": { "island:fs-extra": { "category": "build", From 232d24f2b99b4364867852319399013b4fd2d63e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 7 Nov 2026 03:05:11 -0800 Subject: [PATCH 03/11] List the stability analysis's inherited-type memo among the lowerer's memos Co-Authored-By: Claude Code --- packages/compiler/src/frontend/lowering/lowerer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 8ec425435b8..d6fba5741c6 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -5345,6 +5345,7 @@ export class Lowerer { "narrowing.effects", "narrowing.capturedMutable", "narrowing.staleBindings", + "narrowing.inherited", "narrowing.identifierWrites", "narrowing.widened", "narrowing.plainContainers", From eac148eec832b9aa5b2ab836b5ac1e9223add57b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 7 Nov 2026 05:04:49 -0800 Subject: [PATCH 04/11] End an untyped copy's inherited value at its own reassignment `let copy = x; copy = undefined; return copy`: the copy inherited the declared type of the read it was initialized from at every read, also after an assignment of its own, so a function returning the reset copy was refused (SC1090) although it returns nothing, as TypeScript infers. A read of the copy now keeps the checker's type when the last initializer or assignment that completes before it stores that same nothing from a value that is not stale, is a statement of a block around the read (every path runs it), and no stale one can run after it. A reset on one path only, a reset followed by the read again, and in JavaScript `copy = null` or `copy = "text"` into a binding the checker keeps typing `undefined`, still read the stored value. Co-Authored-By: Claude Code --- .../compiler/src/frontend/lowering/lowerer.ts | 1 + .../lowering/narrowing-stability.test.ts | 26 ++++ .../frontend/lowering/narrowing-stability.ts | 130 +++++++++++++++--- tests/corpus/9641-callee-assigned-reads.mjs | 46 +++++++ .../9643-callee-assigned-reads-typed.ts | 29 ++++ 5 files changed, 210 insertions(+), 22 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 378723a7192..209dad2fdbb 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -5347,6 +5347,7 @@ export class Lowerer { "narrowing.capturedMutable", "narrowing.staleBindings", "narrowing.inherited", + "narrowing.sources", "narrowing.identifierWrites", "narrowing.widened", "narrowing.plainContainers", diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts index 6c017f6a6ce..1a639f733b3 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts @@ -237,6 +237,32 @@ console.log(stale()); expect(readsLocal(module, "stale", "copy")).toBe(true); }); +test("an assignment of the copy's own ends what its initializer left in it", async () => { + const source = (reset: string): string => ` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function copied(clear: boolean) { + start = undefined; + set(); + let copy = start; + ${reset} + return copy; +} +copied(true); +`; + // Every path to the return runs the reset: the copy is undefined there, + // and the function returns nothing, as the checker infers. + await lower(source("copy = undefined;")); + // One path keeps the initializer's value, and so does a reset followed + // by the read again. + const refusal = /SC1090: returning 'copy' from a function whose return type TypeScript infers/; + await expect(lower(source("if (clear) copy = undefined;"))).rejects.toThrow(refusal); + await expect(lower(source("copy = undefined; copy = start;"))).rejects.toThrow(refusal); + await expect(lower(source(""))).rejects.toThrow(refusal); +}); + test("a function inferred to return undefined from a stale narrowing is refused", async () => { const source = (returnType: string): string => ` let found: string | undefined = undefined; diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index 66a3ddf5242..93d42eeff23 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -51,6 +51,7 @@ export class NarrowingStability { private readonly capturedMutable = new Map(); private readonly staleBindings = new Map(); private readonly inherited = new Map(); + private readonly sources = new Map(); private readonly identifierWrites = new Map>(); private readonly widened = new Map(); private readonly plainContainers = new Map(); @@ -160,12 +161,14 @@ export class NarrowingStability { // `let y = x` with `x` such a read: the checker inferred the binding's // own type from the narrowing, so `y` is nothing to it as well, and it // holds what `x` held. - if ( - symbol && - isNothing(checker.getTypeAtLocation(node)) && - isNothing(checker.getTypeOfSymbol(symbol)) - ) - return this.inheritedType(symbol); + if (symbol && isNothing(checker.getTypeAtLocation(node))) { + // An assignment of the binding's own that every path to this read + // runs last (`y = undefined; return y`) ends what an earlier + // initializer or assignment left in it: the checker's "nothing" is + // that assignment's. + if (this.resetBefore(node, symbol)) return null; + if (isNothing(checker.getTypeOfSymbol(symbol))) return this.inheritedType(symbol); + } declared = symbol && this.mayHoldStaleNarrowing(symbol) ? checker.getTypeOfSymbol(symbol) : null; } @@ -255,22 +258,8 @@ export class NarrowingStability { try { const decl = symbol.valueDeclaration; if (decl !== undefined && ts.isVariableDeclaration(decl) && decl.type === undefined) { - const sources: ts.Expression[] = []; - if (decl.initializer !== undefined) sources.push(decl.initializer); - for (const write of this.writesIn(ts.getSourceFileOfNode(decl)).get( - ts.symbolName(symbol), - ) ?? []) { - const assignment = write.parent; - if ( - ts.isBinaryExpression(assignment) && - assignment.left === write && - assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken && - this.lowerer.resolveValueSymbol(write) === symbol - ) - sources.push(assignment.right); - } - for (const source of sources) { - const read = this.restoredNothingRead(source); + for (const source of this.sourcesOf(symbol)) { + const read = this.restoredNothingRead(source.value); const restored = read === null ? null : this.widenedType(read); if (restored === null) continue; result = @@ -283,6 +272,68 @@ export class NarrowingStability { return result; } + /** The initializer and the `=` assignments of a variable, each with + * whether its value can be a read whose narrowing may be stale. */ + private sourcesOf(symbol: ts.Symbol): BindingSource[] { + const cached = this.sources.get(symbol); + if (cached !== undefined) return cached; + // A cycle of bindings assigned from each other sees the sources found + // so far. + const found: BindingSource[] = []; + this.sources.set(symbol, found); + const decl = symbol.valueDeclaration; + if (decl === undefined || !ts.isVariableDeclaration(decl)) return found; + const add = (at: ts.Node, value: ts.Expression): void => { + found.push({ + at, + value, + stale: this.mayBeStale(value) || this.restoredNothingRead(value) !== null, + }); + }; + if (decl.initializer !== undefined) add(decl, decl.initializer); + for (const write of this.writesIn(ts.getSourceFileOfNode(decl)).get(ts.symbolName(symbol)) ?? + []) { + const assignment = write.parent; + if ( + ts.isBinaryExpression(assignment) && + assignment.left === write && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken && + this.lowerer.resolveValueSymbol(write) === symbol + ) + add(assignment, assignment.right); + } + return found; + } + + /** The last initializer or assignment of the binding that completes + * before `read` stores the nothing the checker types the read as, from a + * value that is not stale; every path to the read runs it (it is a + * statement of a block around the read); and no stale one can run after + * it: none follows it in the text, and no other function assigns the + * binding. The read then holds what that assignment stored. */ + private resetBefore(read: ts.Identifier, symbol: ts.Symbol): boolean { + if (this.isCapturedMutable(symbol)) return false; + const sources = this.sourcesOf(symbol); + const start = ts.tokenStartOfNode(read); + let last: BindingSource | null = null; + for (const source of sources) { + if (source.at.end > start) continue; + if (last === null || source.at.pos > last.at.pos) last = source; + } + if (last === null || last.stale) return false; + // The checker's type at the read is that assignment's only when the + // assignment stored the same nothing. A JavaScript binding whose + // inferred type is `undefined` keeps that type after `y = null` or + // `y = "text"`, which the checker does not report there. + const checker = this.lowerer.checker; + const stored = checker.getTypeAtLocation(last.value); + if (!isNothing(stored) || nothingFlags(stored) !== nothingFlags(checker.getTypeAtLocation(read))) + return false; + if (flowBoundary(last.at) !== flowBoundary(read)) return false; + if (!runsBefore(last.at, read)) return false; + return !sources.some((source) => source.stale && source.at.pos > last.at.pos); + } + /** A read in a call or construction argument (directly or inside a literal * argument) reaches the callee's own lowering with the declared type. User * callees coerce any value of their parameter type, and so do the @@ -1026,6 +1077,41 @@ const INERT_CONSTRUCTORS = new Set([ "BigUint64Array", ]); +/** An initializer (`at` is the declaration) or an `=` assignment (`at` is + * the assignment) of a variable. */ +interface BindingSource { + at: ts.Node; + value: ts.Expression; + stale: boolean; +} + +/** Whether a declaration or assignment is a statement of a block that + * contains `node`, so every path to `node` through that block has run it. + * (A `case` clause is not such a block: another clause can fall into it.) */ +function runsBefore(at: ts.Node, node: ts.Node): boolean { + let statement: ts.Node | undefined = at.parent; + if (ts.isVariableDeclaration(at)) { + if (statement === undefined || !ts.isVariableDeclarationList(statement)) return false; + statement = statement.parent; + if (statement === undefined || !ts.isVariableStatement(statement)) return false; + } else if (statement === undefined || !ts.isExpressionStatement(statement)) return false; + const block = statement.parent; + if (block === undefined || !(ts.isBlock(block) || ts.isSourceFile(block))) return false; + for (let n: ts.Node | undefined = node.parent; n !== undefined; n = n.parent) { + if (n === block) return true; + } + return false; +} + +/** Which of `undefined`, `null` and `void` a type includes. */ +function nothingFlags(type: ts.Type): number { + const nothing = ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void; + if (!type.isUnionType()) return type.flags & nothing; + let flags = 0; + for (const part of ts.constituentTypes(type)) flags |= part.flags & nothing; + return flags; +} + /** `undefined`, `null`, `void`, or a union of only those. */ function isNothing(type: ts.Type): boolean { const nothing = ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void; diff --git a/tests/corpus/9641-callee-assigned-reads.mjs b/tests/corpus/9641-callee-assigned-reads.mjs index da0fa792149..bc777359a2f 100644 --- a/tests/corpus/9641-callee-assigned-reads.mjs +++ b/tests/corpus/9641-callee-assigned-reads.mjs @@ -183,6 +183,52 @@ function copies() { } console.log("copies:", copies()); +// An assignment of the copy's own ends what its initializer left in it: +// `undefined`, `null`, a value of another type; a reset on one path only +// does not, and neither does a reset followed by the read again. +function resets() { + var value, size; + function fill() { + value = "filled"; + size = 2; + } + fill(); + var a = value; + a = undefined; + var b = value; + b = null; + var c = size; + c = "text"; + var d = size; + if (size > 5) d = undefined; + var e = value; + e = undefined; + e = value; + return [holder.show(a), "abcd".slice(a), holder.show(b), holder.show(c), "abcd".slice(d), holder.show(d), holder.show(e)].join(" | "); +} +function resetReturned() { + var value; + function fill() { + value = "filled"; + } + fill(); + var copy = value; + copy = undefined; + return copy; +} +function nullReturned() { + var value; + function fill() { + value = "filled"; + } + fill(); + var copy = value; + copy = null; + return copy; +} +console.log("resets:", resets()); +console.log("reset and returned:", show(resetReturned()), show(nullReturned())); + // A function called where it stands is part of the enclosing flow: its // assignment types the read, and stays the value. function inPlace() { diff --git a/tests/corpus/9643-callee-assigned-reads-typed.ts b/tests/corpus/9643-callee-assigned-reads-typed.ts index f9c7acdab7e..15388586a5a 100644 --- a/tests/corpus/9643-callee-assigned-reads-typed.ts +++ b/tests/corpus/9643-callee-assigned-reads-typed.ts @@ -141,6 +141,35 @@ function kept(): string { console.log("returned:", show(found(["apple", "banana"], "b")), "/", show(found(["apple"], "b"))); console.log("kept:", kept()); +// An untyped copy that is assigned `undefined` afterwards holds that: the +// assignment ends what the initializer left in it, and a function that +// returns the reset copy returns nothing, as TypeScript infers. +function resetCopy(): string { + let from: number | undefined = undefined; + const set = (): void => { + from = 2; + }; + set(); + let copy = from; + const kept = "abcd".slice(copy); + copy = undefined; + return kept + " " + "abcd".slice(copy) + " " + show(copy); +} +function resetAndReturn() { + let found; + const search = (): void => { + found = "hit"; + }; + search(); + let copy = found; + const kept = show(copy); + copy = undefined; + console.log("before the reset:", kept); + return copy; +} +resetAndReturn(); +console.log("reset copy:", resetCopy()); + // A function expression called where it stands is part of the flow. function inPlace(): string { let size: number | undefined = undefined; From d18b7a4b80cadacb9c18c11d5a91534035a9b066 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 7 Nov 2026 07:05:12 -0600 Subject: [PATCH 05/11] Address review feedback --- .../lowering/narrowing-stability.test.ts | 55 +++++++ .../frontend/lowering/narrowing-stability.ts | 154 ++++++++++++++---- 2 files changed, 175 insertions(+), 34 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts index 6c017f6a6ce..75d0e773b50 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts @@ -256,3 +256,58 @@ console.log(result()); const module = await lower(source(": string | undefined")); expect(readsGlobal(module, "result", "found")).toBe(true); }); + +/** The value a function's `return` statement carries: `"absent"` for a + * bare return, `"present"` otherwise. */ +function returnValue(module: IrModule, fn: string): string { + const found = module.functions.find((f) => f.name === fn || f.name.endsWith(`.${fn}`)); + if (!found) throw new Error(`no function ${fn}`); + let value = "none"; + JSON.stringify(found.body, (_key, node: unknown) => { + const stmt = node as { kind?: string; value?: unknown }; + if (stmt?.kind === "return") value = stmt.value === null ? "absent" : "present"; + return node; + }); + return value; +} + +test("an untyped copy the function resets to undefined reads as absent", async () => { + // The checker infers `copy: undefined` from the narrowing of `start`, but + // `copy = undefined` is the write the return sees: there is nothing stale + // left to carry, so the return is absent rather than refused. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale() { + start = undefined; + set(); + let copy = start; + copy = undefined; + return copy; +} +stale(); +`); + expect(returnValue(module, "stale")).toBe("absent"); +}); + +test("an untyped copy assigned the stale read again after a reset is refused", async () => { + await expect( + lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale() { + start = undefined; + set(); + let copy = start; + copy = undefined; + copy = start; + return copy; +} +stale(); +`), + ).rejects.toThrow(/SC1090: returning 'copy'/); +}); diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index 66a3ddf5242..8feba6ebc2d 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -50,7 +50,9 @@ export class NarrowingStability { private readonly effects = new Map(); private readonly capturedMutable = new Map(); private readonly staleBindings = new Map(); - private readonly inherited = new Map(); + /** Bindings whose inherited type is being computed: a cycle of bindings + * assigned from each other contributes nothing. */ + private readonly inherited = new Set(); private readonly identifierWrites = new Map>(); private readonly widened = new Map(); private readonly plainContainers = new Map(); @@ -165,7 +167,7 @@ export class NarrowingStability { isNothing(checker.getTypeAtLocation(node)) && isNothing(checker.getTypeOfSymbol(symbol)) ) - return this.inheritedType(symbol); + return this.inheritedType(symbol, node); declared = symbol && this.mayHoldStaleNarrowing(symbol) ? checker.getTypeOfSymbol(symbol) : null; } @@ -243,44 +245,59 @@ export class NarrowingStability { } /** The type an untyped binding holds when the checker inferred "nothing" - * for it from reads widenedType restored: the declared type of those - * reads (`let y = x` after a call that may have assigned `x`), `any` - * when they disagree. Null when no such read initializes or assigns it. */ - private inheritedType(symbol: ts.Symbol): ts.Type | null { - const cached = this.inherited.get(symbol); - if (cached !== undefined) return cached; + * for it from reads widenedType restored: the declared type of the write + * that reaches `node` (`let y = x` after a call that may have assigned + * `x`), `any` when the writes no position relates to the read disagree. + * Null when the write that reaches the read is not one of those reads: + * an explicit `y = undefined` after the initializer leaves nothing to + * inherit, and the read is genuinely absent. */ + private inheritedType(symbol: ts.Symbol, node: ts.Identifier): ts.Type | null { // A cycle of bindings assigned from each other contributes nothing. - this.inherited.set(symbol, null); - let result: ts.Type | null = null; + if (this.inherited.has(symbol)) return null; + this.inherited.add(symbol); try { const decl = symbol.valueDeclaration; - if (decl !== undefined && ts.isVariableDeclaration(decl) && decl.type === undefined) { - const sources: ts.Expression[] = []; - if (decl.initializer !== undefined) sources.push(decl.initializer); - for (const write of this.writesIn(ts.getSourceFileOfNode(decl)).get( - ts.symbolName(symbol), - ) ?? []) { - const assignment = write.parent; - if ( - ts.isBinaryExpression(assignment) && - assignment.left === write && - assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken && - this.lowerer.resolveValueSymbol(write) === symbol - ) - sources.push(assignment.right); - } - for (const source of sources) { - const read = this.restoredNothingRead(source); - const restored = read === null ? null : this.widenedType(read); - if (restored === null) continue; - result = - result === null || result === restored ? restored : this.lowerer.checker.getAnyType(); - } + if (decl === undefined || !ts.isVariableDeclaration(decl) || decl.type !== undefined) + return null; + const sources: { source: ts.Expression; definite: boolean }[] = []; + if (decl.initializer !== undefined) + sources.push({ source: decl.initializer, definite: runsBefore(decl, node) }); + for (const write of this.writesIn(ts.getSourceFileOfNode(decl)).get(ts.symbolName(symbol)) ?? + []) { + const assignment = write.parent; + if ( + ts.isBinaryExpression(assignment) && + assignment.left === write && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken && + this.lowerer.resolveValueSymbol(write) === symbol + ) + sources.push({ source: assignment.right, definite: runsBefore(write, node) }); } + // The latest write that certainly runs before the read is the value + // it sees; when that write stores nothing, the binding holds nothing. + let latest: ts.Expression | null = null; + for (const { source, definite } of sources) + if (definite && (latest === null || source.pos > latest.pos)) latest = source; + let result: ts.Type | null = null; + if (latest !== null) { + const read = this.restoredNothingRead(latest); + result = read === null ? null : this.widenedType(read); + } + // A write in a branch, loop body or another function that the read + // does not share may still have run since a call: it widens the + // inherited type, but only a restored read gives it one. + for (const { source, definite } of sources) { + if (definite) continue; + const read = this.restoredNothingRead(source); + const restored = read === null ? null : this.widenedType(read); + if (restored === null) continue; + result = + result === null || result === restored ? restored : this.lowerer.checker.getAnyType(); + } + return result; } finally { - this.inherited.set(symbol, result); + this.inherited.delete(symbol); } - return result; } /** A read in a call or construction argument (directly or inside a literal @@ -1207,6 +1224,75 @@ function isFlowBoundary(node: ts.Node): boolean { ); } +/** Whether `node` is `inner` or contains it. */ +function holds(node: ts.Node | undefined, inner: ts.Node): boolean { + if (node === undefined) return false; + for (let n: ts.Node | undefined = inner; n !== undefined; n = n.parent) + if (n === node) return true; + return false; +} + +/** Whether `write` certainly runs before `read` on every path that reaches + * it: both in one function, the write earlier in the text, and every + * conditional construct in between one the read is inside too. */ +function runsBefore(write: ts.Node, read: ts.Node): boolean { + if (flowBoundary(write) !== flowBoundary(read)) return false; + if (write.pos >= read.pos) return false; + for ( + let n: ts.Node | undefined = write.parent; + n !== undefined && !isFlowBoundary(n); + n = n.parent + ) + if (!sharesBranch(n, write, read)) return false; + return true; +} + +/** Whether the branch or iteration of `node` that holds `write` also holds + * `read`, or `node` is not conditional. A write in a branch the read does + * not share may not run at all before it. */ +function sharesBranch(node: ts.Node, write: ts.Node, read: ts.Node): boolean { + const same = (part: ts.Node | undefined): boolean => holds(part, write) && holds(part, read); + if (ts.isIfStatement(node)) { + if (holds(node.thenStatement, write)) return holds(node.thenStatement, read); + if (holds(node.elseStatement, write)) return holds(node.elseStatement, read); + // The condition always evaluates before either arm. + return holds(node.thenStatement, read) || holds(node.elseStatement, read); + } + if (ts.isConditionalExpression(node)) { + if (holds(node.whenTrue, write)) return holds(node.whenTrue, read); + if (holds(node.whenFalse, write)) return holds(node.whenFalse, read); + return holds(node.whenTrue, read) || holds(node.whenFalse, read); + } + if (ts.isForStatement(node)) { + // The initializer and the condition run before the body is entered. + if (holds(node.initializer, write) || holds(node.condition, write)) return true; + return same(node.statement); + } + if ( + ts.isForOfStatement(node) || + ts.isForInStatement(node) || + ts.isWhileStatement(node) || + ts.isDoStatement(node) + ) + return same(node.statement); + if (ts.isTryStatement(node)) { + // A finally block runs on every path out of the statement. + if (holds(node.finallyBlock, write)) return true; + if (holds(node.tryBlock, write)) return holds(node.tryBlock, read); + if (holds(node.catchClause, write)) return holds(node.catchClause, read); + return true; + } + if (ts.isSwitchStatement(node)) { + // The discriminant always evaluates; a clause runs only on its case. + if (holds(node.expression, write)) return true; + for (const clause of node.caseBlock.clauses.nodes) + if (holds(clause, write)) return holds(clause, read); + return false; + } + if (ts.isCaseClause(node) || ts.isDefaultClause(node)) return holds(node, read); + return true; +} + function skipOuter(node: ts.Expression): ts.Expression { let e = node; while ( From 5e03dd8d45fc629ef63fb3210c02b23ca1b369c6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 7 Nov 2026 05:14:21 -0800 Subject: [PATCH 06/11] Wrap a condition as oxfmt does Co-Authored-By: Claude Code --- .../compiler/src/frontend/lowering/narrowing-stability.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index 93d42eeff23..715ee59dea8 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -327,7 +327,10 @@ export class NarrowingStability { // `y = "text"`, which the checker does not report there. const checker = this.lowerer.checker; const stored = checker.getTypeAtLocation(last.value); - if (!isNothing(stored) || nothingFlags(stored) !== nothingFlags(checker.getTypeAtLocation(read))) + if ( + !isNothing(stored) || + nothingFlags(stored) !== nothingFlags(checker.getTypeAtLocation(read)) + ) return false; if (flowBoundary(last.at) !== flowBoundary(read)) return false; if (!runsBefore(last.at, read)) return false; From ffaa05095e745db0e48182150d8a239c93a7751d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 7 Nov 2026 07:44:55 -0600 Subject: [PATCH 07/11] Address review feedback --- .../lowering/narrowing-stability.test.ts | 43 +++++++++++++++++++ .../frontend/lowering/narrowing-stability.ts | 21 ++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts index 75d0e773b50..5a9972a7d91 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts @@ -292,6 +292,49 @@ stale(); expect(returnValue(module, "stale")).toBe("absent"); }); +test("a conditional assignment before the copy's reset does not reach its return", async () => { + // `if (flag) copy = start` may not run, and `copy = undefined` after it + // overwrites it when it does: the return still holds nothing. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +let flag = true; +function stale() { + start = undefined; + set(); + let copy = start; + if (flag) copy = start; + copy = undefined; + return copy; +} +stale(); +`); + expect(returnValue(module, "stale")).toBe("absent"); +}); + +test("a reset in the right operand of a logical operator does not end the copy's type", async () => { + // `flag && (...)`: the reset may be skipped, so the copy may still hold the + // stored value and the optional argument reads it. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +let flag = true; +function stale(): string { + start = undefined; + set(); + let copy = start; + flag && (copy = undefined); + return "abcd".slice(copy); +} +console.log(stale()); +`); + expect(readsLocal(module, "stale", "copy")).toBe(true); +}); + test("an untyped copy assigned the stale read again after a reset is refused", async () => { await expect( lower(` diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index 8feba6ebc2d..32fb0f3ae57 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -285,9 +285,13 @@ export class NarrowingStability { } // A write in a branch, loop body or another function that the read // does not share may still have run since a call: it widens the - // inherited type, but only a restored read gives it one. + // inherited type, but only a restored read gives it one. In this + // function, a write before the latest definite one is overwritten by + // it and cannot reach the read. + const definitePos = latest?.pos ?? -1; for (const { source, definite } of sources) { if (definite) continue; + if (flowBoundary(source) === flowBoundary(node) && source.pos < definitePos) continue; const read = this.restoredNothingRead(source); const restored = read === null ? null : this.widenedType(read); if (restored === null) continue; @@ -1232,6 +1236,16 @@ function holds(node: ts.Node | undefined, inner: ts.Node): boolean { return false; } +/** Whether an operator evaluates its right operand only when the left one + * does not answer. */ +function shortCircuits(kind: ts.SyntaxKind): boolean { + return ( + kind === ts.SyntaxKind.AmpersandAmpersandToken || + kind === ts.SyntaxKind.BarBarToken || + kind === ts.SyntaxKind.QuestionQuestionToken + ); +} + /** Whether `write` certainly runs before `read` on every path that reaches * it: both in one function, the write earlier in the text, and every * conditional construct in between one the read is inside too. */ @@ -1263,6 +1277,11 @@ function sharesBranch(node: ts.Node, write: ts.Node, read: ts.Node): boolean { if (holds(node.whenFalse, write)) return holds(node.whenFalse, read); return holds(node.whenTrue, read) || holds(node.whenFalse, read); } + if (ts.isBinaryExpression(node) && shortCircuits(node.operatorToken.kind)) { + // The left operand always evaluates; the right one is skipped whenever + // the left answers, so a write there runs only together with the read. + return !holds(node.right, write) || holds(node.right, read); + } if (ts.isForStatement(node)) { // The initializer and the condition run before the body is entered. if (holds(node.initializer, write) || holds(node.condition, write)) return true; From aa1fff0e6eab08911a99e6edf501a8bbcee15697 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 7 Nov 2026 06:02:48 -0800 Subject: [PATCH 08/11] Add the early-return and throw shapes, the baseline, and oxfmt's wrapping Co-Authored-By: Claude Code --- .../frontend/lowering/narrowing-stability.ts | 10 ++++- .../corpus/9646-copy-reset-paths.mjs.json | 6 +++ tests/corpus/9646-copy-reset-paths.mjs | 38 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9646-copy-reset-paths.mjs.json diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index b3f5898311e..9b12306f44b 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -306,7 +306,12 @@ export class NarrowingStability { found.push( decl.initializer === undefined ? { at: decl, value: null, kind: "declared", stale: false } - : { at: decl, value: decl.initializer, kind: "assigned", stale: stale(decl.initializer) }, + : { + at: decl, + value: decl.initializer, + kind: "assigned", + stale: stale(decl.initializer), + }, ); for (const write of this.writesIn(ts.getSourceFileOfNode(decl)).get( ts.symbolName(symbol), @@ -384,7 +389,8 @@ export class NarrowingStability { own = write; } if (own === null || own.kind === "unknown") return null; - if (ts.isVariableStatement(statement)) return own.at.parent?.parent === statement ? own : null; + if (ts.isVariableStatement(statement)) + return own.at.parent?.parent === statement ? own : null; if (ts.isExpressionStatement(statement)) return skipParentheses(statement.expression) === own.at ? own : null; return null; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9646-copy-reset-paths.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9646-copy-reset-paths.mjs.json new file mode 100644 index 00000000000..a1e246657b1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/9646-copy-reset-paths.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/9646-copy-reset-paths.mjs" + ], + "diags": [] +} diff --git a/tests/corpus/9646-copy-reset-paths.mjs b/tests/corpus/9646-copy-reset-paths.mjs index 8c98d70ce98..71cc5d2c727 100644 --- a/tests/corpus/9646-copy-reset-paths.mjs +++ b/tests/corpus/9646-copy-reset-paths.mjs @@ -110,6 +110,42 @@ function earlyExit(stop) { return holder.show(c) + " / " + "abcd".slice(n); } +function afterReturn(stop) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + if (stop) return holder.show(c) + " / " + "abcd".slice(n); + c = undefined; + n = undefined; + return holder.show(c) + " / " + "abcd".slice(n); +} + +function afterThrow(fail) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var c = text; + var n = size; + var inside; + try { + if (fail) throw new Error("stop"); + c = undefined; + n = undefined; + inside = holder.show(c) + " / " + "abcd".slice(n); + } catch (e) { + inside = "caught " + holder.show(c) + " / " + "abcd".slice(n); + } + return inside + " then " + holder.show(c) + " / " + "abcd".slice(n); +} + function loopBody(times) { var text, size; function fill() { @@ -310,6 +346,8 @@ for (const form of ["&&", "||", "??", "?:", "?.()"]) { console.log("reset under " + form + ":", shortCircuit(form, true), "|", shortCircuit(form, false)); } console.log("reset after an early exit:", earlyExit(true), "|", earlyExit(false)); +console.log("reset after an early return:", afterReturn(true), "|", afterReturn(false)); +console.log("reset after a throw:", afterThrow(true), "|", afterThrow(false)); console.log("reset in a loop body:", loopBody(0), "|", loopBody(2)); console.log("reset in try:", inTry(true), "|", inTry(false)); console.log("reset in catch:", inCatch(true), "|", inCatch(false)); From 538731bfbb40ac2a1fbf9f060acb78c12cb2ecfd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 7 Nov 2026 08:21:28 -0600 Subject: [PATCH 09/11] Address review feedback --- .../lowering/narrowing-stability.test.ts | 21 ++++++ .../frontend/lowering/narrowing-stability.ts | 75 ++++++++++++------- 2 files changed, 69 insertions(+), 27 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts index 5a9972a7d91..32c42ce9b34 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts @@ -335,6 +335,27 @@ console.log(stale()); expect(readsLocal(module, "stale", "copy")).toBe(true); }); +test("a copy written from itself in a conditional keeps the type it held before the write", async () => { + // `keep ? copy : undefined` stores the value `copy` already held when + // `keep` is true, so the argument still reads the binding. + const module = await lower(` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +let keep = true; +function stale(): string { + start = undefined; + set(); + let copy = start; + copy = keep ? copy : undefined; + return "abcd".slice(copy); +} +console.log(stale()); +`); + expect(readsLocal(module, "stale", "copy")).toBe(true); +}); + test("an untyped copy assigned the stale read again after a reset is refused", async () => { await expect( lower(` diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index 32fb0f3ae57..79d9cd0af4c 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -50,9 +50,9 @@ export class NarrowingStability { private readonly effects = new Map(); private readonly capturedMutable = new Map(); private readonly staleBindings = new Map(); - /** Bindings whose inherited type is being computed: a cycle of bindings - * assigned from each other contributes nothing. */ - private readonly inherited = new Set(); + /** Reads whose inherited type is being computed: a cycle of reads of one + * binding contributes nothing. */ + private readonly inherited = new Set(); private readonly identifierWrites = new Map>(); private readonly widened = new Map(); private readonly plainContainers = new Map(); @@ -93,27 +93,38 @@ export class NarrowingStability { * a lowering that discards a value because its type is `undefined` * consults this first. */ restoredNothingRead(expression: ts.Expression): ts.Expression | null { - if (this.files === null) return null; + return this.restoredNothingReads(expression)[0] ?? null; + } + + /** Every stale read `expression` can evaluate to, in evaluation order. */ + private restoredNothingReads( + expression: ts.Expression, + out: ts.Expression[] = [], + ): ts.Expression[] { + if (this.files === null) return out; const e = skipOuter(expression); if (ts.isIdentifier(e) || ts.isPropertyAccessExpression(e)) { - return this.widenedType(e) !== null && isNothing(this.lowerer.checker.getTypeAtLocation(e)) - ? e - : null; + if (this.widenedType(e) !== null && isNothing(this.lowerer.checker.getTypeAtLocation(e))) + out.push(e); + return out; + } + if (ts.isConditionalExpression(e)) { + this.restoredNothingReads(e.whenTrue, out); + return this.restoredNothingReads(e.whenFalse, out); } - if (ts.isConditionalExpression(e)) - return this.restoredNothingRead(e.whenTrue) ?? this.restoredNothingRead(e.whenFalse); if (ts.isBinaryExpression(e)) { switch (e.operatorToken.kind) { case ts.SyntaxKind.AmpersandAmpersandToken: case ts.SyntaxKind.BarBarToken: case ts.SyntaxKind.QuestionQuestionToken: - return this.restoredNothingRead(e.left) ?? this.restoredNothingRead(e.right); + this.restoredNothingReads(e.left, out); + return this.restoredNothingReads(e.right, out); case ts.SyntaxKind.CommaToken: case ts.SyntaxKind.EqualsToken: - return this.restoredNothingRead(e.right); + return this.restoredNothingReads(e.right, out); } } - return null; + return out; } /** The checker narrows this reference below its declared type, and the @@ -252,9 +263,10 @@ export class NarrowingStability { * an explicit `y = undefined` after the initializer leaves nothing to * inherit, and the read is genuinely absent. */ private inheritedType(symbol: ts.Symbol, node: ts.Identifier): ts.Type | null { - // A cycle of bindings assigned from each other contributes nothing. - if (this.inherited.has(symbol)) return null; - this.inherited.add(symbol); + // A cycle of reads of the same binding, which cannot resolve to anything + // but itself, contributes nothing. + if (this.inherited.has(node)) return null; + this.inherited.add(node); try { const decl = symbol.valueDeclaration; if (decl === undefined || !ts.isVariableDeclaration(decl) || decl.type !== undefined) @@ -271,18 +283,28 @@ export class NarrowingStability { assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken && this.lowerer.resolveValueSymbol(write) === symbol ) - sources.push({ source: assignment.right, definite: runsBefore(write, node) }); + sources.push({ source: assignment.right, definite: runsBefore(assignment, node) }); } + // The type a write can store: the union of the restored reads it can + // evaluate to, every arm of a conditional or operand of `&&` included. + // A write of the binding's own value (`copy = keep ? copy : undefined`) + // stores what the read before it held. + const storedType = (source: ts.Expression): ts.Type | null => { + let stored: ts.Type | null = null; + for (const read of this.restoredNothingReads(source)) { + const widened = this.widenedType(read); + if (widened === null) continue; + stored = + stored === null || stored === widened ? widened : this.lowerer.checker.getAnyType(); + } + return stored; + }; // The latest write that certainly runs before the read is the value // it sees; when that write stores nothing, the binding holds nothing. let latest: ts.Expression | null = null; for (const { source, definite } of sources) if (definite && (latest === null || source.pos > latest.pos)) latest = source; - let result: ts.Type | null = null; - if (latest !== null) { - const read = this.restoredNothingRead(latest); - result = read === null ? null : this.widenedType(read); - } + let result: ts.Type | null = latest === null ? null : storedType(latest); // A write in a branch, loop body or another function that the read // does not share may still have run since a call: it widens the // inherited type, but only a restored read gives it one. In this @@ -292,15 +314,14 @@ export class NarrowingStability { for (const { source, definite } of sources) { if (definite) continue; if (flowBoundary(source) === flowBoundary(node) && source.pos < definitePos) continue; - const read = this.restoredNothingRead(source); - const restored = read === null ? null : this.widenedType(read); + const restored = storedType(source); if (restored === null) continue; result = result === null || result === restored ? restored : this.lowerer.checker.getAnyType(); } return result; } finally { - this.inherited.delete(symbol); + this.inherited.delete(node); } } @@ -1247,11 +1268,11 @@ function shortCircuits(kind: ts.SyntaxKind): boolean { } /** Whether `write` certainly runs before `read` on every path that reaches - * it: both in one function, the write earlier in the text, and every - * conditional construct in between one the read is inside too. */ + * it: both in one function, the write finished before the read starts, and + * every conditional construct in between one the read is inside too. */ function runsBefore(write: ts.Node, read: ts.Node): boolean { if (flowBoundary(write) !== flowBoundary(read)) return false; - if (write.pos >= read.pos) return false; + if (write.end > read.pos) return false; for ( let n: ts.Node | undefined = write.parent; n !== undefined && !isFlowBoundary(n); From baa4281e4a5e12d58deea40768ee9904c010bad3 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 7 Nov 2026 06:55:10 -0800 Subject: [PATCH 10/11] Count a hoisted var declaration without a value as no write `for (...) { var c; use(c); c = v; }`: `var c;` stores nothing when it runs, so on a later iteration the copy holds what the earlier one stored; the pass took the declaration for a fresh `undefined` and an optional argument dropped the value. A `var` holds `undefined` from the function's entry and its bare declaration passes the fact through; a `let` without a value is a fresh `undefined` each time it runs. The `undefined` a reset is spelled with must be the global, not a binding of the program's. Co-Authored-By: Claude Code --- .../lowering/narrowing-stability.test.ts | 25 +++++++++++++++++ .../frontend/lowering/narrowing-stability.ts | 28 +++++++++++++++---- tests/corpus/9646-copy-reset-paths.mjs | 23 +++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts index 8e17e420ff6..131f49d4bde 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts @@ -441,3 +441,28 @@ console.log(stale(true), stale(false)); `); expect(readsLocal(module, "stale", "copy")).toBe(true); }); + +test("a var declared without a value in a loop keeps what an earlier iteration stored", async () => { + // `var copy;` stores nothing when it runs: on the second iteration the + // copy holds the first one's value. A `let` is a fresh undefined. + const source = (declaration: string): string => ` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale(): string { + start = undefined; + set(); + let out = ""; + for (let i = 0; i < 2; i++) { + ${declaration} + out += "abcd".slice(copy); + copy = start; + } + return out; +} +console.log(stale()); +`; + expect(readsLocal(await lower(source("var copy;")), "stale", "copy")).toBe(true); + expect(readsLocal(await lower(source("let copy;")), "stale", "copy")).toBe(false); +}); diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index 0f877c610f7..aaf39a05c96 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -393,8 +393,18 @@ export class NarrowingStability { // expects at the read. A JavaScript binding whose inferred type is // `undefined` keeps that type after `y = null` or `y = "text"`, which // the checker does not report there: those do not count. + // A `var` is hoisted: it holds `undefined` from the function's entry, + // and its declaration without a value stores nothing when it runs + // (in a loop it keeps what an earlier iteration stored). A `let` + // without a value is a fresh `undefined` each time it runs. + const list = decl.parent; + const hoisted = + list !== undefined && + ts.isVariableDeclarationList(list) && + (list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const | ts.NodeFlags.Using)) === 0; + const holdsUndefined = (undefinedFlag & ~expected) === 0; const settles = (write: BindingWrite): boolean => { - if (write.kind === "declared") return (undefinedFlag & ~expected) === 0; + if (write.kind === "declared") return !hoisted && holdsUndefined; if (write.kind === "unknown" || write.stale || write.value === null) return false; // Only a value that is nothing by its own spelling: any other // expression the checker types as nothing (a call, a conditional, a @@ -402,6 +412,13 @@ export class NarrowingStability { // without, the copy's own included (`y = keep ? y : undefined`, // `y = same(y)`). if (!spellsNothing(write.value)) return false; + const spelled = skipOuter(write.value); + if (ts.isIdentifier(spelled)) { + // `undefined` the global, not a binding of the program's. + const declaration = checker.getSymbolAtLocation(spelled)?.valueDeclaration; + if (declaration !== undefined && !ts.getSourceFileOfNode(declaration).isDeclarationFile) + return false; + } const stored = checker.getTypeAtLocation(write.value); return isNothing(stored) && (nothingFlags(stored) & ~expected) === 0; }; @@ -433,7 +450,8 @@ export class NarrowingStability { return may; } const own = writeOf(statement); - if (own !== null) return !settles(own); + // A hoisted declaration without a value changes nothing. + if (own !== null) return own.kind === "declared" && hoisted ? may : !settles(own); if (ts.isIfStatement(statement)) { const entered = may || raises(statement.expression); const then = after(statement.thenStatement, entered); @@ -480,9 +498,9 @@ export class NarrowingStability { ? (boundary as ts.FunctionLikeDeclaration).body : undefined; if (body === undefined || !contains(body, read)) return false; - // Before its declaration statement the binding is taken to hold - // anything. - return !before(body, true); + // On entry a hoisted binding holds `undefined`; a block-scoped one is + // not readable before its declaration and is taken to hold anything. + return !before(body, hoisted ? !holdsUndefined : true); } /** A read in a call or construction argument (directly or inside a literal diff --git a/tests/corpus/9646-copy-reset-paths.mjs b/tests/corpus/9646-copy-reset-paths.mjs index 82fe690137d..ccedcc9be51 100644 --- a/tests/corpus/9646-copy-reset-paths.mjs +++ b/tests/corpus/9646-copy-reset-paths.mjs @@ -166,6 +166,28 @@ function loopBody(times) { return seen.join(", ") + " then " + holder.show(c) + " / " + "abcd".slice(n); } +function declaredInLoop(times) { + var text, size; + function fill() { + text = "filled"; + size = 2; + } + fill(); + var seen = []; + for (var i = 0; i < times; i++) { + var c; + var n; + let fresh; + let freshSize; + seen.push(holder.show(c) + " " + "abcd".slice(n) + " " + holder.show(fresh) + " " + "abcd".slice(freshSize)); + c = text; + n = size; + fresh = text; + freshSize = size; + } + return seen.join(", "); +} + function inTry(fail) { var text, size; function fill() { @@ -386,6 +408,7 @@ console.log("reset after an early exit:", earlyExit(true), "|", earlyExit(false) console.log("reset after an early return:", afterReturn(true), "|", afterReturn(false)); console.log("reset after a throw:", afterThrow(true), "|", afterThrow(false)); console.log("reset in a loop body:", loopBody(0), "|", loopBody(2)); +console.log("declared in a loop body:", declaredInLoop(1), "|", declaredInLoop(3)); console.log("reset in try:", inTry(true), "|", inTry(false)); console.log("reset in catch:", inCatch(true), "|", inCatch(false)); console.log("reset in finally:", inFinally(true), "|", inFinally(false)); From c48fee903ba2430f7a61194f4b6b1589ada3c121 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 7 Nov 2026 09:01:28 -0600 Subject: [PATCH 11/11] Address review feedback --- .../lowering/narrowing-stability.test.ts | 28 +++++++++++++++++++ .../frontend/lowering/narrowing-stability.ts | 24 +++++++++------- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts index 8e17e420ff6..2402e2662f6 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.test.ts @@ -380,6 +380,34 @@ console.log(stale()); expect(readsLocal(module, "stale", "copy")).toBe(true); }); +test("an uninitialized var declaration in a loop does not reset the copy on later iterations", async () => { + // A `var` binding is initialized when the function is entered, not each + // time its declaration statement runs: on the second iteration the copy + // still holds the value the first one stored, so the argument reads it. + const loop = (declaration: string): string => ` +let start: number | undefined = undefined; +const set = (): void => { + start = 2; +}; +function stale(): string { + start = undefined; + set(); + for (let i = 0; i < 2; i++) { + ${declaration} + if (i === 1) return "abcd".slice(copy); + copy = start; + } + return "none"; +} +console.log(stale()); +`; + const stored = await lower(loop("var copy;")); + expect(readsLocal(stored, "stale", "copy")).toBe(true); + // A block-scoped declaration does initialize its binding each time. + const fresh = await lower(loop("let copy;")); + expect(readsLocal(fresh, "stale", "copy")).toBe(false); +}); + test("a copy written from itself in a conditional keeps the type it held before the write", async () => { // `keep ? copy : undefined` stores the value `copy` already held when // `keep` is true, so the argument still reads the binding. diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index 0f877c610f7..0337774fe1d 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -325,16 +325,20 @@ export class NarrowingStability { const found = this.mayBeStale(value) || this.restoredNothingRead(value) !== null; return found || this.selfReferences !== selfReferences; }; - found.push( - decl.initializer === undefined - ? { at: decl, value: null, kind: "declared", stale: false } - : { - at: decl, - value: decl.initializer, - kind: "assigned", - stale: stale(decl.initializer), - }, - ); + // A `var` binding is initialized to `undefined` when the function is + // entered, not when its declaration statement runs: a declaration + // without a value stores nothing there, and a loop around it does + // not reset the binding on later iterations. A block-scoped + // declaration does initialize its binding each time it runs. + const blockScoped = (list.flags & ts.NodeFlags.BlockScoped) !== 0; + if (decl.initializer !== undefined) + found.push({ + at: decl, + value: decl.initializer, + kind: "assigned", + stale: stale(decl.initializer), + }); + else if (blockScoped) found.push({ at: decl, value: null, kind: "declared", stale: false }); for (const write of this.writesIn(ts.getSourceFileOfNode(decl)).get( ts.symbolName(symbol), ) ?? []) {