Repository navigation
Read the value a called function assigned where TypeScript still says undefined - #3310
Conversation
… 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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
… memos Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
`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 <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
…the read Merges d18b7a4 ("Address review feedback") and replaces its rule and the one this branch had for the same finding with one pass, because both decided by where a write stands in the text: d18b7a4 took a reset in the right operand of `&&` for unconditional (the stored value was then dropped by an optional argument), and kept a conditional assignment before a definite reset. A read of an untyped copy keeps the checker's "nothing" only when every write of the copy that can reach the read stores that nothing from a value that is not stale. A forward pass over the statements on the way to the read carries one fact, "the copy may hold something else": the declaration and an assignment statement set it from what they store, a block passes it through, an `if` joins its arms, and every other construct (a loop, try, switch, a label, a short-circuit or conditional expression, a call's arguments, destructuring) can only raise it. Where the pass cannot tell, the read is of the stored value. tests/corpus/9646-copy-reset-paths.mjs runs each shape both ways against Node; d18b7a4's two tests are kept. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ping Co-Authored-By: Claude Code <noreply@anthropic.com>
Merges ffaa050 ("Address review feedback"), keeps its two tests, and keeps this branch's pass in place of its rule: ffaa050 still read `undefined` after `copy = keep ? copy : undefined`, after a reset behind a labeled `break`, and after a destructuring store that follows a reset (silently, in tests/corpus/9646-copy-reset-paths.mjs). The pass now believes a reset only when the stored value is nothing by its own spelling (`undefined`, `null`, `void e`, a declaration without a value). Any other value the checker types as nothing may carry what its type was inferred without: the copy itself (`y = keep ? y : undefined`, `y = y ?? x`, `y = same(y)`), another copy, a swap. A judgement that runs into its own computation is not kept, and its write counts as stale. The corpus program has those rows, each both ways. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
@ctate your three "Address review feedback" commits (d18b7a4, ffaa050, 538731b) are merged here with their tests kept. I replaced the rule they patch (and the one I had pushed for the first finding) with one pass, because each fix by syntax form left another form open: on ffaa050, Red checks to expect that are main's, not this change: |
`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 <noreply@anthropic.com>
c48fee9 makes the pass's write list leave a `var` declaration without a value out; this branch had the same finding answered by passing the fact through that declaration. Both are kept as one: the declaration is not a write, and a `var` holds `undefined` from the function's entry. Its test is kept beside this branch's. Co-Authored-By: Claude Code <noreply@anthropic.com>
main's #3254 replaced narrowing-stability.ts's local skipOuter with withoutParenthesesTypeWrappers from frontend/outer-expressions.ts and forbids peel-only helpers elsewhere. The three uses this branch added take the shared function, and its own parenthesis loop becomes withoutParentheses. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
Head 81f093e takes main b3ff410 (the shared outer-expression helpers of #3254 replace the local Red that is main's on this head: "Node island conformance 1/4" fails on |
Main gained #3169, #3310, #3282, #3315, #3327 and the untyped-promise adoption. Two conflicts: - frontend/lowering/expressions/binary.ts: #3169 adds the deferred-field read at the top of ensureString, whose signature #3290 extends by a conversion hint; both kept, and the new recursive call passes the hint on. - ir/codec.generated.ts (generated): regenerated from the merged ir.ts with scripts/generate-ir-codec.mjs. Co-Authored-By: Claude Code <noreply@anthropic.com>
…3096, #3104, #3257) into train/pi-1 Conflict: lower-stmts.ts, lowerVarDecl. #3274 moved the slot of an uninitialized let into uninitializedLetSlot; main's #3169 widens a binding a nested function can read before its first store, inline at the declaration. The helper stays and takes the widening as an opt-in argument that only the declaration passes, which also notes the early-read binding (the integration tree's resolution). Co-Authored-By: Claude Code <noreply@anthropic.com>
Main gained vercel-labs#3315 and vercel-labs#3282 (both members of this train, merged by themselves; they add no difference here any more), vercel-labs#3310 and vercel-labs#3169. No conflict. Co-Authored-By: Claude Code <noreply@anthropic.com>
Fix four small differences from Node in the lowering: <T>process.env, satisfies, undefined results, class-field function names Conflicts, resolved as the branch's owner wrote down for the train (trains/lang-1/INBOX/small-silent-fixes.md): - Five files against vercel-labs#3254, which replaced the local peel helpers this branch widened: main's side, with the call sites taking withoutParenthesesTypeWrappers (fork-target.ts, builtins/environment.ts, iterable-spelling.ts, program.ts with its two satisfies lines; builtins/arguments.ts is main's as it is). - The former stripTypeCasts callers, which main spells withoutParenthesesAssertionsNonNull, take the same name in ten files, and three calls in lowerer.ts. - lower-functions.ts against vercel-labs#3310 (on main): its refusal of a dropped stale return stays first, then this branch's check of an untyped body value. - executable-cache.test.ts merged without a conflict and then named emit-lowered, from-lowered and from-lowered-accept twice (TS1117): the branch's three entries are dropped, the file is main's. Co-Authored-By: Claude Code <noreply@anthropic.com>
The bug
A compiled program silently computed something else than Node, with exit 0 and no diagnostic.
mustache@4.2.0, compiled natively, rendered the empty string for every template. Eleven lines reproduce it without a package:Fully typed TypeScript had it too:
The mechanism
TypeScript types a read by the last assignment it sees in the read's own function, and a call does not reset that (microsoft/TypeScript#9998). In both programs it types the read
undefined.lowerStaticallyUndefinedArgumentasks "is this argument's typeundefined?" for every optional or defaulted argument, and its callers then keep the argument only for its effects and continue with "absent". A variable read has no effects, so it was dropped. #591 put the same question into the dynamic method call, which is how a method of a constructor function's instance is called; a class method or a plain function reads its argument, which is why only the old-style object showed it.The stale-narrowing analysis (
NarrowingStability) already decides when a call may have undone a narrowing. It restored the declared type in the positions that can take it and left every other read to a checked extraction. A narrowing to nothing butundefinedornullhas no payload to check, so nothing protected those reads.The change
One rule added to that analysis: a narrowing to nothing but
undefinedornullthat a call may have undone reads as declared in every position, whatever the declared type is. For a binding the checker types by flow alone (var x;) the declared type isany, which is what the checker itself answers for a read from a nested function. Other narrowings keep their rule.Three places used a type the checker inferred from such a read:
let y = x) is "nothing" to the checker as well. It now has the declared type of the read it was initialized or assigned from, at its declaration and at every read, until an assignment of its own that every path to the read runs stores that nothing again (y = undefined; return yreturns nothing, as before; a reset on one path only, ory = null/y = "text"into a JavaScript binding the checker keeps typingundefined, still reads the stored value).s.slice(c ? n : undefined), a parenthesized read) is no longer folded to "absent".undefinedfrom the read (function f() { x = undefined; fill(); return x; },(() => x)()) returned nothing. Its callers' types come from the same inference, so the compiler cannot carry the value out. It is now refused by name:SC1090, "returning 'x' from a function whose return type TypeScript infers as 'undefined'", with the hint to declare the return type. With the declaration the return carries the stored value. The limitations page says so.The family, measured
One program per (source kind × scope × binding × assigner), one line per read position, each compared with Node:
.mjs,.cjsand.ts; module level, function level and properties; 13 kinds of binding; 10 ways a function assigns it; 51 read positions. 330 programs, 15,749 cells, both compilers built from the same tree (main e8c933e and this change on it).Of the 1,416 silently wrong cells, 1,244 are right, 96 throw a deferred
SC2020(array.slice(start)with an untyped start in JavaScript, which the wrong fold used to hide), and 76 are refused at build (62 inferred-undefinedreturns, 14array.slicewith an untyped start in TypeScript'slet x;). 48 loud and 38 refused cells became right as well. No cell that is right on main is anything else here.The silently wrong positions on main were the argument of a constructor function's method (JavaScript),
Number(x),slice,indexOf,split,padStart,join,push, a conditional or parenthesized argument (JavaScript and typed TypeScript, bindings and class fields alike), and in TypeScript a function or arrow returning such a read. A function expression called where it stands was never affected: the checker follows it.A second set of 31 positions (a second optional argument,
substring,charAt,at, arrayindexOfwith a start,fill,splice,repeat,padEnd, copies,x!,x as T,x++and others; 840 cells): 140 silently wrong on main, none here.What is left is loud or refused by name, on main as well: an array or object literal holding such a read in JavaScript (the literal's element type is the checker's), member reads on a flow-typed binding (TypeScript's 2454, refused as
SC1090),array.sliceandtoFixedwith an untyped argument (SC2020,SC2012).Tests
tests/corpus/9641-callee-assigned-reads.mjs,9642-callee-assigned-reads-commonjs.cjs,9643-callee-assigned-reads-typed.ts: the family in the three source kinds, including the parser shape mustache uses.tests/corpus/9646-copy-reset-paths.mjs: an untyped copy and an assignment of its own on some or all paths to the read, each shape both ways: a conditional assignment then a reset, a reset in both arms and in one, under&&,||,??,?:and?.(), after an earlybreak,returnandthrow, in a loop body, avarand aletdeclared in a loop body, intry,catchandfinally, in aswitch, behind a labeledbreak, by a closure, through destructuring,nulland a value of another type, and the copy assigned from itself (c = keep ? c : undefined,c = c ?? x,c ||= x,c = same(c), a swap).tests/corpus/9645-mustache-templates/: the publishedmustache@4.2.0(vendored unmodified with@types/mustache), compiled natively, rendering sections, inverted sections, partials, custom and switched delimiters, standalone lines, escaping,parseand its three error messages. With the package forced into a Node island it matches Node too (the conformance lane picks the directory up; no entry in its list).tests/diagnostics/stale-undefined-inferred-return.ts: the refusal.narrowing-stability.test.ts, including the reviewer's six.Review
Five bot findings were all about one sub-mechanism: when an untyped copy's own later assignment ends what its initializer left in it. d18b7a4, ffaa050, 538731b and c48fee9 (pushed to this branch) are merged and their tests kept; their rule, and the one this branch had, are replaced by one pass, because a rule per syntax form kept leaving a form out (on ffaa050,
9646-copy-reset-paths.mjsprintsundefinedwhere Node prints the value behind a labeledbreak, after a destructuring store, and afterc = keep ? c : undefined). The pass carries one fact along the statements on the way to the read, "the copy may hold something else": aletdeclaration and an assignment statement set it (cleared only by a value spelledundefined,nullorvoid e), a barevardeclaration is no write, a block passes it through, anifjoins its arms, and every other construct can only raise it. That list is closed: for any shape outside it, the read is of the stored value.Proof
All in Vercel Sandboxes.
--gate-corpus=all, no allow pattern), this head (81f093e) against main b3ff410: 5,314 programs identical in IR, LLVM and release executable, 118 fail to build on both sides, none differ, none runtime-only. No benchmark, corpus program or npm-suite program contains such a read, so there is nothing to list. Compile time: benchmarks +0.48% [95% CI -0.60%, +1.35%] and fixtures +0.88% [-0.18%, +1.94%], within tolerance; self-compile +0.43% [-0.77%, +3.52%], which the gate calls inconclusive in this round (over the nine rounds run on this branch it was -1.03% to +1.23%, on both sides of zero). The candidate compiler builds its own source. The gate gave the same identity verdict on every earlier head.island-value-matrixcells red since Read an instance's class through x.constructor, and compile static accessors #3165 (re-pin in Re-pin the island value matrix cells that read x.constructor #3298), andexecutable-cache.test.ts"every build and run option is in the cache keys" (Add --emit-lowered and --from-lowered: build from a lowered program #3218's three options have no rule from Key the executable cache by the tier policy, and require a rule for every build option #3260; fix in Give the lowered-program options their cache key rule (main fails this test) #3306). Two tests timed out in the plain lane only (runtime/test/regex.test.tssetup,native-toolchain.test.ts"TLS builds promote staged vendor archives") and pass alone and in the sanitized lane.unit-journal.test.tsfailed in that round because a new memo was not in the lowerer's list; it is listed now and the test passes. The review round changed one function's rule; its unit tests, the six new programs (plain and sanitized, release and dev), lint, format and a fullpnpm -r buildwere run again on the final tree.self-hosting-native-driver), both pass (same round).scripts/checker-identity.mjs --filter 964: tsgo and tsc-ts give the same LLVM IR for the new programs, release and dev.pnpm test:ts7 --baselines-onlyand the order-parity batches pass. Docs gate: 25 of 25.🤖 Generated with Claude Code