Files
Zachary Lowden c318ef1fb8 fix(typecheck): make a crashed typecheck report as crashed, not as clean (#3619)
* fix(typecheck): make a crashed typecheck report as crashed, not as clean

`pnpm run typecheck` was `cross-env NODE_OPTIONS="--max_old_space_size=8192"
tsc --noEmit`. When the heap cap is too small for the program graph, V8 aborts
part way through checking, so tsc emits ZERO diagnostics and dies. cross-env
normalises the SIGABRT to exit 1, and V8's explanation goes to stderr — so a
caller that captures stdout gets an empty log, a bare non-zero exit, and no
type errors anywhere in it.

That is indistinguishable from a clean pass to anything that judges the run by
its output, which is what people and scripts actually do (a clean run also
prints nothing). Reproduced with a deliberate `const x: number = 'nope'` in
`src/`: at a 4096 MB cap the run reported 0 errors and hid it completely; the
same tree at 8192 MB reported it.

Measured cold on a clean checkout, with that error in place as a visibility
control:

  node 24.18.1  4096 -> OOM/0 diags   4608 -> OOM/0 diags
                5120 -> pass/found    8192 -> pass/found
  node 22.22.2  6144 -> pass/found    8192 -> pass/found

So the current 8192 is NOT at the cliff — the cliff is between 4608 and 5120,
and 8192 carries ~1.6x headroom. The number is left alone deliberately: the CI
runner has 16 GB, and a cap near that trades a self-describing V8 abort for a
kernel OOM-kill, which says less. Raising it would only move the cliff anyway.

What changes is that crossing the cliff becomes loud. `scripts/typecheck.mjs`
runs tsc and classifies the outcome:

  - clean            -> prints an explicit "typecheck: OK" line, so silence is
                        no longer what a pass looks like
  - type errors      -> passed through untouched, exit code preserved
  - crashed          -> a CRASHED banner naming the cause, on stdout AND stderr
                        (the original blind spot was a stdout-only capture),
                        plus a ::error:: annotation under Actions
  - exit 0 w/ diags  -> treated as a crash rather than trusted

Heap exhaustion, an outside kill (out of system RAM / a container limit) and an
unexplained abort are named separately, because the fix differs — an outside
kill wants a LOWER cap, not a higher one. The cap is passed as an argv flag
rather than via NODE_OPTIONS so an inherited NODE_OPTIONS cannot override it.

Override per-run with TYPECHECK_HEAP_MB=<mb>.

Covered by scripts/__tests__/typecheck.test.ts, which drives the classifier with
stub typecheckers (sub-second, vs minutes for a real run). Each of the five
cases was mutation-checked against the wrapper: 6/6 mutations killed, each by
its own test. One mutation initially SURVIVED and exposed a real gap in the
test — the crash banner is written to stderr, so asserting only on stdout let a
grep-poisoning regression through; both streams are asserted now.

CI already invoked this via `pnpm run typecheck` and so inherits the wrapper;
the step carries a comment against being "simplified" back to a bare tsc.

* fix(husky): stop the pre-push hook echoing success over a failed typecheck

The hook was:

    npm run typecheck

    echo "Typecheck successful"

`sh` without `set -e` runs the next line regardless of what the previous one
returned, and a script's exit status is its last command's — so the `echo`
became the hook's verdict. A failing typecheck on `main` printed "Typecheck
successful" and the push went through.

Measured against the real hook in a throwaway repo on `main`, with a stub `npm`
whose exit code is controlled:

    npm exit    hook exit (before)    hook exit (after)
       0              0                     0
       1              0                     1
     134              0                   134

Before, all three printed "Typecheck successful". The 134 row is the case this
matters most for: that is V8 aborting on heap exhaustion, which emits no
diagnostics at all, so the hook was echoing success over a typecheck that had
not merely failed but never finished. The failure message points at
scripts/typecheck.mjs, which distinguishes the two.

The branch/username guard above is unchanged, and still makes the hook a no-op
off `main`.
2026-08-04 13:48:02 -05:00
..