Commit Graph

16 Commits

Author SHA1 Message Date
Zachary Lowden 4ede37c9c5 ci: re-pin BASE_TESTS to 37,857 — the unit shard gate is red on main (#4621)
* ci: re-pin BASE_TESTS to 37,857 — the unit shard gate is red on main

`scripts/ci/assert-shard-ran.mjs` pins `BASE_TESTS = 22157`, measured
2026-08-25. The suite has since grown to 37,857 executed across 1,626
files, so shards 3 and 4 exceed the derived ceiling of 9,971 and the
job fails with every test passing.

This is red on `main`, not just on PRs: runs 33900980756, 33901008291
and 33901251166 all fail the same two shards on unrelated commits. A
permanently-red gate trains people to click through, so it is re-pinned
rather than worked around.

Re-measured from run 33900980756: 7177 + 5769 + 14724 + 10187 = 37,857.

Cause (2) confirmed against cause (1) by the script's own discriminator:
the four counts are unequal proportionate shares (2.55x spread) and each
shard ran ~4 min against a ~10.4 min full suite. `--shard` failing to
reach vitest would instead give four near-identical full-suite counts at
full-suite runtime.

Also corrects two claims in the comment that are no longer true and that
are the stated justification for the 0.3 / 1.8 multipliers:

  - imbalance was "~5% off the mean"; the heaviest shard is now +55%
  - ceiling headroom was "~1.7x"; it is now 1.16x

vitest shards by file, so a few test-dense files skew a shard badly and
the next one trips the ceiling well before suite growth would. Recorded
in the comment: if this reds again on a green suite, balance the shards
rather than raising the number again.

Verified: the new band [2839, 17036] admits all four current shards, and
still rejects a collapsed shard (0, 99) and a shard that ran the whole
suite (37857).

* ci: make the shard-gate tests follow BASE_TESTS instead of restating it

CI caught what the previous commit missed: re-pinning BASE_TESTS turned
`Unit tests (3)` and `(4)` green but broke `Unit tests (1)`, because
scripts/__tests__/assert-shard-ran.test.ts carried a SECOND copy of the
constant — `const BASE_TESTS = 22157`, under a comment saying it was
"kept in step with BASE_TESTS in the script". Kept in step by convention,
which is what failed. Three boundary tests asserted a stale band while
the script itself was correct.

The test now reads the constant out of the script source. Parsing rather
than importing is deliberate: the script is a CLI that reads process.argv
and calls process.exit at module scope, so an import would execute it. The
regex throws a named error if the constant is renamed, so that fails loudly
instead of silently falling back to a default.

Also fixes the third failing test, which read `const count = 3000` under a
comment claiming it was "computed rather than hardcoded so it follows
BASE_TESTS". It did not follow it. When BASE_TESTS moved the interesting
region slid from 1662..3324 to 4259..5679 and 3000 fell out of it. It is
now derived from the two bounds, with an assertion that the region is
non-empty so a multiplier change fails loudly rather than silently picking
a midpoint that satisfies neither bound.

Validated rather than assumed:
  - constant moved to 50000  -> 14/14 still pass (the tests follow it)
  - constant renamed         -> throws the named error, 1 file failed
  - restored                 -> byte-clean, 14/14 pass

* ci: retrigger preview checks (empty)
2026-09-04 16:18:02 -05:00
Zachary Lowden 6de64ee5a8 fix(tests): unzero the preview / component-tests tier, and make a zero-collected run say so (#4531)
* fix(tests): unzero the component tier — one mock factory was aborting the whole run

`preview / component-tests` was reporting `failure` on `main` while executing ZERO
tests. Reproduced deterministically (3/3 at d353f785c3, and in isolation): the whole
`component` project aborts before any reporter prints, with no `Test Files` line, no
per-file results, and exit 1.

Root cause, one file:
`src/tests/pages/apps/review/review-queue-nav.browser.test.tsx:30` mocks
`~/providers/FeatureFlagsProvider` with a WHOLESALE factory naming only
`useFeatureFlags`. The review queue page's row now renders the review entry point,
which reads flags through `useOptionalFeatureFlags`, so the named import has nothing
to bind to:

  SyntaxError: The requested module '/src/providers/FeatureFlagsProvider.tsx'
  does not provide an export named 'useOptionalFeatureFlags'

In the node `unit` project that would fail ONE file. In BROWSER mode it kills the run:
vitest resolves a manual mock over the browser-to-node channel inside a Playwright
route handler that does not catch (`@vitest/browser-playwright/dist/index.js`,
`await module.resolve()` inside `page.route`), so the rejection escapes as an
Unhandled Rejection in the orchestrator.

The printed error names neither the file nor the real cause. It is wrapped twice --
once by the browser mocker, once again on the node side -- and the innermost `cause`
is dropped in transit, so all you see is the generic "[vitest] There was an error when
mocking a module ... make sure there are no top level variables inside", which points
at hoisting and is wrong. The root cause above was recovered by temporarily patching
`createHelpfulError` in the browser tester bundle to inline `cause.stack`; the file was
then confirmed by bisecting the 50 candidate files down to one.

This is the SECOND time this class has bitten (see the header of
`src/components/AppBlocks/__tests__/featureFlagsMockCompleteness.test.ts`, which fixed
six sibling suites in the AppBlocks directory and deliberately scoped its guard there).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(ci): make the component tier fail LOUDLY on zero collected, not silently

The `preview / component-tests` tier could report `failure` having executed ZERO
tests, and nothing anywhere said so. The shared `npm-report-only-suite` Tekton task
computes its verdict from the runner's EXIT CODE alone, so an abort that collected
nothing and a genuine list of red assertions both render as `component:fail` /
"Component suite failed" -- same words, same colour, same place. That is the shape
that trains people to click through a tier.

`pnpm test:component` now runs through `scripts/test-component-run.mjs`, which asks
vitest for a JSON report and hands it to `scripts/ci/assert-component-suite-ran.mjs`.
That gate prints a ledger -- `N executed, N skipped, across N files; N failed suites,
N failed tests` -- and fails when nothing was collected, or when the executed count
falls below a floor (1240, ~55% of the 2254 measured on a full green run of 201 files
on 2026-08-31).

Deliberate limits, each of which is a way this could have been wrong:

- It can only ever ADD a failure. Vitest's own exit code is passed straight through
  when the ledger is satisfied, so a red suite stays red for its own reason.
- EXECUTED, not TOTAL. `numTotalTests` counts skipped tests, so a suite that
  self-skipped wholesale would satisfy a total-based floor having run nothing.
- `failed` counts as executed. A guard that scored a red run as "did not run" would
  fire on every genuine failure, and the tier would then be red for two different
  reasons that nobody could tell apart -- the exact confusion this removes.
- A signal-killed runner short-circuits the gate entirely. The CI task wraps this in
  `timeout(1)` and distinguishes "timeout" from "fail" on purpose; a killed run also
  writes no report, so relabelling it "collected nothing" would be a wrong answer
  rather than a missing one.
- A narrowed run (a file argument, or `-t`) skips the FLOOR but not the zero check:
  the collected count is then a property of the filter, but a single-file run that
  collects nothing is the cheapest reproduction of the abort and is precisely when
  someone is debugging it.

The message enumerates every cause it cannot tell apart rather than asserting one --
an absence is the observable the most causes share, and a guard that names the wrong
one sends the next reader hunting a bug that is not there. All three named have been
observed on this suite; two of them were observed while writing this change, and the
browser-crash one arrives wearing the mock error's headline with the real cause on
the `Caused by:` line.

Verification:
- 16 unit tests over JSON fixtures (`scripts/__tests__/`), covering zero, the floor
  boundary both sides, all-red, all-skipped, narrowed both ways, import-failed
  suites, and a missing/unparseable report.
- Mutation-checked: 9 mutants of the gate, ALL KILLED, each by the specific named
  test written for it (verified per-test, not "some test failed"), against a green
  16/16 baseline.
- End-to-end negative control: reverting the one-line fix in the previous commit and
  running through the wrapper produces the ledger's abort message and exit 1, from
  the missing-report branch.
- End-to-end positive: the full suite through `pnpm run test:component`.

Docs corrected while here: CONTRIBUTING said component tests "don't run in CI at all"
and put the file count at 106; they do run, report-only, in the preview pipeline, and
there are 201 files with ~2,250 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): the zero-collected message overstated a PARTIAL abort

The headline said "THE COMPONENT SUITE COLLECTED NOTHING ... nothing executed", and
the code contradicts it in the case that actually happens most: the JSON reporter
writes at the END of a run, so an abort part-way through leaves no report at all.
Measured while writing this: a run that aborted 68 files into 201 had 68 files
scrolled past as green, wrote nothing, and got told nothing executed.

Both halves of that were wrong to assert. Tests HAD executed, and a reader who
scrolls up and sees green lines is entitled to believe the message is confused --
or, worse, to believe the green lines are coverage.

So the headline is now "THIS RUN PRODUCED NO ACCOUNTABLE RESULT", which is true of
both shapes, and the diagnosis says explicitly that an abort can land part-way and
that whatever scrolled past is unaccounted for rather than confirmed. The
missing-report branch says why the report is absent (the reporter writes at the end)
and that a partial run and a run that never started are indistinguishable from
there -- which is the reason neither counts as one that ran.

No behaviour change: the same runs fail, with the same exit code. The four test
assertions that pinned the old headline move with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): close six findings from the adversarial audit of this PR

An adversarial audit of #4531 found two ways the new guard reproduced the very
pathology it was written to remove, plus four smaller gaps. All six are fixed here;
nothing about the payload fix in c32aa06e8d changes.

1. A SIGNAL WAS RELABELLED AS A TEST FAILURE. `child.on('exit')` returned a hardcoded
   143 for EVERY signal. `report-only-suite-task.yaml` branches on 137 to report
   `oom-killed` -- "this is an OUT-OF-MEMORY kill, not a timeout; raise the task's
   memory limit" -- and before this wrapper existed an OOM-killer SIGKILL on vitest
   reached that task as 137, because pnpm re-raises. With a constant 143 it matched no
   branch and fell through to `RC=1`, verdict `fail`, rendered "Component suite
   failed". A memory problem reported as a test failure, on the same tier, in the same
   words. Now `128 + os.constants.signals[sig]`, so 137/143/130 come out right and the
   mapping cannot drift from the names node hands back. The comment above the branch
   claimed it passed the status through; it does now.

2. A CALLER `--outputFile` MADE A GREEN RUN REPORT "COLLECTED NOTHING". Measured by
   the auditor: `pnpm test:component <file> --outputFile=/tmp/x.json` wrote an 18/18
   green report to the caller's path, left the wrapper's path empty, and printed the
   full "this tier verified NOTHING on this commit" diagnosis naming three causes,
   none of them real. `.github/workflows/lint.yml` runs the SIBLING unit tier with
   exactly that flag, so it is a copy-paste away. Now refused up front with the fix in
   the message.

3. `isNarrowed` WAS WRONG IN BOTH DIRECTIONS ON SPACE-SEPARATED FLAGS. `--max-workers 1`
   -- the form CONTRIBUTING steers people towards for sizing a run on a shared box --
   put `1` in a positional slot, so the run scored "narrowed" and THE FLOOR WAS SILENTLY
   TURNED OFF; same for `--reporter`, `--retry`, `--bail`, `--project`, `--pool`. In the
   other direction `--shard=1/4` scored NOT narrowed, so a healthy sharded run would
   fail the floor while being told "Do NOT lower the floor to make this green" --
   misdirection, not merely a false red. Value-taking flags now consume their value, and
   `--shard`/`--changed`/`--related` narrow explicitly.

4. WINDOWS. `spawn()` of `vitest.cmd` without `shell` has failed since the node
   18.20.2/20.12.2 CVE fix; `scripts/test-unit-run.mjs` already sets
   `shell: process.platform === 'win32'` for this reason. Added. And a spawn failure
   (rc 127) now short-circuits instead of handing the gate a missing report and getting
   forty lines about mock factories and dead browsers.

5. THE REPORT IS NOW DELETED BEFORE THE RUN, not only after. Cleanup after the run is
   skipped by exactly the paths that leave a stale report behind (a signal death, a
   throw), so a healthy 2254-test report could survive into a later run that aborted
   before writing one -- the gate reading it, printing a green ledger, and passing:
   silently inert in precisely its own use case.

6. THE GATE COMPUTED A FILE COUNT AND NEVER CHECKED IT. Replaced with a LEDGER, which
   is the stronger of the two checks: it walks `src/` for every `*.browser.test.tsx` and
   fails when one is absent from the report, NAMING it. The test floor sits at ~55%, so
   ~45% of the suite could stop being collected while the gate stayed green -- and the
   incident this whole guard descends from is exactly that shape (six files contributing
   0 of 438, nothing red). The expectation is re-derived every run, so there is no
   constant to go stale. Skipped when narrowed, and when the walk finds nothing -- an
   empty walk is not a measurement, and it SAYS so rather than passing quietly.

Also: the fixed factory now names all THREE hooks the flags module exports.
`useFeatureFlagsReady` is the third and has four live consumers; none is in this page's
graph today, which is the only reason naming two loads at all. The comment said "BOTH
hooks", which reads as "the module has exactly two" -- and it is the template the PR
proposes copying to fifty files.

Verification:
- 27 unit tests (was 16), including the on-disk ledger against a fixture tree that
  contains a `node_modules/` and a non-browser `.test.tsx` neither of which may count.
- Mutation-checked: 17 mutants, ALL KILLED, each by its own named test. One SURVIVED on
  the first sweep -- `out.length > 0 ? out : null` was unreachable from the fixture,
  which had no `src/` at all, so the walk returned early. A second case with a `src/`
  that holds no browser tests reaches it; an empty array is TRUTHY, so that mutant would
  otherwise have armed a ledger over zero expected files and passed everything.
- The on-disk ledger run against the REAL tree: 201/201 green, and dropping one real
  file from the report fails and names it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): close audit round 2 — two regressions the round-1 fixes introduced

A delta re-audit of `6ceac37f33..7f005f0482` confirmed 4 of the 7 claims outright
(the signal mapping, the pre-run clear, the file ledger, the fixed factory) with its
own positive and negative controls, and found that two of the fixes had introduced
new defects of their own. Both are here, with the smaller findings.

🔴 THE ON-DISK LEDGER FALSE-FAILED A LEGITIMATE RUN, WITH A MESSAGE FORBIDDING THE FIX.
`--exclude`, `--dir` and `--root` were added to VALUE_FLAGS but not to
NARROWING_FLAGS. All three take a value AND genuinely shrink the collected file set,
so `pnpm test:component --exclude 'src/tests/**'` scored as a FULL run and the ledger
failed it naming up to 200 files -- asserting the include broke or the run died, and
telling the reader "Do NOT silence this by narrowing the walk", which is the only
thing that would have fixed it. `--config` joins them: it can replace the project's
`include` outright, which is the assumption the walk is built on. This is precisely
the shape the round was convened to prevent, produced by the round's own fix.

🔴 `shell: win32` WENT ON THE SHARED `run()`, SO IT ALSO WRAPPED THE GATE SPAWN.
The claim said this matched `scripts/test-unit-run.mjs`; it did not -- that file puts
`shell` on its vitest spawn only and deliberately leaves its `process.execPath` spawn
alone. Node with `shell: true` concatenates argv UNESCAPED (DEP0190), and the gate is
spawned as `process.execPath`, which on Windows is `C:\Program Files\nodejs\node.exe`.
So on the one platform the option exists to support, every `pnpm test:component` would
have failed at the gate step with a cmd.exe parse error rather than any of this
wrapper's messages. `shell` is now per-call.

Also:

- KEBAB AND CAMEL ARE THE SAME FLAG TO VITEST (cac camelCases every option key), so a
  hand-enumerated list has a hole wherever it carries one spelling and not the other --
  and it did: `--max-workers`/`--maxWorkers` were both listed, `--test-timeout` was
  not, so the kebab form silently disabled both checks. Spellings are now canonicalised
  rather than enumerated, and the test asserts them in PAIRS.
- `--output-file` (kebab) was NOT refused, so the exact defect the refusal exists for
  was still reachable -- under a test named "catches every spelling". Meanwhile
  `--outputFile.junit=` and `--outputFile.html=` WERE refused, though neither touches
  the `.json` key: object-form output paths are per-reporter, so that was over-strict
  and the stated reason ("the bare form sets the path for EVERY reporter") is true only
  of the bare form. Both directions fixed.
- The rc-127 short-circuit is keyed on a spawn-failure FLAG, not on the number. 127 is
  an exit code a runner can produce on its own, and relabelling that as "the binary
  could not be started" is a second wrong answer that also skips the gate on a run that
  happened.
- `--repo-root <dir>` did not consume its value, so with the flag FIRST the directory
  was read as the report path: "EISDIR: illegal operation on a directory" plus the
  whole abort diagnosis. Every test passed it last, which is why nothing caught it; the
  ledger tests now pass it first.
- `--related` removed. It is a vitest SUBCOMMAND, and this wrapper always spawns
  `vitest run …`, so it cannot arrive -- the entry and its assertion were both inert,
  the assertion pinning behaviour for an input the runner cannot receive.
- The factory now names all FOUR runtime exports of the flags module, including
  `FeatureFlagsProvider`. The comment said "EVERY runtime hook", which was narrower
  than the module -- and "not in this graph today" is exactly the reasoning that put
  this file in the diff.
- A comment the previous round made false: it said the positional rule would catch
  `-t foo` anyway. It stopped being true the moment `-t` was listed as value-taking --
  the value would now be CONSUMED -- so the explicit narrowing branch is load-bearing.
- CONTRIBUTING documents the refusal, the file ledger, and which flags narrow.

Coverage for the two claims that shipped with none: `main` now takes its collaborators
as injected defaults, so the ORDER of effects can be asserted rather than inspected.
The two `clearReport()` calls are byte-identical statements -- only their position
carries the meaning, so a refactor moving the first below the run reopens "a stale
report satisfies the next run's ledger" with every other test green.

Verification: 35 unit tests (was 27). Mutation-checked at 17 mutants, ALL KILLED, zero
survivors -- including re-running round 1's guards, because an audit fix resets the
gate. Real-tree controls re-run with the new argument parsing, flag first and flag
absent: 201/201 green, and dropping one real file fails and names it. ESLint on the
touched files is back to the base count; two `no-empty-function` errors this change
introduced are fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): close audit round 3 — the canonicaliser re-opened the class it closed

Round 3 verified 8 of round 2's 9 claims against the diff or against vitest's own
source (it read cac's `setDotProp` to confirm the `--outputFile.junit=` allowance is
genuinely safe, and `cliOptionsConfig` to confirm `-r`/`-c` really are root/config).
It found one regression and one coverage hole, both here.

🔴 A BOOLEAN FLAG SWALLOWED THE FILENAME AFTER IT — the same shape round 2 fixed,
re-introduced by round 2's own mechanism. `canonicalFlag` collapsed `.subkey` onto the
parent, so every dot-subkey inherited its parent's value-consuming behaviour, and the
two entries `--coverage.reporter`/`--coverage.provider` became a single `coverage` that
also matched the BARE `--coverage` — which takes no value (`argument: ""` in vitest's
`cliOptionsConfig`). So `pnpm test:component --coverage <file>` ate the FILE as
`--coverage`'s value, scored the run as full, and failed an 18/18 green single-file run
naming ~200 files as ABSENT while telling the reader not to narrow the walk. Same for
`--coverage.enabled <file>` and `--browser.headless <file>`. Measured base-vs-head, all
three flipped `true` to `false`. Matching is now on the full canonical PATH, so a
subkey is value-taking only if it is listed as one.

🔴 THREE MUTANTS SURVIVED A GREEN 35-TEST SUITE, AND EACH WAS THIS PR'S OWN HEADLINE
FAILURE. The injected fakes took no parameter, so nothing could observe the argv `main`
builds — the seam between the two modules this change exists to wire together. Dropping
`--narrowed` from the gate argv makes every narrowed run hard-fail both the floor and
the on-disk ledger; dropping `--outputFile.json=` makes every run report "does not
exist" plus the whole abort diagnosis; dropping `...argv` makes
`pnpm test:component <file>` silently run the entire suite behind a healthy-looking
ledger. Testing `isNarrowed` in isolation cannot see whether its answer is ever USED.
The fakes now capture their argument and three cases assert it; all three mutants die.

Also:
- `--repo-root` with no value was a silent fall-through to the real repo root, so a
  fixture report got graded against the 201 real files and failed with a confident
  diagnosis about the include breaking — produced by a typo. Now a usage error (exit 2).
- CONTRIBUTING: the sample ledger omitted `measured <date>`, which the real output
  carries; and "skips both checks but not the zero check" counted a different pair than
  the two enumerated four lines above. The checks are now numbered and the sentence
  names which ones a narrowed run skips.

Verification: 39 unit tests (was 35). Mutation-checked at 19 mutants, ALL KILLED, zero
survivors — the three that survived round 3, the round-3 fixes themselves, and every
guard from rounds 1 and 2 re-run, because an audit fix resets the gate. Real-tree
controls re-run: 201/201 green, one missing file still fails. ESLint clean on the
changed test file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): close audit round 4 — replace the flag LIST with a shape rule

Round 4 measured `isNarrowed` at base vs head across every one of vitest 4.1.11's 72
boolean options and 73 value-taking ones, and found the flag list had traded 22 loud
wrong answers for 25 quiet ones. Splitting `coverage` into two subkeys left
`--retry.count 2`, `--browser.name chromium` and sixteen more `coverage.*` paths
reading their VALUE as a filename, scoring a FULL run as narrowed and switching the
file ledger and the floor off with a one-line note. That is the direction this file's
own comments repeatedly name as the worse of the two.

🔴 THE LIST WAS THE PROBLEM, AND THREE ROUNDS WERE SPENT ON IT. Enumerating flag NAMES
missed `--test-timeout` next to `--testTimeout`. Canonicalising spellings then made
`--coverage <file>` swallow the file. Splitting into subkeys produced the 25 above.
vitest 4.1.11 has a 164-path option tree; a hand-maintained copy of it is wrong the day
it is written, and each fix moved the wrongness rather than removing it.

So the rule is now about the ARGUMENT, not the flag: a positional is a file filter if
it looks like a path, or if nothing before it could have been expecting a value. A
non-path token straight after a flag is that flag's value, WHATEVER the flag is —
correct for all 73 value-taking options without naming one of them, and correct for all
72 booleans too. `VALUE_FLAGS` is deleted. What remains is `NARROWING_FLAGS` (flags that
genuinely shrink the run and must be named, because omitting one fails LOUDLY) and a
five-entry `PATH_VALUE_FLAGS` for the only residual the shape cannot decide: a value
that is itself a path.

🔴 AND THE DECISION IS NOW A SENTENCE, NOT A BOOLEAN. No rule over an unknowable flag
list is right always; what must never happen is being wrong SILENTLY, because
`--narrowed` disables the two checks this whole change exists to add. `narrowingReason`
returns why, and the runner prints it: `--retry.count 2` reading `2` as a file filter is
obvious on sight and invisible otherwise.

Also:
- `canonicalFlag` camelCases only the FIRST dot segment, matching cac's own
  `camelcaseOptionName` (`name.split(".").map((v,i) => i===0 ? camelcase(v) : v)`).
  Camel-casing all of them made this wrapper accept `--coverage.reports-directory`,
  a spelling vitest does not — so the two would disagree about the next token.
- `--repo-root=<dir>` is parsed. Matching only the space form left the inline spelling
  falling through BOTH branches to the real repo root — byte-for-byte the failure the
  missing-value guard was added to close, reachable through one extra character. A
  value that is itself a flag is rejected too.

Verification: 45 unit tests (was 44 after the round's own additions; 39 before).
Mutation-checked at 23 mutants, ALL KILLED, zero survivors, each by its OWN named test.

🔴 THE FIRST SWEEP REPORTED FOUR SURVIVORS AND WAS WRONG ABOUT ALL FOUR, WHICH IS WORTH
RECORDING BECAUSE THE HARNESS HAD THIS PR'S OWN DEFECT. Two were mislabelled — killed by
a different test than the one named, which the sweep scores as SURVIVED. Of the other
two, one was a real gap (`--reporter=json AppNameCrumb`, now covered) and one was
`canonicalFlag`'s dot handling, which is equivalent for every input that has no dashed
subkey (also now covered). Along the way the harness itself was found reusing a JSON
report path without clearing it — the same stale-report defect fixed in the product two
commits ago — and now clears it and verifies the mutation reached disk before running.
A mutant reported SURVIVED is a claim about the instrument until the instrument has been
controlled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* wip(tests): stop guessing which vitest flags take values

Safety commit of in-progress round-5 rework so it is not lost; the agent was
interrupted mid mutation-sweep. Verification is NOT complete — the sweep, the
red/green pair and the merged-tree re-run have not been reported. Do not merge
on this commit.

Round 5 measured both prior approaches against vitest 4.1.11's real option
table (170 long options, 74 boolean, 96 value-taking): the hand-maintained
list was wrong 73 times, the shape heuristic 74. The error count never moved,
only its direction. This removes the question instead of answering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEa6GDJyTiu2R146ndYsLK

* test(ci): finish the verification bacb8a2cf5 was pushed without

bacb8a2cf5 landed as a rescue commit marked `wip` because the session that wrote it
died with the change only in a working tree. The change itself is unaltered; this is
the verification it was missing, plus the merge of a main that moved twice underneath
it. Nothing here modifies the rule.

WHY THE RULE CHANGED, WITH THE NUMBER THAT JUSTIFIES IT. Round 5 of the audit
enumerated vitest 4.1.11's REAL CLI option table -- by calling `createCLI()` and reading
each cac option's `isBoolean` rather than by hand -- and ran `isNarrowed(['--<flag>',
'VALUE'])` against both revisions imported side by side:

  hand-maintained flag list   wrong on 73 options (every value-taking one)  QUIET
  shape heuristic             wrong on 74 options (every boolean one)       LOUD

73 versus 74. The heuristic did not beat the list; it moved the wrongness off one half
of the table onto the other. The direction improved, which is worth something, but the
error count did not -- and `pnpm test:component --coverage AppNameCrumb` would run one
test and then be failed against the 1240 floor with "the include broke or the run died".
That is a mis-posed question, so it is no longer asked: any argument at all means
narrowed.

Re-measured against the same table on the merged tree, with the same method:

  164 long options (72 boolean, 92 value-taking)
  UNSAFE-LOUD -- rule claims FULL so the floor and ledger fire on a partial run: 0
  no-argument invocation (what CI runs): NOT narrowed, so all three checks arm

(My enumeration walks the global + `run` commands and sees 164/72/92 where round 5's
saw 170/74/96; the traversal differs slightly, the conclusion does not.)

🔴 The comparison is one-directional ON PURPOSE, and the other direction is a real cost
rather than a rounding error: all 164 options now score as narrowed, so an arg-ful run
does not get the floor or the file ledger even when it was genuinely full. That is
affordable for one measured reason -- `pr-preview-pipeline.yaml` invokes
`pnpm run test:component` with NO arguments, so CI is the `argv.length === 0` path and
always gets all three. What is given up is enforcing a floor on an ad-hoc local run.
`VITEST_MAX_WORKERS=4` in the environment sizes a run without giving that up.

Verification on the MERGED tree (d77dd394db), all at load <= 16 with no browser-session
errors in any run quoted:

  no-arg full run (the CI path)   202 files / 2257 tests, exit 0, no NARROWED line,
                                  floor 1240 and the 202-file ledger both ARMED
  guard red arm                   exit 1, "THIS RUN PRODUCED NO ACCOUNTABLE RESULT",
                                  failing on the missing-report branch
  guard green arm                 exit 0, "2 executed, 0 skipped, across 1 files"
  gate unit tests                 37 passed
  mutation sweep                  20 mutants, ALL KILLED, zero survivors

🔴 The red arm was run NARROWED, deliberately: it proves `--narrowed` disables the floor
and the ledger and NOT the zero-collected check, which is the one failure this whole
change exists to catch.

THE MERGE. main moved twice; both times the only conflict was `package.json`, and both
times it was the same semantic hazard -- main editing `test:lint-rules` and this branch
editing `test:component`, adjacent lines of one object, where taking either side
wholesale silently reverts the other and makes the entire guard inert with every test
green. Resolved by parsing the merged JSON, not by reading the diff: `test:lint-rules`
now matches origin/main byte-for-byte and `test:component` is the wrapper. The wiring
guard added for exactly this was then checked against the bad resolution -- applying
main's `package.json` wholesale fails one test, the one written for it.

Round 5's two remaining 🟢 items are closed by the rework itself rather than separately:
the stale JSDoc described `VALUE_FLAGS`, which no longer exists, and the test whose
description claimed "a BOOLEAN flag does not swallow the filename after it" while
passing entirely through the path check is gone with the heuristic it tested. That one
was load-bearing -- a description asserting coverage its body did not provide is what
let the class through -- so its replacement asserts a property with no free parameters
instead, across both halves of the option table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(tests): format the gate test file

The rescue commit was pushed before a prettier pass could run over the last edit to
this file, and `ESLint + Prettier (changed files)` caught it: the file is ADDED by this
PR, so it is covered by the added-files prettier gate. One line.

This red was MINE, not inherited — unlike `Unit tests (1)/(2)` (a redis integration
test that arrived from main) and `preview / smoke-tests` (#4516 changing
`reaction.toggle`), both of which are verified as pre-existing and are left alone.

CONTRIBUTING.md also reports unformatted, and that one is NOT actionable here: it is
already unformatted on origin/main, and it is modified rather than added, so the gate
(which reads `added.txt`) does not cover it. Reformatting it would bury this PR under an
unrelated whole-file diff.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 10:57:49 -05:00
Zachary Lowden 2ba7ecf186 refactor(apps): remove the orphaned 5-star AppBlockReview system (#4501)
* refactor(apps): remove the orphaned 5-star AppBlockReview system

Thumbs (AppListingReview) supersedes it. The 5-star write form had no
reachable entry point: its only two hosts were the /apps/[appBlockId]
detail route (retired, now redirects to the store detail) and
AppDetailsModal, which is opened only from AppBlockCard, which renders
only inside MarketplaceBody / RecentlyOpenedAppsView -- and MarketplaceBody
has had no importer in app code since /apps swapped to
AppListingsMarketplaceBody. The page's own header comment already recorded
this and flagged deciding the form's fate as the follow-up; this is it.

Removed:
  - appBlockReview.service.ts and its two suites
  - the blue-buzz appBlockReview reward + its registration and suite
  - blocks.upsertReview / listReviews / getMyReview / setReviewExcluded
    (setReviewExcluded had zero call sites at all) + their zod schemas
  - AppBlockReviews.tsx and its browser suite
  - the Bayesian machinery that was exclusive to it in
    block-registry.service.ts: the AVG/COUNT/SUM correlated subqueries,
    bayesianRatingSortKey, getGlobalMeanRating and the
    app-rating:global-mean cache tag
  - avgRating / reviewCount from AvailableBlock and PublicAppDetail, and
    the card's rating chip

blocks.listAvailable no longer offers a "rating" sort and defaults to
"popular". The keyset cursor drops its pinned-mean third field; the decoder
still tolerates a stale 3-field cursor so an in-flight page resumes rather
than 500ing.

MARKETPLACE SORT: the live store is unaffected. /apps renders
AppListingsMarketplaceBody, which reads appListings.listAvailable and
defaults to sort "top-rated" -- a Bayesian shrinkage over
AppListingMetric.thumbsUpCount/thumbsDownCount in app-listing.service.ts,
with its own LISTING_BAYES_PRIOR and its own
app-listing:recommend-global-mean cache tag. It never touched
app_block_reviews. Only blocks.listAvailable (the retired grid) read the
star table.

DATABASE: the DROP TABLE migration is committed for history and is NOT
applied anywhere. This project does not run prisma migrate deploy; a human
applies it per environment, after this ships.

Kept deliberately: MarketplaceBody / AppBlockCard / AppDetailsModal (the
documented one-line rollback path for /apps, minus the star bits) and
listAppInsiderUserIds, which now has no production caller but carries a
documented displayed-vs-capability asymmetry that is under test.

* refactor(apps): address audit round 1 on the AppBlockReview removal

Four audit findings, none deploy-blocking.

F1 — the tolerant 3-field cursor decode was documented as a live back-compat
path ("an in-flight page resumes instead of 500ing"), and its guard did not
actually test that. Both halves are corrected:

  - The comment now says what is true. A 3-field cursor was only ever minted by
    the `rating` sort, and a client resuming that view sends `sort: 'rating'`
    with it — a value `marketplaceSortSchema` no longer accepts, so zod rejects
    at the router before the decoder runs. The tolerant split is defence in
    depth over a door the sort removal already closed, not the thing keeping
    that page alive.

  - The guard is now real. It asserted `items.length` off a mocked return plus
    a SQL-shape regex, but `capturedSql()` exposes only the assembled string
    with `?` placeholders — the bound values are exactly what it cannot see, so
    nothing could observe the decoded `cursorId`. Added `capturedValues()` and
    asserted the parsed resume tuple directly.

    Mutation-checked: rewriting the decoder to `decoded.slice(sep1 + 1)` (which
    concatenates the dead mean onto the id and resumes at the wrong tuple)
    SURVIVED the old guard and now dies on this guard's own assertion —
    `expect(values).toContain('ab_5')`, 1 of 19 tests failing, the other 18
    still green so the kill is attributable to this assertion rather than to
    some other test's error.

F2 — the migration header undercounted the read paths and did not say that
applying it forecloses the revert. It named two; three queried the table at
base (listAvailable, getAppDetail and getFeaturedBlocks all projected
avg_rating off it). Header now states plainly that running the DROP makes the
PR-level revert a one-way door, and explicitly does not conflate that with the
store-grid rollback note, which stays safe.

F3 — regenerated the direct-mock allowlist with
scripts/test-perf/gen-mock-allowlist.mjs, dropping the entry for the test file
this PR deletes. The regeneration also sweeps in 43 entries that had accrued on
main since the file was last generated (all 43 exist at the base commit; the
canonical list is byte-identical, and totals are self-consistent again).

F4 — made the DROP's precondition executable instead of a comment asking a
human to run a COUNT. A DO block raises and aborts when the table is non-empty.
It carries its own to_regclass existence check so a re-run against an
already-dropped table stays a no-op, matching the IF EXISTS guards below it;
the COUNT is dynamic so it is never planned when the table is absent.

Verified against a throwaway PostgreSQL 17.10 cluster in all three states:
table absent (exit 0, no-op), table empty (exit 0, dropped), table with 3 rows
(exit 3, aborts with the row count, and the table, its rows and all three
indexes survive intact).

Full unit suite: 1519 files / 23963 tests passed, 0 failures.
typecheck: 0 errors. typecheck-tests-gate: 1030 errors/190 files at base ->
1010/188 here, unchanged by this commit.

* fix(db): make the app_block_reviews DROP guard hold under a plain psql -f

Self-review of the F4 guard added in the previous commit found it was only
effective under specific psql flags, which defeated its whole purpose.

A RAISE stops the rest of the FILE only when psql was invoked with
`-1`/`--single-transaction` or `-v ON_ERROR_STOP=1`. Under a plain
`psql -f migration.sql` — an entirely reasonable way to apply a
hand-applied migration — psql runs each statement in its own implicit
transaction and continues past the error: the guard raised, and the DROPs
then executed anyway.

Measured against a throwaway PostgreSQL 17.10 cluster before the fix: the
table and all 3 of its rows were destroyed and psql still exited 0. Silent
and total — the guard read as protection while providing none in that mode.

Wrapping the guard and the DROPs in one explicit BEGIN/COMMIT closes it. The
RAISE poisons the transaction, every later statement is rejected with
`current transaction is aborted`, and the COMMIT degrades to a ROLLBACK.

Re-verified across all 9 combinations of {plain `-f`, `-1`,
`-v ON_ERROR_STOP=1`} x {3 rows, empty, already dropped}: the table and its
rows survive every non-empty case, and the empty and absent cases drop and
no-op as intended. The header now also notes that without ON_ERROR_STOP psql
exits 0 even when the guard refused, so the exit code is not a usable success
signal and the operator should read the output.

* fix(db,test): stop the DROP guard hijacking the caller's transaction

Round 2's delta re-audit found that round 1's own fix introduced a worse
defect, plus one assertion that could not see the mutant it was written for.

1. The migration no longer issues BEGIN;/COMMIT;. Wrapping the guard and the
   DROPs in an explicit transaction closed the plain-`psql -f` fail-open, but a
   file that commits a transaction it did not open hijacks the caller's
   transaction state, and that fails destructively in two shapes an operator
   is likely to use:
     - `BEGIN; \i this; \i next; COMMIT;` under ON_ERROR_STOP=1 (the header's
       own recommended invocation, applied to more than one file). Our COMMIT
       ends the operator's transaction early, so when the second file fails
       the DROP is already committed and the table is gone anyway, together
       with any unrelated row the operator had written in that transaction.
     - `\set AUTOCOMMIT off` (the pgAdmin/DBeaver/Retool posture; Retool is a
       documented apply path here). The operator's ROLLBACK; prints only
       WARNING: there is no transaction in progress, exit 0.
   It also silently defeated `psql -1`.
   The two DROP INDEXes and the DROP TABLE now live inside the existing DO
   block as EXECUTE statements. A lone DO block is one statement and therefore
   atomic by itself, so the fail-open protection is retained without the file
   taking any position on the caller's transaction state.

2. The legacy-cursor test pinned the bound values as a multiset, not a tuple.
   toContain is order-blind, so a decoder that transposes its two return
   fields still binds both required strings, merely swapped into the wrong
   sides of (sort_key, ab.id) < (?, ?) -- a wrong page boundary, i.e. the
   silent skip/duplicate the guard exists to prevent. That mutant survived all
   19 tests. The assertion now pins the ordered tail of the bound array.

3. The header cited PostgreSQL 17.10. Production runs 18.3.

Verification, all on PostgreSQL 18.3 (server_version_num 180003, the
production server version):
  - 15-cell invocation matrix over {plain -f, -1, -v ON_ERROR_STOP=1, a
    failing-second-file batch, AUTOCOMMIT off + ROLLBACK} x {3 rows, empty,
    already dropped}: rows survive every non-empty case, empty/absent drop and
    no-op, and no case leaves the caller's transaction in a state it did not
    choose.
  - Negative control first: the guard-stripped file destroys all 3 rows under
    -f, -1 and ON_ERROR_STOP=1, so the harness can observe destruction.
  - The BEGIN;/COMMIT; draft reproduces the audited defect on 18.3: table
    dropped-and-committed with the operator's pending row committed alongside
    it, in both nested shapes.
  - Transposition mutant: 1 failed / 18 passed, failing on the new tuple
    assertion; the same mutant passes 19/19 against the previous toContain
    form. The author's original mutant (dead mean concatenated onto the id)
    still fails, on the same assertion.
  - pnpm typecheck: 0 errors. typecheck-tests-gate (already red on main):
    base 1030/190 vs HEAD 1010/188, the 68 unbaselined files identical.

* style(test): prettier-collapse the new cursor-tuple assertion

Formatting only, on the three lines added by the previous commit -- prettier
--check flagged them and CI runs that gate on changed files. Re-verified after
the reformat: 19/19 pass, and the transposition mutant still dies on this
assertion (1 failed / 18 passed, line 329).

* docs(db): correct four false or stale claims in the DROP migration header

Prose only. The SQL is byte-identical: stripped of comments and blanks, the
file matches dc5dbb8509 exactly (21 non-comment lines), and the 15-cell psql
matrix re-runs identically.

This file is the operating instructions a human reads immediately before an
irreversible DROP with no down migration, so a false sentence in it is a live
hazard rather than a typo.

1. The stated reason for EXECUTE-ing the three DDL statements was FALSE.
   It claimed they had to be dynamic so "nothing in this block should be
   planned against objects that may not exist on a re-run". Measured on
   PostgreSQL 18.6: written as plain PL/pgSQL against an absent table, the
   DROP INDEX/TABLE IF EXISTS statements complete at exit 0 with three
   "does not exist, skipping" NOTICEs. PL/pgSQL does not fail to compile a
   utility statement against a missing relation; only the COUNT genuinely
   needs to be dynamic-or-guarded, and the IF already guards it. The
   instruction itself was right and is kept, just as emphatic: the DROPs must
   stay inside the DO block because a single DO block is one statement and
   therefore atomic by itself, which is what lets the RAISE abort them
   without this file issuing any transaction-control statement of its own.
   A maintainer who tests a stated reason and finds it false has cause to
   distrust the whole block; that is the risk this closes.

2. "the empty/absent cases drop and no-op as intended" was true of the block's
   effect but not of the committed outcome in 2 of the 15 measured cells. The
   failing-batch and AUTOCOMMIT-off shapes against an EMPTY table both leave
   the table present with 0 rows, because the operator's own abort or rollback
   wins. That is correct and wanted, but an operator applying under
   AUTOCOMMIT off and then rolling back would have read the old sentence and
   believed the DROP landed. The header now separates the two and gives the
   confirming query.

3. Dropped the name of a third-party admin tool and the claim that it is a
   documented apply path for this database. civitai/civitai is public and that
   is unnecessary intel about a tool with production database access. The
   operational point survives: this migration is applied by hand, and many GUI
   SQL clients default to autocommit-off.

4. Moved an orphaned "IF-EXISTS guards so a manual re-run is a no-op" comment
   down to the guards it describes, which had moved ~95 lines away.

Verified on PostgreSQL 18.6, scratch cluster, against the same matrix the
header cites: {plain -f, -1, ON_ERROR_STOP=1, BEGIN batch with a failing
second file, AUTOCOMMIT off + ROLLBACK} x {3 rows, empty, already dropped}.
15/15 cells identical to dc5dbb8509 on exit code, table presence, row count,
index count and the operator sentinel row. Harness validated first with a
negative control: the same matrix run against a guard-stripped copy destroys
all 3 rows under -f, -1 and ON_ERROR_STOP=1, so a green matrix here is not
vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEa6GDJyTiu2R146ndYsLK

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 21:10:32 -05:00
Zachary Lowden afe84cf7c8 ci: shard the unit suite across 4 runners, 10.4m -> 3.8m (#4392)
Measured from the Actions API (n=12): the job was 555.5s of test work plus
66.5s of fixed overhead = 622s, the 10.4m median, and 3.4x the next-slowest
job. T(N) = 66.5 + 555.5/N puts N=4 at ~3.4m.

Measured after: 10.3m -> 3.8m on the critical path (2.7x) at +36%
runner-minutes, reproduced across two runs with a 1.12-1.14x shard spread.
N=4 rather than more because `App unit tests + typecheck` is 2.9m — past N=5
sharding optimises something that is no longer the bottleneck.

Ships with a positive control, `scripts/ci/assert-shard-ran.mjs`. Sharding
adds a failure this job did not have: a `--shard=i/N` selecting no files exits
0 and reports green, and four green checks look identical whether they ran
22,000 tests or none. The guard asserts per shard that work happened, counting
EXECUTED assertions rather than `numTotalTests` (which includes skipped, so an
all-self-skipping shard would pass a total-based floor), and its bounds scale
with `total` so a change to the matrix does not trip them.

It earned its keep immediately: on the first run it failed all four shards
while `Unit tests` reported SUCCESS. `pnpm run … -- --shard=…` forwards the
`--` literally, vitest DISCARDS everything after it, and so `--shard` and
`--outputFile` were both silently dropped — each runner executed the entire
suite (7m30s-9m54s per step) and wrote no report. Without the control this
would have merged as four green checks that tested nothing verifiable.

A subsequent adversarial audit found three further claims that measurement did
not support, all fixed here: bounds hardcoded off a ten-day-stale suite size
that would have tripped within weeks and blamed the wrong cause; a comment
asserting post-`--` args become filename filters when they are discarded; and
a "mutation-verified" claim that five of twelve mutants walked through. Four
are now covered by named cases; the fifth was unreachable dead code and is
removed rather than papered over with a test that could not kill it.

Final sweep, re-run after the Prettier pass: 10 mutants, 10 killed, each by its
own named test, with a no-op negative control that correctly survived. Suites
20/20. Verified live across two runs; the typecheck-scripts gate was confirmed
to have executed rather than assumed green.

Not verified: behaviour on a push to `main`, where `continue-on-error` is false
and a red shard renders as a genuine failure. That path only exercises on merge.
2026-08-25 15:59:41 -05:00
briant 6c9904b389 fix(tests): unbreak the unit suite on Windows — 16 failures across 7 files, and two of them could never pass
Measured on a Windows checkout: 7 files / 16 tests fail identically before and after any local
change, so every Windows developer has to hold "these are expected" in their head to read a suite
result. 868kubhjr tracked four of them; the set had grown to seven.

Five are the separator bug that ticket describes — `path.relative` yields backslashes and the
assertion compares against forward-slash literals — fixed with `.split(path.sep).join('/')`, the
prescribed shape. `standalone-boot-graph` is the same fault in a Node error string, so it matches
`/esm[\/]index\.js/` instead of a literal.

Two were NOT cosmetic, and would have kept failing after the separator fixes:

  * `appListingStatChips` read `new URL(…).pathname`, which is `/C:/…` on Windows, so `readFileSync`
    resolved it against the drive root as `C:\C:\…`. Now `fileURLToPath`.
  * `dev-server-daemon-port` spawned `await import("C:\…")`, which node rejects with
    ERR_UNSUPPORTED_ESM_URL_SCHEME — it drives a child and asserts exit 0, so on Windows it could
    never observe the property it claims. Now `pathToFileURL`. Verified the harness is live again:
    the real daemon with a bad port exits 0, a deliberately throwing module exits 1.

`source-nul-bytes` needs symlinks, which Windows refuses without SeCreateSymbolicLinkPrivilege.
Junctions need no privilege and the walk cannot tell them apart (`lstat().isSymbolicLink()` is true,
dangling ones included), so the three controls still run rather than skipping.

`typecheck-apps` needed the script itself. `spawnSync` skips PATHEXT so a bare `pnpm` is ENOENT on
Windows, and naming `pnpm.cmd` is EINVAL — node refuses to spawn a .cmd without a shell. Hence
`shell` on win32 only; ubuntu CI takes the false branch and is byte-identical.

That shell brings a hazard, so it does not arrive alone: `shell: true` applies NO per-argument
quoting, and `app.name` is read raw from `apps/*/package.json`. A name containing `&` ends the
command under cmd.exe, so cmd returns the trailing command's exit code and a genuinely-red typecheck
reports 0 — reproduced, and precisely the reports-success class this script exists to close. Names
are now validated with a hard error.

The suite's fake pnpm answered by exit code alone, so it could not see WHAT the script asked pnpm to
run: mutating the spawn to `--filter totally-wrong-<dir> run lint` left all tests green. It now
echoes argv and one case asserts it, which also pins argv fidelity through cmd.exe.

Windows unit suite: 16 failures -> 0. Nothing changes on ubuntu CI.

Closes 868kubhjr

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:18:31 -06:00
Zachary Lowden 69b5ce5497 fix(typecheck): typecheck scripts/ — 15 non-test files now covered, scripts/__tests__ excluded like src/ (#4189)
* fix(typecheck): close the scripts/ blind spot — 31 files now checked, 5 quarantined behind a ratchet

The root tsconfig named `scripts/local-dev/*.ts` in BOTH `include` and
`exclude`. Exclude wins, so nothing under `scripts/` was typechecked at all —
not even the one directory the include list appeared to name.

Measured on 23cecb57c0: `tsc -p tsconfig.json --listFilesOnly` contained 0
files under `scripts/`, and a deliberate `const x: number = 'nope'` planted in
`scripts/oneoffs/parse_header.ts` produced `OK — 0 type errors`, exit 0. Both
CI tiers run that config, so the gap could not close on its own.

Adding `scripts/**/*.ts(x)` puts 36 files in the program and surfaces 198
pre-existing errors, all in 5 files. The other 31 are clean and are checked
from now on by the real `pnpm typecheck` in both tiers, as is every file added
under `scripts/` later.

The 5 are excluded BY NAME — enumerable, and shrinkable in a reviewable diff —
and are NOT left unchecked: `tsconfig.scripts.json` is the root config without
those entries, and `scripts/ci/typecheck-scripts-gate.mjs` ratchets their
per-file counts. Excluding them and walking away would recreate the very
defect being fixed.

164 of the 198 are one file with one cause: it imports an untyped `.mjs` with
no JSDoc, so under `allowJs` tsc infers `request({ worktree, args = [] } = {})`
as `{ args?: never[] }` (40x TS2353) and the run object's `null` initializers
as type `null` (123x TS18047/TS2531). Not 164 defects; typing that module is
the fix and is left to a follow-up.

The gate mirrors `typecheck-tests-gate.mjs` and IMPORTS its five controls
rather than copying them (`classifyEmptyAllowance` gains an `envName`/
`gateScript` parameter, defaulted, so both gates report their own). What is
genuinely different lives in `typecheck-scripts-compare.mjs`: the positive
control anchors at the repo root instead of matching the substring `/scripts/`,
because this repo pulls `.claude/skills/**/scripts/*.mjs` into the program
transitively and every vendored package has a `scripts/` directory — a
substring control could stay green with the whole measured tree gone.

`tsconfig.tests.json` is updated in step: the sibling gate's config-drift
control caught the base-exclude change immediately and refused to run, which
is the control working.

Verification
- positive control: planted error in scripts/oneoffs/ -> 1 error, exit 2 under
  the new config; the SAME error under main's config -> "OK — 0 type errors",
  exit 0.
- ratchet proven both ways against the real tsc: worsening gen_seed 2->3
  BLOCKS (exit 1, names the delta); clearing it PASSES (exit 0, "1 file(s) now
  clean").
- 42 new tests; 10 mutants (one per control) each killed by their specifically
  named test, with an unmutated green control on both ends of the battery.
- root `pnpm typecheck`: 0 errors. Both gate suites: 189 passed.

Known-unrelated: the sibling tests gate is already BLOCKED on main (869/160 vs
its 784/140 baseline) — verified identical at the base ref, not caused here,
and it is not wired into CI.

* fix(typecheck): exclude scripts/__tests__ from the root program; gate becomes a report, not a CI block

#4181 landed scripts/__tests__/dev-server-daemon-port.test.ts and turned both
the root typecheck and this PR's gate red. Reproduced locally on the merged
head: 4 errors, all in that one new file, all TS2345 "not assignable to
parameter of type 'ProcessEnv'".

The gate was working as designed. The design was wrong.

Root cause, read at the source rather than inferred: scripts/__tests__ exists
to test the untyped `.mjs` modules under .claude/skills and .claude/hooks by
importing them. None carries JSDoc, so under `allowJs` tsc infers their
signatures from the implementation and every caller inherits the result.
`daemonPortsGuarded(env = process.env)` infers as requiring a full ProcessEnv,
so `{ DEV_DAEMON_PORT: '9555' }` is an error. It is the same mechanism as the
164 errors already recorded for dev-server-test-queue.test.ts.

That makes the failure systemic, not a backlog: 5 of the 15 files there carry
this class, and ALL 15 were added within one month. Quarantining files
one-by-one would have fired several times a month, always for a defect in a
dependency the test author did not write, and the fastest remedy would have
been to add another exclude line — training the reflex the quarantine existed
to prevent.

So scripts/__tests__/** is now excluded as a DIRECTORY, exactly as
src/**/__tests__/** already is, and the gate is no longer wired into CI. Its
CI job is removed; .github/workflows/lint.yml is byte-identical to main again.

What this costs, stated plainly: scripts/__tests__/** is UNCHECKED. That is the
pre-existing state, not a regression, and it is measured rather than hidden —
tsconfig.scripts.json re-includes it and the gate reports per-file counts on
demand (202 across 6 files).

What it still buys, measured by --listFilesOnly rather than asserted: 15
non-test files under scripts/ (oneoffs 6, local-dev 3, test-perf 2, root 2,
metric-migration 2) go from unchecked to checked by the real `pnpm typecheck`
in BOTH tiers, along with any file added to those directories later. The
earlier claim of "31" was wrong — it counted scripts/__tests__ files and
transitively-imported .mjs.

gen_seed.ts stays excluded by name: its 2 errors are a genuinely broken import
(`~/server/db/notifDb`, deleted in 927e31bfee). notifDbWrite now lives in
apps/notifications behind a different API, so restoring it is a cross-workspace
migration, not a one-line fix.

Verification
- root typecheck on the merged head: 0 errors.
- positive control: planted error in scripts/oneoffs/ -> caught (1 error,
  exit 2), so the remaining coverage is real and not a vacuous glob.
- negative control: a NEW deliberately-broken test file dropped into
  scripts/__tests__/ -> 0 root errors. The tripwire is gone.
  The gate still reports that same file, so it is excluded, not invisible.
- ratchet both ways against real tsc: gen_seed 2->3 BLOCKS (exit 1, names the
  delta); untouched tree PASSES (exit 0).
- new invariant test, watched fail: a baseline entry for a root-COVERED file is
  rejected by name, so this baseline cannot be used to park a failure that
  `pnpm typecheck` is actually red on. Plus a negative control proving its
  predicate can reject.
- drift-control mutant re-run against the new glob-shaped quarantine: killed by
  3 tests including the end-to-end one.
- 190 tests pass across both gate suites; prettier and eslint clean.
2026-08-20 11:38:38 -05:00
Zachary Lowden b8f84204d7 fix(auth): fix the two type errors gating apps/auth, and bring it into the CI typecheck gate (#4188)
apps/auth was the last app excluded from `scripts/ci/typecheck-apps.mjs` (#4156),
on two pre-existing errors. Both are fixed at the root, not silenced, and the
exclusion is gone — the gate now covers all 7 apps.

providers.ts:165 — Parameter 'p' implicitly has an 'any' type
  The stub entry's key is a COMPUTED property (`['stub' as ProviderId]`) whose type
  is the whole ProviderId union rather than one literal, so TS cannot match it to a
  member of `Record<ProviderId, ProviderDef>` and drops contextual typing for the
  value. `satisfies ProviderDef` restores it. Measured, both arms: with `satisfies`,
  misspelling a required field (`scope` -> `scopes`) is caught; with it removed, the
  same misspelling is silently accepted and only the implicit-any resurfaces. So the
  whole entry was unchecked against ProviderDef, not just that one parameter.

establish-session.test.ts:100 — Property '_store' does not exist on type 'never'
  The Cookies stub was cast `as never` to satisfy establishSession's parameter, but
  `never` has no properties, so reading `_store` back in an assertion was itself an
  error. Cookies has exactly five members, so the stub now implements all of them
  and needs no cast at all.

Removing the exclusion breaks the guard suite, which is the real work here: six of
its eight cases put an `auth` app in every fixture purely to satisfy the
stale-exclusion guard, so an empty map made them fail for reasons unrelated to what
they test. Rather than delete those guards, the exclusion map is now a parameter of
an exported `runTypecheckApps({ excluded })`, and the tests drive it with a synthetic
app. Deliberately a function parameter and not an env var: an env-var override would
be a live way to un-gate an app in CI, which is the failure class the script exists
to close. All ten cases now pass regardless of what the shipped map contains.

Also neutralises the "six apps" counts in the script header and workflow comment,
which this change would otherwise make wrong, and applies prettier to two
pre-existing unformatted lines in the touched files.
2026-08-20 11:07:40 -05:00
Zachary Lowden 23cecb57c0 ci: run per-app typecheck scripts in CI (#4156)
* ci: run per-app typecheck scripts in CI

The root tsconfig.json include is:

    scripts/local-dev/*.ts src packages/*/src tests .next/types/**/*.ts

No apps/ entry, so `pnpm run typecheck` does not reach any sibling app.
Each app defines its own typecheck script but none were run by any CI job.

Six of seven apps pass today; wire them up as steps in the existing `apps`
(App unit tests) job, which already has the workspace installed.

  app               script                          result
  event-engine      tsc --noEmit -p tsconfig...     clean
  notifications     tsc --noEmit                    clean
  orchestrator-gateway  tsc --noEmit                clean
  storage           tsc --noEmit                    clean
  creator-studio    svelte-check --tsconfig ./...   clean
  moderator         svelte-check --tsconfig ./...   clean
  auth              svelte-check --tsconfig ./...   2 errors + 1 warning

apps/auth is excluded from the CI wiring. Its pre-existing errors:

  src/lib/server/auth/providers.ts:165
    Parameter 'p' implicitly has an 'any' type.
  src/lib/server/auth/__tests__/establish-session.test.ts:100
    Property '_store' does not exist on type 'nev
  src/routes/login/+page.svelte:28
    state_referenced_locally (warning)

Ticket: 868kt8pfu

* ci: make the app typecheck prove it ran, and report every failing app

Review follow-up on the six `pnpm --filter <pkg> run typecheck` steps. Two ways they could
report SUCCESS while checking nothing or hiding work, plus a blame problem.

🔴 `pnpm --filter <name> run <script>` EXITS 0 WHEN THE FILTER MATCHES NOTHING. Measured on
pnpm 10.28.1: a bogus package name prints "No projects matched the filters" and returns 0.
Six hardcoded package names are six chances for a rename to turn a gate into a green no-op
— the same shape as `prettier --check "$FILES"` reporting "All matched files use Prettier
code style!" across zero files. Nothing in the six steps could notice.

🔴 A NEW app under `apps/` is simply absent from a hardcoded list. The gate stays green and
the app is unchecked — this PR's own hole, reopened by the next person to add an app.

So the app set is now a LEDGER READ FROM DISK: every `apps/*` with a `typecheck` script must
run, each run must be proven to have selected a real package, and `auth`'s exemption is an
explicit entry that hard-errors if it goes stale. Adding an app wires it automatically. This
mirrors `scripts/ci/assert-workspace-suites-ran.mjs`, which exists for the same reason on
the vitest side.

Failures are COLLECTED rather than fatal on the first. Six sequential steps abort the job at
the first red app, so a shared type change breaking four of them reports one.

The job is renamed `App unit tests` -> `App unit tests + typecheck`. The typecheck steps were
added under the old name, so a type error would have rendered in the checks list as a failing
unit-test job, pointing the reader at the wrong suite. Safe: neither `main` nor `release` has
any required_status_checks — re-verified 2026-08-20 against the branch-protection API rather
than inherited from the note already in this file.

Kept in the `apps` job on purpose: `pnpm install` is the expensive part and this job has
already paid for it. A matrix would give parallel legs and per-app check names for the price
of six installs; the rename plus collected failures buys most of the legibility for free.

Eight guard tests in `scripts/__tests__/typecheck-apps.test.ts`, each driven against a stub
apps/ tree with a fake `pnpm` on PATH so the no-match and failure paths are exercised for
real: happy path, stale filter caught, red app, failures collected not masked, empty
discovery refused, new app auto-wired, stale exclusion hard-errors, auth excluded while the
rest still run. All 8 pass; the first draft had six failing for the wrong reason (fixtures
omitted `auth`, so the stale-exclusion guard fired first) — fixtures fixed, not the guard.

Two things deliberately NOT changed:
- The Svelte apps' bare `typecheck` script is fine as-is. Their tsconfig extends
  `./.svelte-kit/tsconfig.json`, absent from a fresh checkout, which looks like a vacuous
  green waiting to happen. It is not: `"prepare": "svelte-kit sync"` runs during
  `pnpm install`. And if it ever stops, svelte-check FAILS LOUDLY rather than passing —
  verified by moving `.svelte-kit` aside, which gave exit 1, "Cannot read file
  .svelte-kit/tsconfig.json", `1 FILES 1 ERRORS`. An earlier review comment of mine claimed
  it would go silently vacuous; that was wrong, and switching to `check` is unnecessary.
- `scripts/__tests__/` is outside every typecheck program (root `include` carries only
  `scripts/local-dev/*.ts`), so this new test file is unchecked — as are the 12 already
  there. Not introduced here; it is the `scripts/` half of this ticket's widening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: prettier the two new files

The `Prettier (added files)` step is BLOCKING by design — a new file has no pre-existing
findings, so holding it to the rules is free. Both new files failed it and I did not run the
formatter before pushing. Reproduced locally with `prettier --list-different` (both listed),
fixed with `--write`, and re-ran the 8 guard tests after the reformat: still 8/8, so the
formatting did not disturb the fixture strings or the fake-pnpm heredocs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: DevPod Agent <agent@devpod.local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:18:59 -05:00
Zachary Lowden f419a64461 chore(deps): delete the inert next@16.3.1 patch and correct the comments about it (#4086)
Next 16.3.1 is the first release containing upstream's SVG-loader fix (vercel/next.js#96681) — it ships 'VipsForeignLoadSvg' in the sharp.unblock list itself, in both the CJS and ESM image-optimizer copies. Our local patch was therefore inert: the installed files are byte-identical (sha256) to the published tarball, and the patch does not even apply (its hunk context wants Png immediately followed by Tiff; 16.3.1 has Svg between them).

It looked healthy because pnpm silently no-ops an already-applied patch — install exits 0, prints no warning, and still creates a patch_hash= virtual-store directory.

Deletes the patch and its patchedDependencies entry (@mantine/hooks untouched), regenerates the lockfile, and corrects three comments that asserted upstream still lacked the entry.

The CI guard stays: it asserts the outcome (the installed Next unblocks the SVG loader), so it keeps protecting against a future Next regressing this the way 16.3.0 did. Verified post-removal: guard green, still goes red when the loader entry is stripped from both installed copies, and /api/og renders PNGs on both paths on the preview build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:20:51 -05:00
Zachary Lowden c78f85dd5a fix(deps): Next 16.3.0 → 16.3.1, and make the compiled-branch gate hard (#3983) (#4075)
* fix(deps): Next 16.3.0 -> 16.3.1, and make the compiled-branch gate hard (#3983)

This is the fix for #3983. The defect was in the bundler, not in our source.

Turbopack's value analyzer in 16.3.0 models a bare `return someAsyncFn()` tail
call as `Promise<Promise<T>>`. That is always truthy, so a caller that `await`s
it is analysed as always-true and every statement after the resulting
conditional is eliminated as dead code. `isAppListingsEnabled` ends in exactly
that shape, which is why `resolveStoreVisibilityScopeUninstrumented` lost two of
its three returns, fell off the end, and produced `undefined` for every
non-privileged caller — served as the whole catalog on one read path
(`?? 'full'`) and as an empty store on the other (`?? 'none'`).

Upstream: vercel/next.js#96601 "[turbopack] Collapse nested promises in the
analyzer", backported as #96675, shipped in 16.3.1.

MEASURED, not inferred. Two production builds of THIS commit on one machine,
same Node 24.19.0, differing only in the pinned Next:

  16.3.0  async function S(e){if(await p(e))return"full"}
  16.3.1  async function w(e){return await c(e)?"full":await y(e)?"public-external":"none"}

Both read out of the emitted `.next/server` chunks by source-map attribution and
identified by their source neighbour `STORE_SCOPE_FLAGS`, never by minified
name. Note the fixed form is a TERNARY — `grep 'return"public-external"'`
returns zero on the FIXED build too, which is why the gate reads source maps.

`package.json` already allowed 16.3.1 (`^16.3.0`); only the lockfile pinned
16.3.0, so the substance here is the lockfile. The floor is raised to `^16.3.1`
so a fresh resolution cannot land back on the broken compiler. `patches/next@…`
is renamed and its `patchedDependencies` key updated — that patch is the
unrelated libvips/SVG one-liner (vercel/next.js#96681), it still applies
cleanly, and 16.3.1 still does not carry the loader entry upstream, so it stays.

`--warn-only` is removed from `scripts/assert-compiled-branches.mjs` in the same
commit. It existed only because the 16.3.0 build genuinely violated the gate,
and a permanently-red gate trains everyone to click through. Keeping the bump
and the strictness atomic means the gate's strictness always matches the
toolchain: a revert of the bump turns it red instead of silently passing.

Verified on this commit:
  - gate exit 0 (hard, no --warn-only) against the 16.3.1 build
  - gate exit 1 against a 16.3.0 build of the same tree — watched red
  - `scripts/ci/assert-next-svg-patch-applied.mjs` OK on both installed copies
  - unit suite 1149 files / 18,123 tests passed, 0 failed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(build): ship @swc/helpers' module-sync branch into the standalone image (#3983)

The bump built clean, passed every source-level gate — unit suites, typecheck,
ESLint + Prettier, schema drift, the event-engine pin, and the now-hard
compiled-branch gate — and the container could not boot:

  Error: Cannot find module '.../@swc/helpers/esm/_interop_require_default.js'
      at ... next/dist/server/require-hook.js
    code: 'MODULE_NOT_FOUND'

Same shape as the defect this PR exists to fix: a correct source tree producing
a broken artefact, invisible to everything that reads source.

ROOT CAUSE, measured on the published artefact rather than inferred.
`output: 'standalone'` does not ship node_modules; it ships the subset
@vercel/nft traced. nft resolves a bare specifier under the `require`/`default`
conditions. Node (>= 22.10) additionally honours `module-sync` for a CJS
`require`. When a package's `exports` map points those two at different files,
the build traces one and the running process asks for the other.

next/dist/shared/lib/constants.js does
`require('@swc/helpers/_/_interop_require_default')`, reached from the generated
server.js via `next` -> config.js -> constants.js, i.e. before any application
code. The relevant delta is not next itself but next's own dependency:

                                 next 16.3.0        next 16.3.1
  @swc/helpers                   0.5.15             0.5.23
  ./_/_interop_require_default   {import,default}   {module-sync,webpack,import,default}
  require.resolve() under CJS    cjs/...cjs         esm/...js

Both resolutions were RUN, not reasoned about. nft still traced the cjs file, so
the published image carried that package as exactly cjs/_interop_require_default.cjs,
cjs/_interop_require_wildcard.cjs and package.json — no esm/ directory at all.
Adding only the missing esm/ directory to that exact image, nothing else changed,
boots it: "Next.js 16.3.1 ... Ready".

FIX. `outputFileTracingIncludes` force-includes BOTH condition branches of EVERY
installed @swc/helpers copy — not the one file missing today, because which
helper Next requires and which branch each resolver picks are upstream details
that move. Globs are version- and hash-agnostic (`@swc+helpers@*`), plus a flat
form for a hoisted layout. ~950 KB per copy. Verified on a local production
build of this commit: both copies land in .next/standalone with complete esm/
(108 and 105 files) and cjs/, and next's virtual store links the 0.5.23 copy.

Attached to three existing API-route keys rather than a `'**'` key.
copyTracedFiles unions every entry's traced set into the single
.next/standalone node_modules, so one entry carrying it is enough, while `'**'`
would make all 572 entries read/parse/rewrite their .nft.json concurrently —
826 MB of JSON in one Promise.all — on a build already tuned against OOM.

GATE, because a glob is a silent no-op once it stops matching.
scripts/ci/assert-standalone-boot-graph.mjs runs in the Dockerfile's RUNNER
stage: the first gate in that file to run against the runtime filesystem rather
than the build tree, and the only one that can see this class of defect. It
reads the GENERATED server.js for the specifiers that process requires at module
scope and loads them in a child rooted at the shipped tree — no package, version,
virtual-store path or patch hash hardcoded, so it keeps covering this after the
next bump. Exit 2, never 0, when it cannot observe its input. It must run in the
runner and not the builder: /app there is byte-for-byte what ships, whereas the
builder's complete node_modules sits above .next/standalone on the resolution
path and can satisfy a require the image cannot.

Watched red and green on real artefacts, not only fixtures:
  - exit 1 with this exact MODULE_NOT_FOUND against the published broken image;
  - exit 0 against the same image with only the esm/ directory added;
  - exit 0 against the local production build of this commit, isolated from any
    parent node_modules;
  - exit 1 again after deleting exactly esm/_interop_require_default.js from
    that same local build.
src/tests/build/standalone-boot-graph.test.ts pins the MECHANISM (a
module-sync/default split with only the default branch present) rather than the
package, and all 5 of its cases were watched to fail against a neutered gate.

NOT VERIFIED. Nothing about production: this is only true of production once it
merges, is promoted main -> release, is built and is serving. The gate covers the
ENTRYPOINT's require graph; route chunks load lazily, so a condition mismatch
reachable only from a route would still surface at request time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: retrigger preview

The previous preview run (pr-preview-4075-b2wnw) never scheduled: its
build-image and typecheck pods sat Pending with ExceededNodeResources for
82 minutes and the run hit the 1h30m PipelineRunTimeout. No verdict was
produced — this was build-pool capacity contention, not a code failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:07:05 -05:00
Justin Maier 4f619f0f39 fix(tests): clear the three type errors that reddened the typecheck-tests gate (#3956)
* fix(tests): clear the three type errors that reddened the tests gate

`pnpm typecheck` excludes `src/**/__tests__/**`, so these landed green:

- minimax-h3-license: `BaseModel` is re-imported, not re-exported, by
  `~/server/common/constants`. Take it from its origin.
- cosmetic-phash: BigInt literals need ES2020; the repo targets ES2018.
- sticker-placement: `createStickerPlacement` grew a required `spendType`
  and the shared fixture never did, so 17 call sites disagreed with it.

The fixture carries 'yellow' deliberately — the escrow test asserts
'green', so a placement that dropped the caller's currency cannot pass by
matching the fixture. Typing the fixture as `CreateStickerPlacement` means
the next required field fails once, at the fixture, not at every call site.

Baseline regenerated: sticker-placement leaves it entirely (16 -> 0), and
remix-gallery drops 29 -> 28 from an unrelated merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(sticker-placement): make the currency guard structural, not a comment

The escrow test's non-vacuity depended on the fixture default differing
from the value it asserts. That was a comment; now the two are named
constants and the test asserts they differ, so a future edit that collides
them fails on the spot instead of going permanently green against a
service that stopped forwarding the caller's currency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 08:32:56 -06:00
Zachary Lowden dacc42e324 fix(ci): typecheck-tests gate matched __tests__ with the HOST separator (#3884)
`tsc --listFilesOnly` emits forward slashes on every platform, Windows
included. The gate built its positive-control marker from `path.sep`, so
on Windows the marker was `\__tests__\` and matched ZERO of the 948
`__tests__` lines tsc printed -- every run there died with
`CANNOT MEASURE - POSITIVE CONTROL FAILED ... contains 0 file(s)`.

`scripts/**/*.test.ts` is in the `unit` project's include, so 18 of the
134 cases in scripts/__tests__/typecheck-tests-gate.test.ts were red and
`pnpm run test:unit:run` was RED on main for everyone on Windows. On
Linux `path.sep` is `/`, so CI was green -- which is why it merged clean.
Reported by a developer hitting it locally.

Same class, second site: parseDiagnostics normalised diagnostic paths
with `split(path.sep)`. Those strings are the keys of the committed
baseline JSON and the input to `isGatedTestFile` (a `/`-anchored regex),
so a backslash path was both unmatchable against the baseline and
silently dropped as "outside src/**/__tests__/".

Fix: one canonical separator, applied to the INPUT and never derived
from the host. `toPosixPath` / `isTestFileLine` / `countTestFilesInProgram`
move into the pure module -- the predicate previously existed only inline
in a script reachable by spawning a process, which is why its platform
assumption was never exercised. The `node_modules` exclusion normalises
too; fixing only the marker leaves a control that passes by counting
vendored test files. Neither module now reads `path.sep` in code.

Coverage: the platform was a PINNED dimension -- every test ran on Linux,
where the marker happened to equal the shape tsc emits, so no number of
extra cases written the same way could have found this. The new cases
never read `path.sep`; they name both separators via path.win32.sep /
path.posix.sep and drive the gate end to end with a backslash-shaped
`--listFilesOnly` listing, which reproduces the reporter's exact failure
on Linux. 11 failed / 135 passed (146) before the fix, 146/146 after.
9-mutant battery: 7 breaking mutants died, both equivalent controls
survived.

Behaviour on Linux is unchanged: the gate reports the same 963 test
files / 843 errors across 146 files with and without this change, and a
--write-baseline regeneration is byte-identical under both.
2026-08-13 13:18:10 -05:00
Zachary Lowden a7d7c5da46 PROPOSAL (do not merge): pnpm typecheck is structurally blind to src/**/__tests__/ — 801 errors, and a ratchet to stop the inflow (#3868)
* proposal: pnpm typecheck cannot see any file under src/**/__tests__/

`tsconfig.json` excludes `src/**/__tests__/**`, so the typecheck is not lenient
about test files — it is structurally blind to them. A planted type error in one
is reported as "OK — 0 type errors", exit 0.

Measured against main @f19574a9cb: 801 type errors in 141 of 870 test files
(83.8% of the tree is clean). Zero of the 801 land outside a __tests__ directory
and zero are TS2304, so the measurement config adds no artifacts of its own.

The exclusion is not a perf decision: the line was added exactly once, in the
same commit that added the repo's FIRST src/**/__tests__/ file, whose subject is
about neither. Including tests costs +64s cold / +5s warm here.

The debt is not legacy — 138 of the 141 dirty files are under three months old
and 40 arrived in the first twelve days of August, so the gate's value is in
stopping the inflow, not in ratcheting down sediment.

Proposes a per-file ratchet in the shape this repo already uses for schema
drift, with a proof of concept shown to pass on main, block on a planted error
(naming the file, 801 -> 802), and pass again on revert. Nothing is fixed here
and no existing file is modified; the PR is purely additive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* proposal: make the typecheck-tests gate unable to report a confident zero

The audit finding on this PR was about the INSTRUMENT, not the measurement.
This gate's whole thesis is that a checker must not be able to report a
confident zero — and this gate could, in four separate ways, all sharing one
root cause: it inferred "clean" from "I parsed no diagnostics".

Demonstrated live against this repository, not against a stub. Before -> after:

  TYPECHECK_HEAP_MB=abc  (wrapper exits 2, no marker)  PASS/0 -> exit 3
  wrapper exits 127      (binary missing)              PASS/0 -> exit 3
  wrapper exits 134      (V8 heap abort)               PASS/0 -> exit 3
  tsc `pretty` output, 801 REAL diagnostics            PASS/0 -> 801 across 141
  --write-baseline while the check cannot run   writes 0/0, exit 0 -> exit 3,
                                                file untouched

The old "CRASH IS NOT CLEAN" control was SPELLED, not structural: it matched the
literal string TYPECHECK CRASHED, which scripts/typecheck.mjs emits from exactly
one of its four failure exits. The header comment claiming a crash could "never"
read as 0 was false as written; it is corrected.

Five structural controls replace it: an exit-status truth table (non-zero exit +
zero parsed diagnostics is a FAILURE TO RUN, never a clean tree); a parse control
(--pretty false forced, BOTH diagnostic formats parsed, ANSI stripped, and output
containing `error TS` that parses to zero refused); a plausibility check (a zero
against a non-empty baseline is refused, escape TYPECHECK_TESTS_ALLOW_EMPTY=1);
a positive control that now runs through the SAME wrapper/-p/flags as the verdict
run, with its floor derived from a test-file count stored in the baseline (938 ->
844) instead of a magic 400 that let 57% of the test tree vanish unnoticed; and a
config-drift control asserting tsconfig.tests.json is the base exclude list minus
exactly one entry.

Also: the outsideTests predicate now matches the ACTUAL exclusion
(src/**/__tests__/), so the 68 packages/*/src/**/__tests__ files — which are in
BOTH programs — are no longer claimed by this gate.

New: scripts/ci/typecheck-tests-compare.mjs (the pure half) and a 78-case suite in
scripts/__tests__/typecheck-tests-gate.test.ts covering the branches the three
proof-of-concept runs never touched: a baselined count rising, falling, going
clean, deleted, renamed; two files sharing a basename; empty/malformed/absent/
wrong-config baselines; both diagnostic formats; every cell of the truth table.
A pure `git mv` still BLOCKS by design — the gate cannot see file content — but it
now names the probable former path instead of reading as a regression.

Thirteen mutations, one per guard, were each confirmed to turn the suite red for
THAT guard's cases; tree checksum-verified pristine afterwards.

Measurement re-verified unchanged: 801 errors / 141 files, per-file map identical
to the committed baseline, --write-baseline byte-stable across two runs. The three
proof-of-concept runs re-run with real counts: PASS 801/141, BLOCKED 802/142
naming the planted file (while `pnpm typecheck` on the same tree says "OK — 0 type
errors", 0 error TS lines, 0 mentions of the file), PASS 801/141 on revert.

Doc: §2's history is CORRECTED. It claimed exif-parser.test.ts was "the first file
the repository ever put in a src/**/__tests__/ directory" and that "nobody decided
that test files should not be typechecked". Both are false — eight such files
already existed at 1a7f89df98^, the earliest 2026-01-23, across six commits by
different authors. The likelier reading, labelled as inference: the typecheck was
already red with those files in the program and the exclude was a deliberate,
undocumented silencing. That strengthens the ratchet recommendation rather than
weakening it — deleting the exclude was effectively rejected once already.

Still a proposal: no CI wiring, no attempt to fix the 801, tsconfig.json and
`pnpm typecheck` untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* proposal: close the four ways the gate's own self-checks could still be defeated

A delta re-audit confirmed the previous round closed every finding structurally,
and then found four ways the NEW controls could be defeated. Each is demonstrated
before and after, against this repository rather than a stub.

F3 (the CI blocker). TYPECHECK_TESTS_ALLOW_EMPTY=1 with a wrapper that measures
nothing produced `0 error(s) across 0 file(s) (baseline: 801 across 141)` ->
`141 file(s) now clean` -> PASS, exit 0, with nothing in the output recording
that a control had been switched off. One env var in one job definition
reinstated the exact failure this gate exists to eliminate. The hatch is now
honoured on --write-baseline ONLY (so it cannot make a verdict run green),
REFUSED rather than ignored on a verdict run, and requires a written reason
instead of `=1` which is echoed into the output. Scoping it this way costs
nothing: on the day the tree is genuinely clean the required action IS to
regenerate the baseline, after which the control never fires again.
  before: rc=0, "PASS — no new or worsened test-file type errors"
  after:  rc=3, "REFUSING TO RUN — ... is set on a VERDICT run"

F1. The plausibility floor was a cliff at exactly zero. classifyRun({status:2,
total:5, sawAnyErrorTs:801}) returned ok, so a 99% undercount passed and printed
"136 file(s) now clean". The invariant is not "did it parse SOMETHING" but "did
it account for EVERY line claiming to be a diagnostic": parseDiagnostics now
returns `unparsed` plus a verbatim sample, and any divergence is refused.
Strict equality is safe here, and correct rather than merely conservative:
measured over the real corpus, unparsed === 0 across 801 diagnostics and 1451
output lines (650 of them message-continuation lines, which do not carry the
marker). The divergent shapes that exist in tsc are the FILELESS diagnostics
(TS18003, TS6053) — precisely the outputs meaning the program was not built as
intended. A COLLAPSE (under 25% of the baseline) is refused too, split by
reversibility: shouted on a verdict run, where it cannot hide a regression, and
refused on --write-baseline, where it rewrites the ratchet and destroys the
evidence together.

F2. The derived floor had no lower bound, so replacing the magic 400 with a
value read from a file made the control only as good as that value — while
still logging "derived from the baseline". testFileFloor(1) returned {floor: 0},
a floor no program can fail. It is now max(fixed 400, 90% of the recorded
count); a missing/null count falls back, and a corrupt one (non-integer,
negative, zero, or absurd) is REFUSED rather than silently downgraded, because
an absent value and a corrupt one are different facts. `derived` is now claimed
only when the baseline actually raised the floor.

F5. The drift control's WIRING was untested: mutating `if (!drift.ok)` to
`if (false)` left the suite 78/78 green. diffExcludes was well covered and the
control was live, but nothing pinned that the gate READ it — the same class this
PR exists to close, inside its own suite. A TYPECHECK_TESTS_CONFIG_DIR seam now
drives it end to end, with an undrifted control arm so a refusal attributes. The
sweep for siblings found four more unpinned call sites (the positive-control
arm's own verdict, the unusable-floor refusal, the escape-hatch refusal, and the
tsconfig read error); each is now pinned on its SPECIFIC message, because these
sites are outcome-equivalent and an exit-code assertion would survive the
mutation.

Also: validateBaseline cross-checks totalErrors against the sum of its entries
(a hand-edited total was cosmetic and unnoticed); the three production env seams
are echoed with [OVERRIDE] markers, so a stub-driven run is no longer
byte-identical to a real one; and the doc's self-contradicting counts are
reconciled and re-derived rather than trusted. Its history grep is corrected
from `^src/.*/__tests__/` to `^src/(.*/)?__tests__/`, which cannot match
src/__tests__/setup.ts — added by the same commit as two of the others — so the
count is NINE, not eight. The load-bearing claims are unaffected: the convention
was established, earliest 2026-01-23.

Suite: 78 -> 134 cases, all green. A 19-mutant battery over the changed logic
found and fixed one survivor (F5-floorresult-callsite, whose case used a value
the Math.max fix had made valid) and carries two equivalent controls that must
survive and two breaking controls that must die; final run has 0 survivors and
0 false kills, tree checksum-restored. One control was initially reported KILLED
and it deserved to be — it renamed only a declaration, leaving ${pct} dangling,
i.e. a breaking mutation mislabelled as equivalent. That is recorded in the doc
rather than quietly corrected.

Measurement re-verified unchanged on the exact committed content: 801 errors
across 141 files, 938 test files in program, floor 844, and the committed
baseline byte-identical to a fresh --write-baseline (same sha256). The three
proof-of-concept runs re-run: PASS 801/141; BLOCKED 802/142 naming the planted
file while `pnpm typecheck` on the same tree reports "OK — 0 type errors",
exit 0, with 0 `error TS` lines and 0 mentions of that file; PASS 801/141 on
revert.

Still a proposal: no CI wiring, no attempt on the 801, tsconfig.json and
`pnpm typecheck` untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 12:07:25 -05:00
Zachary Lowden 018f9a0e7f fix(og): unblock the SVG loader Next 16.3.0 leaves blocked, so /api/og stops 500ing (#3777)
Next 16.3.0's image optimizer applies a process-global libvips loader allowlist that omits SVG. next/og rasterizes satori's SVG through the same sharp instance, so every ImageResponse threw 'Input buffer contains unsupported image format' once the optimizer initialized — making /api/og return 500 on 100% of requests.

Carries upstream vercel/next.js#96681 verbatim as a version-pinned pnpm patch (both the CJS and ESM image-optimizer copies), plus a CI guard that fails the build if the patch stops applying.

Verified on the standalone preview build: /api/og returns a valid PNG on both the FallbackCard and OgCard paths after the optimizer is warmed to a cache miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 14:29:46 -05:00
Zachary Lowden ca699f5def ci(test): run the apps/* vitest suites — 369 tests CI had never executed (#3694)
The root vitest.config.mts registered `packages/*/vitest.config.*` as projects
and nothing for `apps/*`, and CI runs only `--project unit` (root-relative
`include`) and `--project '@civitai/*'`. So no test under apps/ has ever run in
CI: 43 files / 369 tests across five apps, green only for whoever remembered
`pnpm --filter <app> test` by hand.

This is the same gap #3591 documented for packages/, in the sibling directory,
missed when that one was closed.

Register `apps/*/vitest.config.{ts,mts}` — globbed on the CONFIG FILE for the
reason the packages comment already gives — and give each app project an
explicit `app:` name. That name is load-bearing: every app is also published as
`@civitai/*` (`@civitai/auth-app`, and `@civitai/orchestrator-gateway` with no
suffix), so under default naming no pattern separates apps from packages and
`--project '@civitai/*'` would have silently swept the apps into the packages
job. Verified the packages selector is unchanged: same 9 packages, same 957
tests, zero app files in the report.

apps/creator-studio gets a config it never had — it declared a `test` script
and owned a vitest test file that no runner could reach.

Generalize scripts/ci/assert-package-suites-ran.mjs to take the workspace dir
and rename it to assert-workspace-suites-ran.mjs, so one ledger serves both
jobs, and wire an `App unit tests` job that runs it.

Per-app counts: auth 29 files/237, notifications 10/101, storage 1/17,
orchestrator-gateway 2/6, creator-studio 1/8. All green.

Positive controls (both with vitest exiting 0, assertion exiting 1 on its own
`missing` branch): breaking one app's `include` so it collects nothing, and
dropping one app's `test.name` so the selector no longer matches it.

Also corrects an inherited premise in the script header — on vitest 4.0.18 a
`--project` filter matching nothing fails startup and exits 1, it does not
exit 0 as the comment claimed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:52:36 -05:00
Zachary Lowden 902d222ed1 ci: run the nine packages/* test suites, which nothing has ever executed (#3642)
* ci: run the nine packages/* test suites, which nothing has ever executed

The root `unit` Vitest project's `include` is root-relative (`src/**`,
`scripts/**`), and the `Unit tests` job runs `vitest run --project unit`. So
no CI job in this repo has ever invoked a workspace package's suite: 616 tests
across nine `packages/*` packages ran only for whoever remembered
`pnpm --filter <pkg> test` by hand.

That is not theoretical. The schema-drift detector (#3591) shipped 81 tests
into this gap, and two of them had been RED on `main` since #3592 landed —
with nothing anywhere to say so.

Root `vitest.config.mts` now globs each package's OWN config file, so every
package keeps the config it was written against. The new `Package unit tests`
job is BLOCKING: the reasoning that made `Unit tests` report-only does not
transfer, because this is 616 tests with no browser, no database and no Next
module graph, and the whole run is ~15s.

Three pre-existing failures the job surfaced, all fixed here rather than
skipped:

  * civitai-db-queries/src/infra/enum-array-parsers.explain.test.ts could
    never pass without a database. `describe.skipIf(!url)` skips the tests but
    Vitest still EXECUTES the describe callback during collection, so the
    eager `new Pool({ connectionString: noVerify(undefined) })` threw
    `TypeError: Invalid URL` and failed the whole FILE to import — which
    reports as "1 failed test file" with no test count, not as a failing test.
    The Pool moves into `beforeAll`, which genuinely does not run when skipped.

  * production-snapshot.test.ts pinned 235 nullability findings on seven
    `*Rank` tables. #3592 marked those columns optional to match the database
    and the findings correctly went away. The assertion now guards the
    REMEDIATION (Rank family at zero) with a positive control on that zero —
    the 11 findings that remain.

  * parse-prisma-schema.test.ts pinned Prisma's defaulted `onDelete` at one
    named site, `TagsOnImageNew.image`, and #3589 gave that relation an
    explicit `onDelete`. Re-anchored to the POPULATION of bare relations, which
    an ordinary schema edit cannot retire.

A green vitest run is a claim, not evidence: `--project` matching nothing exits
0, and so does a config whose globs stopped resolving. scripts/ci/assert-
package-suites-ran.mjs therefore asserts a ledger — every package with a vitest
config and a test file on disk must appear in the results — so the job fails
when the executed set SHRINKS, which the totals cannot see. Validated against
three known-bad reports (empty run, one package dropped, missing report file)
before being trusted.

Test count, measured in CI: 811 files / 12,031 tests before, all from `src/`;
61 files / 616 tests added by the new job, of which 81 are the drift detector's.

* fix(ci): correct the "blocking" claim, and close three ledger blind spots

Audit follow-ups on the packages/* CI job.

The job was described as BLOCKING. It is not, and the distinction is
load-bearing for whoever next decides whether to flip `unit`: `main` has branch
protection but no required_status_checks at all, so a red check here does not
prevent a merge. What it actually buys is rendering RED instead of
red-but-ignored. Corrected in the workflow comment, the README and the PR body,
along with what would make it a real interlock.

The Rank positive control was green for the wrong reason. Asserting the Rank
family is at zero nullability findings is satisfied just as well by a differ
that never visits a Rank table — measured: making compare.ts `continue` on every
model whose table ends in `Rank` left that test GREEN and reddened only two
neighbouring ones. The `nullability.length >= 11` control could not see it,
because it proves the KIND is still emitted, not that those seven tables are in
scope. Now pinned via findings of a different kind that the same seven models
must still produce; re-running the identical mutation kills the test itself.

Three ledger blind spots, each demonstrated with a probe before and after:

  - It only looked in `src/`. A package with its tests in a top-level
    `__tests__/` was invisible to the EXPECTED set, so it could never be
    noticed going missing. Now scans the whole package directory.
  - It only matched `.test.ts`/`.spec.tsx`. `.mts`, `.cts` and `.js` tests were
    invisible the same way.
  - It counted SKIPPED tests as having run, summing assertionResults.length. A
    package that self-skipped in its entirety still satisfied the ledger — and
    self-skipping on a missing DATABASE_URL is exactly the pattern these suites
    use, so that failure mode is live, not hypothetical. It now requires a
    non-zero EXECUTED count and reports skips beside it, so a package quietly
    turning itself off is visible rather than absent from the arithmetic.

Both directions verified by exit code, not by reading output: real report 0;
tests-outside-src probe 1; .mts probe 1; wholly-skipped package 1; empty run 1;
one package dropped 1; missing report 1.

Known remaining gap, deliberately not widened into this change: `apps/*` has
four more vitest configs and ~43 test files that no CI job runs. Same one-line
fix in the same projects array, plus teaching the ledger about `apps/` — it
hardcodes `packages/` today. Follow-up PR.

Also: the CI job's JSON report is gitignored (the documented command left an
untracked artifact at the repo root), and the workflow comment's "four EXPLAIN
tests" is corrected to 3 + 1 across two files.
2026-08-05 19:55:32 -05:00