2 Commits

Author SHA1 Message Date
Justin Maier d757478088 fix(block): enforce blocking on legacy model comments and review edits (#4072)
* fix(block): enforce blocking on legacy model comments and review edits

The write guard covers the CommentsV2 surfaces, but the older model-comment path
was never wired into it. The value it passed as the block target is the comment's
author — the caller themselves on a create — so a create resolved to a check about
the caller and the model owner's block was never consulted. A reply checked only
the parent comment's author.

Resolve the model owner (and the parent author, for a reply) through the shared
block-check service instead, and enforce on edits as well as creates: an edit is
scoped by comment id while writing modelId/parentId through from the request, so
both the comment's stored home and the one the request names are checked. Refused
writes 404, matching the other write paths.

Resource reviews had the create-only half of the same shape — a review written
before a block stayed editable afterwards — and an edit can re-point modelId, so
both models are checked there too.

Tests: each negative assertion was verified by mutating the guard away and
confirming it fails in under two seconds.

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

* fix(block): guard the review procedures the UI calls, and 3D model reviews

Review round on the previous commit found the review half aimed at the wrong
procedure. `resourceReview.upsert` is only reached from the edit-review modal;
the app calls `resourceReview.create` and `.update`, and neither had a block
check at all — so an author refused on `upsert` had an unguarded path to the
same row. Both are guarded now. `update` carries no modelId, so it resolves the
review's model from the stored row.

3D model reviews had no check on either branch, while comment threads hanging
off such a review were already guarded — which made the surface look covered.
Re-homing is already refused there, so one check against the target model does
both branches.

The legacy-comment resolver now answers through `getBlockCheckOwnerIds` rather
than reading the two rows itself, so a rule added to either entity type reaches
this path too. `commentOld` resolves the model owner alongside the comment
author, matching its CommentsV2 counterpart — author alone still let a blocked
user react under a blocker's model.

Tests: mocks key on the id being asked for rather than on call order, after
identical reply-resolution failures under load turned out to be an empty default
being indistinguishable from a real answer. Every guard was mutation-checked
individually.

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

* test(block): assert the reaction behaviour the widened commentOld arm changes

`commentOld` has one consumer, `toggleReaction` — the legacy comment surface has
no reaction of its own — so resolving the model owner there changes reaction
behaviour: reacting to somebody else's comment under a blocker's model now
refuses. That arrived as a side effect of routing the resolver through the
shared switch; give it its own assertions, in both directions.

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

* fix(block): restore the moderator exemption on the 3D-review guard

A reviewer's mutation probe was live in the working tree and got swept into the
previous commit by a broad `git add`, leaving the guard with a hardcoded
`isModerator: false` — so a moderator blocked by a creator would be refused on
that creator's 3D-model reviews, letting any user lock a moderator out of a
review surface by blocking them.

The suite catches it (`exempts moderators`); the commit that introduced it was
pushed on the strength of a full-suite run that predated the sweep.

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

* test(block): pin the handler wiring, and read the guard's target from the writer

The review handlers are the half of the fix that reaches production — the guard
only knows who is acting because the handler passes it — and no test touched
them. Dropping `userId: ctx.user.id` left the guard asking whether an undefined
user is blocked: a silent no-op on the path the guard exists to close, with
nothing red. Pinned per procedure, along with the moderator status.

`userId`/`isModerator` are guard inputs, not columns, and reach the service in
one object with the review's fields; a destructure that stops pulling them out
sends them into the Prisma payload, which on update rewrites the review's author.
Asserted on both payloads.

The stored-review lookup read the replica while the write it guards goes through
the writer, so a review created seconds earlier resolved no owner and the write
proceeded — the fall-through was to a real write, not to a not-found. It reads
through the writer now.

Also: the moderator-exemption test now covers the upsert branch it was named for,
and the new handler test uses the canonical redis mock rather than a direct one.

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

* refactor(block): one owner map, one statement of the moderator rule

The widened `commentOld` arm still read the model row itself, one arm away from
`case 'model'` answering the same question — so a rule added there would reach
every path except that one. It recurses now, the way `case 'comment'` already
does.

`throwIfBlockedByModelOwners` stated the moderator bypass a second time before
handing the same flag to the shared guard. Dropped the local early return; the
argument stays, and deleting it fails `exempts moderators on every branch`.

Also documents a measurement hazard found while verifying this branch: two
vitest runs in one worktree make configured mock lookups return their empty
default, so a targeted run beside a full suite produces reds the code does not
explain. 10/10 green frozen and alone, 1/5 red with a suite alongside, 6/6 green
under CPU load with no rival vitest. The mechanism is unproven and the text says
so.

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 14:15:30 -06:00
Justin Maier 51eb5d7bf0 test(perf): canonical shared-module mocks, a migration ratchet, and the first 100 files (#3959)
* test(perf): canonical shared-module mocks for running without per-file isolation

The unit suite spends ~81% of worker-time importing modules. `vitest --no-isolate`
collapses that but breaks ~1,500 tests, and the mechanism is narrower than
"vi.mock is per file": a test file that does not mock a module still gets the real
one. The damage travels through ordinary source modules — a module that imports a
mocked module is evaluated ONCE per worker and captures its bindings then, in
whatever mock context that first evaluation happened to occur. Every later file
reuses it, still pointing at the first file's mock object.

So the fix must keep the mocked shape complete and function identities stable for
the worker's lifetime, and swap only behaviour. src/__tests__/mocks/ does that: a
hybrid node that is both a vi.fn() and a proxy vivifying cached children, one
canonical mock per specifier, registered globally in setup.ts and reset per file
(setupFiles re-run per file in both isolation modes).

Inert under isolation, where it is registered but nothing can leak: 174 files,
2089 tests, 0 failed.

Ships the tooling to continue the migration: a codemod that converts only shapes it
can prove equivalent and reports every refusal with a reason, an allowlist
generator that refuses to grow, and a run/compare pair that diffs per-file
collected counts — because under --no-isolate a file whose module scope throws
collects ZERO tests and the run still reads as green.

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

* test(perf): migrate 83 files onto the canonical mocks, and ratchet the rest

Converted by scripts/test-perf/codemod-shared-mocks.mjs. The test bodies are
untouched: only the vi.mock factory and its vi.hoisted spies go, replaced by consts
bound to canonical nodes, so the change is mechanical and reviewable as such.

Measured on the 83 files that are clean for all three specifiers, against the same
files isolated at 4 workers (856 tests, 0 failed):

  --no-isolate,  4 workers   846 tests   11 failed   wall 62.6s -> 22.9s
  --no-isolate, 12 workers   853 tests    1 failed   wall 62.6s -> 18.8s

The residual failures are not mock shape. eventloop-longtask.ts registers prom
metrics at module scope, so under --no-isolate the first file to import it in a
worker takes the registration and later files see an empty registry; that belongs
with no-module-scope-cache.

Migration is all-or-nothing per specifier — one hold-out re-poisons its whole
worker — so no-direct-shared-module-mock guards it with an allowlist at 433 files.
It ratchets both ways: a new direct mock fails, and a migrated file left on the list
fails too, so the count cannot be padded.

purge-review-snapshots proved "the gate reads the primary" with
`expect(dbRead.x.findFirst).toBeUndefined()` — the replica fixture simply lacked the
method. The canonical mock vivifies every method, so absence stops being observable;
rewritten as `not.toHaveBeenCalled()`, which asserts the behaviour rather than the
fixture's shape and survives another file populating that method.

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

* test(perf): type the shared-mock self-tests against the canonical nodes

`tsconfig.tests.json` sees these files even though `pnpm typecheck` does not, and
calling through the re-exported `dbRead`/`redis` demanded real Prisma `where`
objects and literal Redis key unions — 8 errors about argument shapes, in tests
about default return values. The canonical nodes are the same objects (asserted in
the file), so routing the calls through them keeps each test about what it is for.

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

* test(perf): migrate 17 more files, and teach the codemod the real REDIS_KEYS

The codemod now static-parses REDIS_KEYS_UNPREFIXED / REDIS_SYS_KEYS out of
packages/civitai-redis/src/client.ts and deep-compares each hand-written literal
against it: dropped where every leaf matches, refused and printed where one does
not. That was the largest refusal class.

42 files diverge across 73 leaves. Some are placeholders ("rl", "kill"); others
read as real and are wrong — CACHE_LOCKS as "caches:lock" against a real
"cache-lock", TRPC.LIMIT.BASE as "trpc:rate-limit" against "packed:trpc:limit", a
system:-prefixed sys key written without the prefix, and a cache key still on v1
after the real one was bumped to -2. None are live bugs: a test asserting against
its own copy uses the same wrong string on both sides, which is why nothing in the
repo would ever surface them.

Allowlist 433 -> 416.

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

* test(perf): teach the codemod three more shapes, migrate 31 more files

Repo-wide convertible goes 10 -> 84 on the current allowlist. New shapes:

- a whole client built as an object literal of spies, collapsed to the canonical
  root: binding the root makes every leaf vivify at exactly the path the literal
  named, so `mockDbRead: { collection: { findFirstOrThrow: vi.fn() } }` becomes
  `const mockDbRead = dbMock.dbRead` and the test body needs no edits. Leaves
  carrying real behaviour are still refused rather than dropped.
- `vi.hoisted` with a block body whose returned property names a local; the local
  is removed too, but only when the block reads it exactly once, so a `make()`
  helper shared by two clients is left alone.
- an import alias when a test's own local collides with the canonical mock's name.
  `const { redisMock } = vi.hoisted(...)` is real in this repo, and lifting it
  produced the self-referential `const redisMock = redisMock.redis`.

Verified on the 120 files now clean for all three specifiers, against the same
files isolated (1268 tests, 0 failed): 1268/1268 collected under `--no-isolate` at
both 4 and 12 workers, no file lost tests. The remaining failures are other shared
specifiers that have no canonical mock yet — `~/server/flipt/client` is the loudest
— which is the same mechanism, not a regression in these conversions.

Allowlist 416 -> 396.

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

* test(perf): widen the mock guard to every shared specifier, counted honestly

The three specifiers with canonical mocks are not the whole job, and an allowlist
covering only them would reach zero with `isolate: false` still unflippable.
Measured: a 120-file set clean for all three still failed 110 tests at 4 workers
under --no-isolate, entirely through specifiers nobody had listed — loudest being
`No "isFliptSync" export is defined on the "~/server/flipt/client" mock`. Same
mechanism, different module.

So the guard now tracks 15 specifiers, split by obligation:

  CANONICAL (3, enforced)   562 sites across 396 files
  PENDING  (12, counted)    856 sites across 392 files

PENDING is counted rather than enforced because those modules have no canonical
mock to migrate to; enforcing would push every new test file onto the allowlist and
measure churn instead of work. Its recorded count is asserted against reality, so
the remaining scope cannot silently understate itself.

The specifier lists live in one TS module and the generator PARSES them rather than
holding a second copy — a generator that disagreed with the guard about which
modules are guarded is the one failure this pair must not have.

Stated in the guard, because it is the part that is easy to get wrong: the flip
criterion is not "the allowlist reaches zero", it is "every specifier a test file
shares with another test file has a canonical mock".

Next specifier to do is `~/env/server`: 109 sites, every one partial, and it is
already globally mocked in setup.ts with a Proxy — so the canonical shape exists
and the work is per-file behaviour plus a reset, not a new design.

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

* test(perf): give the six external mock factories a `default` key

Pre-bundling wraps a CJS dep for interop, so the consumer resolves through
`default` and a factory that returns only named exports yields undefined. Adding
`default` lets `redis`, `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` join the
SSR pre-bundling safelist.

Measured under a pre-bundling config, same six files, back to back:

  without `default`   6 files   7 tests collected   6 files failed to load
  with `default`      6 files   106 tests collected  0 failed

7 -> 106. Note the failure mode: the run does not report 99 failures, it reports
almost no tests. A file whose module scope throws collects nothing, the failure
count barely moves, and a summary line reads as a pass. The acceptance check is
therefore the collected count, per file — `s3-utils.test.ts` alone is 66 of the
106, so the total can look healthy while that one file is empty.

`importOriginal` does NOT protect against this, despite the repo mandating that
form: the spread copies the original module NAMED exports and does not synthesise
a `default`. `s3-utils.test.ts` already used `importOriginal` and still collected
nothing without the key.

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

* fix(test-perf): stop the canonical mocks evaluating the real db/redis shims

`~/server/db/client` and `~/server/redis/client` are shims: they re-export their
package wholesale AND construct real clients at module scope. Registering them with
an `importOriginal` spread therefore forced real Prisma/Redis construction into
EVERY test file, where before only files without their own db mock paid it.

That is a correctness surface, and it fails in the worst available shape. A file
whose own `@prisma/client` mock omits `PrismaClient` died during module evaluation
with `PrismaClient is not a constructor`, so it collected ZERO tests — the failure
count stayed at 0 and the run read as green. arabella found it on a control run
before converting anything, which is the only reason it was found at all.

The package re-exports are all the spread was ever protecting, so the fix is to
spread `@civitai/db/client` / `@civitai/redis/client` directly. Same exports, no
construction, and the precondition for the whole failure class disappears rather
than being enumerated: a file no longer needs its own db mock to be protected.

Verified: `process-vault-items.test.ts` collects 15 tests again; all ten files that
mock `@prisma/client` collect (134 tests, 0 failed); and a file simulated into the
MIGRATED state — its own db mock removed, its `@prisma/client` factory still
lacking `PrismaClient` — collects 8/8, so the five files predicted to die when
migrated no longer will.

The 120 migrated files were unaffected: identical per-file collected counts before
and after (1268), 0 zero-collect files in either.

Pinned by a test asserting neither shim's globalThis client cache is populated —
absent globals are direct evidence the module body never ran.

Also adds the canonical `~/env/server` mock (worker-level defaults + per-file
overrides + reset) and drops three per-file env mocks onto it. Under `--no-isolate`
at 4 workers the 120-file set goes 110 -> 58 failures, 1268/1268 collected.

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

* docs(test-perf): the shim-vs-package rule and env's two buckets

Both are things a migrator gets wrong silently rather than loudly.

Spreading a shim evaluates a module that constructs clients; spreading its package
does not. Stated with the failure shape, because the symptom is a file collecting
zero tests behind a green run rather than anything that looks like an error.

`~/env/server` splits where the other specifiers do not: a per-file override cannot
reach a value read at module scope, since under `isolate: false` that module is
evaluated once per worker. Module-load values belong in the worker defaults,
call-time values in per-file `setEnv`. Recorded with the reason it is not a
shortcoming of the design — a per-file `vi.mock` factory has the same problem — so
nobody reverts to per-file mocks trying to fix it.

Plus the `Object.defineProperty` trap: an inconsistent proxy descriptor throws on
the SECOND call, which surfaces as a dozen unrelated failures pointing nowhere near
the cause.

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

* docs(test-perf): make the whole-suite collected-count diff a blocking flip gate

Per-slice verification is sound and insufficient at the same time. Every slice
owner diffing per-file collected counts over their own files is correct practice
and still leaves a gap: a file nobody owns can collect zero and no owner's control
covers it.

That is not hypothetical. The `importOriginal`-on-a-shim regression made one file
die at module scope and contribute nothing; the 120-file pilot was verified by
per-file collected counts and could not have caught it, because the affected file
was outside the set. It was found by someone taking a control run on a different
slice before converting anything. The failure was not in anyone's work — it was in
the gap between everyone's work.

So the gate is whole-suite, diffed per file against a `main` control taken in the
same window, zero files losing tests and totals matching exactly — and it runs
immediately before the flip, because the property is only true of the tree that
ships. Cheap: the integration run was 1069 files / 16806 tests in about four
minutes.

Records the general form too, since it outlives this migration: the day's two most
valuable findings each came from someone else running a control on another person's
work, and a third correction went the other way. Authors verify what they changed;
nobody verifies what changed around them.

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

* test(perf): four more codemod shapes, taking convertible from 53 to 204

Widening the tool beats converting files by hand: every shape lands on four slices
at once. Two of the four came from arabella's refusal taxonomy, and one of those
was a case of the tool asking the wrong question rather than being too conservative.

1. LIFT inline behaviour instead of refusing it. A leaf carrying behaviour is
   already an expression, so it becomes an explicit assignment on the canonical
   node - mockImplementation / mockResolvedValue - emitted at module scope. A spy
   reached through an object (`mocks.findMany`) is WRAPPED rather than rebound, so
   `expect(mocks.findMany).toHaveBeenCalled()` still holds and the assertions do
   not move. Skipped entirely in a file that calls resetAllMocks or
   restoreAllMocks, where a module-scope assignment would be wiped before the
   first test runs.

2. A client literal may now carry behaviour-bearing leaves. `$executeRaw:
   vi.fn().mockResolvedValue(undefined)` beside four bare spies was the only thing
   separating several files from the object-literal collapse.

3. Factory-supplied constants are DELETED, not proved equal. The registration
   spreads @civitai/redis/client, where both key tables are defined, so the
   factory's copy is redundant whatever it contained - and proving the literal
   equals the real constant is unanswerable for `completeKeys({ ... })` and is not
   the question that decides safety. What decides it is whether the test names the
   constant outside the factory, which is a check the tool can make. Divergences
   are still reported, as findings rather than blockers.

4. Any export other than the client roots is dropped on the same rule - safeError,
   withSysReadDeadline - since the spread already supplies them. The factories only
   declared them because replacing the module wholesale meant they had to.

Also: a block-body factory may now hold arbitrary locals. It used to refuse
anything that was not `const actual = await importOriginal()`, which rejected a
`make()` key-proxy helper for no reason - the whole factory is deleted, so its
locals go with it.

Verified on the 11 files this converts in my slice, against a control of the same
27: 582/582 tests collected, 0 failed, isolated. One file went red and it is the
finding the docs predict: middleware.trpc.test.ts asserted
`stringContaining('trpc:rate-limit:')` against its own fixture's copy of
REDIS_KEYS.TRPC.LIMIT.BASE. The real key is `packed:trpc:limit`, so both sides of
that comparison were the fixture's invention - it could never fail and never
matched production. The assertion now names the real key.

Allowlist 396 -> 385.

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

* test(perf): widen mock-guard DETECTION to .tsx, leave conversion on .ts

A .browser.test.tsx adding a direct canonical mock is a class the guard was
structurally unable to observe, which is different from a class that happens to be
empty (it is: 0 such files today). Detection is the half with value now.

The codemod deliberately stays .ts. Converting a browser-mode file would put it in
a regime the canonical mocks have never been proven in, and a glob change would
make that look like a supported path. Proving browser mode is real work, not a
one-word edit. (donovan's finding, and his own suggested split.)

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

* test(perf): lift behaviour on declarations and hoisted entries too, 204 -> 223

The lift rule already applied to a factory leaf; a spy declared WITH behaviour —
`mockUpdateMany: vi.fn(async (args) => ({ count: 1 }))` in a vi.hoisted object —
still blocked its whole file. Same treatment now: the behaviour becomes a
mockImplementation on the canonical node and the binding converts.

Validated on a real file rather than by the report: agent-report-callback.test.ts
converts with zero refusals and passes 14/14. Its own slice owner is mid-probe on
that directory, so the file was restored afterwards and nothing there is committed.

Remaining refusals are now dominated by the dbRead/dbWrite aliasing class (43),
which is deliberately manual — splitting an alias needs someone to know which
client the code under test exercises, and some of those are supposed to go red.

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

* docs(test-perf): record the browser-mode gap as work, not verification

The codemod stays .ts while the guard detects .tsx. Worth stating that there are
ZERO .tsx files mocking a canonical specifier today, so closing the gap means
writing one rather than migrating one — a piece of work, not a check somebody can
tick off. (donovan's framing, sharper than mine.)

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

* fix(test-perf): three unsound codemod shapes, found by two probe runs

223 -> 164 convertible. The drop is the point: 59 files that were converting
silently wrong now refuse. All three were caught by other people running a
control on the tool's output, and two of the three do not fail loudly.

1. DROPPING A NON-ROOT EXPORT removed a test's control surface. The registration
   does supply the real `withSysReadDeadline` / `safeError` — but a factory that
   replaces one with a SPY is using it to drive the behaviour under test.
   session-verifier.test.ts injected a timeout through it; with the spy gone the
   deadline never fired and two fail-open legs asserted nothing while still
   passing. Available is not the same as redundant. Only plain DATA drops now;
   anything carrying behaviour refuses.

2. LIFTING AT A CLIENT ROOT treated a whole client object as a leaf spy.
   `redis: mainFake.client` became `redisMock.redis.mockImplementation(...)`,
   which loses every method on the object — `scanIterator` yields nothing, `del`
   is gone. It does not throw, it returns empty, so a test asserting "nothing was
   deleted" would have gone GREEN. Lifting is now confined to method positions.

   The wiring bug underneath it is worth naming: the guard was passed
   `spec.flat`, which is `undefined` for db and redis, and `undefined` triggers
   the parameter's `= true` default. The check was present and inert.

3. LIFTING AN EXPRESSION OUT OF A HOISTED BLOCK broke its references.
   `$queryRaw: vi.fn(queryRaw)` became a module-scope `mockImplementation(queryRaw)`
   while `queryRaw` was a const inside the `vi.hoisted` body the conversion
   deletes — `ReferenceError` at import, so the file collects zero tests. A lifted
   expression may now only reference names declared at the file's top level. Type
   annotations are skipped, since they are erased before any of this runs.

Verified: all four files from the two probes now refuse, and a known-good
conversion still converts and passes 14/14.

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

* docs(test-perf): state what the flip gate cannot catch

Collected counts and residual-mocks detect ABSENCE. Neither can see a test that
still runs, still passes, and no longer asserts anything real — which is what two
of the three codemod defects produced. Both files converted with zero refusals,
kept every test, and read clean on residuals; one of them returned empty instead of
throwing, so a test asserting 'nothing was deleted' would have gone green.

What caught them was a control pair at assertion level, on a small set, run by
someone who did not write the tool. Recorded beside the gate as the half the gate
cannot do, so nobody reads a clean gate as completeness. (archer's point.)

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

* fix(test-perf): the migration guard was inert in every full-suite run

It scanned the test tree in the `describe` body, so a throw during that scan was a
COLLECTION failure: the file contributed zero tests, the suite's failure count did
not move, and the guard was simply absent. It passed whenever invoked as a named
file, which is how it was checked all day. The allowlist, the ratchet and the
both-directions property were all unenforced in the run that matters.

Three changes, because the symptom and the shape both needed fixing:

- The scan runs INSIDE the tests. The same fault is now a red test with a stack
  instead of a file that quietly is not there.
- It skips anything that is not a regular file. A full-suite run creates
  directories under `src/` while the walk is happening, and one whose name matches
  the glob reaches readFileSync as EISDIR.
- A POSITIVE CONTROL asserts the walk actually saw the tree (>800 files against a
  real ~1,200). Every claim this guard makes is about a set of files; an empty walk
  makes all of them vacuously true. Every other guard on this project has such a
  control and this one did not, which is how it got here.

`compare-runs.mjs` gains the condition that would have caught it: every file the
candidate ADDS must collect at least one test. A new file has nothing on the
control to lose against, so it can collect zero and diff perfectly clean — the
gate's own blind spot, found by running the gate rather than by reasoning about it.

Also records that the PENDING specifier list is a FLOOR rather than an inventory.
It was assembled from a static scan, and specifiers keep arriving from the other
direction, as failures in a --no-isolate run of files already clean for everything
listed. flipt/client arrived that way; middleware/block-scope.middleware (27 files)
is a live candidate. The remaining work is discovered, not known.

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

* docs(test-perf): the unit of work is a cluster of specifiers, not one

Canonicalising block-scope.middleware took one pair from 13 failures to 7, and the
remaining 7 were a different class — so that pair shares at least one more
poisoning specifier nobody has named. 13 -> 7 is the dangerous shape: it reads as
progress and is not completion.

A set is done when its --no-isolate failures reach zero, not when the specifier you
were working on stops appearing. (arabella's measurement.)

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

* perf(tests): migrate src/server (other) onto the canonical mocks

20 of the 56 candidate files in the slice — src/server/** minus services/,
jobs/ and routers/, which belong to other slices. Converted with the codemod at
95ac2f9e6a; nothing hand-written.

Verified on the full protocol rather than on a green:

  control -> convert -> per-file collected-count diff -> pass/fail diff -> mutation

  20 files, collected 162 -> 162, 0 regressed, 0 failures

Mutation sample across the mock kinds, on the converted files:

  purgeCache          remove the fail-soft catch    -> 1 failed, bites
  stored-image-probe  drop the ETag passthrough     -> 1 failed, bites
  base.reward         rethrow a transient CH error  -> 5 failed converted,
                                                       5 failed unconverted

The base.reward pair is the claim worth making: the conversion preserves the
file's discriminating power, not merely its pass/fail state.

Two mutations came back green and are NOT findings — the same mutations were
equally green against the unconverted files, so they were mis-aimed at paths
those files never exercise. A mutation that does not bite is evidence of nothing
until it has been shown to bite on the original.

Three files keep a direct mock for a specifier the codemod refused, having been
converted for the others: challenge-helpers (redis), challenge-winner-payout-dedupe
and challenge-winner-persistence (logging). Per-specifier atomicity holds — no
file is half-converted for any one specifier — but those specifiers stay poisoned
for the worker, so the slice is not a clean read for redis or logging on its own.

The codemod also reported 9 constants the fixtures had invented, e.g.
REDIS_KEYS.CACHE_LOCKS real "cache-lock" vs test "caches:lock". None of the
affected tests named those constants outside their factory, which is why nothing
went red — both sides of any such comparison would have been the fixture's own
invention. The factory copies are gone; the tests now see the real values.

* test(perf): migrate the six aliased download fixtures by hand

All six aliased dbRead and dbWrite onto ONE spy, so a read routed to the primary
would have satisfied a replica assertion silently. Every handler reads the replica
only - dbRead.keyValue.findUnique for the blocklists, plus dbRead.vaultItem and
dbRead.modelVersion in the vault route - so each binds dbRead alone. That is
strictly more discriminating than the fixture it replaces.

Codemod refuses this class deliberately: splitting an alias needs someone to read
the production path and decide which client the code exercises, and getting it
wrong produces a passing test asserting the wrong thing.

7 files, 71 tests, 0 failed.

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

* chore(test-perf): regenerate the allowlist after merging archer's slice

383 -> 366 canonical files, 518 sites. The allowlist is derived state and three
people are converting on separate branches, so it is regenerated ONCE here rather
than churned on each of them — a both-directions ratchet in a shared JSON is a pure
conflict generator otherwise.

Guard green after the merge, which is the case it exists for: a merge that resolves
a converted test file leaves its entry stale, and that is the direction that has
already caught something today.

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

* docs(test-perf): a drifted constant nothing asserts on is invisible both ways

The doc already said to expect redness when the real constant swaps in. The more
common case is the opposite: in one slice nine invented constants were replaced and
none went red, because no affected test named them outside its own factory — so
both sides of any comparison were the fixture's invention.

That is worse than a failure, not better. It cannot fail and it cannot be reviewed,
and six of the nine were plausible variants of the real key
(new-order:sanity-check-failures against a real new-order:sanity-failures) which is
exactly how they survive a reading. Log what the codemod reports as drifted even
when the suite stays green. (archer's slice.)

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

* docs(test-perf): why the PENDING list misses what it misses, and how to find it

The list was built from repo-wide mock counts. Services is a fan-in problem and
the list covers it — every high-fan-in specifier is on it, first undiscovered one
ranks 12th. Routers is a clique: locally dense, repo-wide rare, so a repo-wide
count structurally cannot see it. Two different problems needing two different
searches, not more of the same one.

The method that finds the missing ones is a per-pair shared-specifier graph for the
directory being worked on, not a sorted repo-wide count.

Also records arabella's untested idea, which is the highest-value thing nobody has
tried: a third of services pairs share NO specifier, so a worker assignment
grouping non-overlapping files could make much of that suite clean under
isolate:false without canonicalising anything — statically evaluable from the same
graph before any code is written.

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

* test(perf): migrate arabella's routers/jobs slice, 22 files

Codemod at fbc5aa6d0b, nothing hand-written except one drift fix.

Verified against a control of the same 36: 885/885 collected, 0 failed, no file
lost tests. blocks.router.workflow collected its full 347, which is also the
submodule check CLAUDE.md asks for.

The one red was the drift case arabella predicted before her slice was reverted:
the file asserted 	oContain('REVIEW_RUN_FOR_REAL_BUZZ_CAP') — the literal
placeholder NAME from its own REDIS_SYS_KEYS fixture, not any key production emits.
Real key is system:blocks:review-run-for-real-buzz-cap; the assertion now names
it.

On the 21 that are clean for all three specifiers, under --no-isolate:

  12 workers   562/562 collected   0 failed
   4 workers   562/562 collected  26 failed across 4 files

The 4-worker failures are the routers clique — locally dense, repo-wide rare
specifiers that the PENDING list structurally cannot see, since it was built from
repo-wide counts. Not mock shape, and not fixed by finishing these three
specifiers.

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

* fix(test-perf): track data assigned to a hybrid node, and clear it per file

sysRedis.isReady = false is not mock state, so mockReset() never touched it.
Without a set trap the value landed on the underlying vi.fn as an own property
and, under isolate:false, outlived the file that set it for the whole life of the
worker — leaking a flag into every later file in that worker. Found by donovan, who
bounded it locally with an afterEach; this is the general fix.

Assignments are now tracked per node and cleared by resetHybridNodes(). Pinned by a
cross-file pair: one file sets the flag, the companion asserts the VALUE is gone.
Note the invariant is not 'undefined' — an unset property still vivifies to a node
by design — it is that the previous file's value does not survive.

Also names the review-session key through REDIS_SYS_KEYS rather than as a literal,
so that assertion cannot drift from the constant again. 347 collected, 347 passed.

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

* test(perf): pin the review-session key's WIRE VALUE alongside the behavioural assertion

The behavioural assertion names REDIS_SYS_KEYS so it can never re-invent a key --
which is the class that made this file's old assertion match its own fixture's
placeholder. A constant reference cannot drift from the constant, but it also
cannot notice the constant CHANGING: the wire value addresses live Redis entries
written by deployed code, so a rename orphans whatever sits under the old name and
should be a deliberate decision rather than something a test silently follows.

So both, because they guard different failures and neither subsumes the other: one
golden-value assertion pinning the literal, and the behavioural test naming the
constant. The reasoning is in a comment so the next person does not 'improve' one
of them away. (archer's shape, argued against his own preferred version.)

348 collected, 348 passed.

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

* docs(test-perf): two assertion shapes the migration keeps producing

toBeUndefined() on a hybrid node is wrong about the design while looking like the
obvious check — an unset property still vivifies, so it fails with 'expected
[Function Mock] to be undefined'. The claim people actually mean is that the
previous file's VALUE did not survive.

And the key-assertion shape, settled between three of us today: name the constant
in the behavioural assertion so the test can never re-invent a key, and pin the
wire value once so a rename is loud. Different failures, neither subsumes the
other.

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

* fix(test-perf): spread the original in the server-domain mock; classify constant drift

Two changes, both from ivy.

1. manifest-schema-endpoint.test.ts wholesale-mocked ~/server/utils/server-domain,
   which has 14 exports. Under --no-isolate that freezes the module for the worker,
   and a later file whose consumer reaches one of the other 13 dies at MODULE scope
   - a zero-collect, not a failure. It took out a DIFFERENT file at each worker
   count (tag-with-model-count-cache at 4, bitdex-model3did at 12), which is why it
   read as flakiness rather than as one broken file.

   The general point is bigger than the patch: a wholesale mock of a NON-canonical
   specifier is enough to cause the silent class. The three canonical mocks do not
   bound it.

2. Constant drift is now split by a rule rather than reported flat. A placeholder
   the test never meant as a real key - shares no :-delimited segment with the real
   value, or four characters or fewer, or namespaced 	est: - is noise and is
   dropped silently. A value that reads as a plausible real key and is wrong is the
   thing worth a human. 42 reported drifts become 13 substantive ones.

   Encoded as a discriminator rather than as a hand list, so a placeholder invented
   next month is covered too.

Verified: the three affected files collect 14/14 with zero failures at both 4 and
12 workers under --no-isolate.

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

* docs(test-perf): the canonical three do not bound the silent zero-collect class

A wholesale vi.mock of ANY multi-export module causes it. server-domain (14
exports, mocked with a single-key factory) froze for the worker and killed a
different file at each worker count, which is why it presented as flakiness.

Records the diagnosis shortcut with it: grep the no-isolate log for
'No "<export>" export is defined on the' and the message names the poisoned
module — usually not one of the three. (ivy's finding and her method.)

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

* docs(test-perf): module-scope booleans set both ways are a permanent exclusion

IS_BUILD and IS_DATAPACKET are set true by some tests and false by others, read at
module scope. Under isolate:false the reading module is evaluated once per worker,
so only the first file's value is ever visible — no mechanism fixes that, including
a per-file vi.mock factory.

Recorded as an exclusion rather than as work, so an unmigrated count containing
them does not read as progress still to be made. (ivy's finding, from bucketing
the 106 env sites before converting any of them.)

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

* chore(test-perf): ignore .test-perf/ on the mock-system base

The ignore entry exists only on perf/test-perf-tooling (#3957), while
scripts/test-perf/README.md tells every reader that .test-perf/ is
gitignored. On every other branch the directory is plain untracked and
inventory.json alone is ~2 MB, so `git add -A` on a slice branch sweeps
multi-megabyte artifacts into the commit.

Every migration slice is cut from this branch, so putting it here covers
them all until #3957 merges.

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

* chore(test-perf): track reporter.mjs on the mock-system base

run-pilot.mjs passes this file as --reporter and compare-runs.mjs reads
the .perf.json only it writes, so the whole per-file measurement protocol
silently produces nothing without it. It existed only as an untracked
file in each agent's worktree: every measurement taken this way is real,
but none of them are reproducible from a fresh checkout of this branch.

Byte-identical to perf/test-perf-tooling (#3957), which is where the file
belongs; this copy is resolved away when #3957 merges. Every migration
slice is cut from this branch, so tracking it here closes the provenance
gap for all of them at once.

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

* fix(hooks): stop the prettier guard firing on commands that only MENTION it (#3964)

The guard matched `prettier` anywhere in the command text, so text that quotes a
command was treated as a command. A shell loop over a list of example strings
was blocked outright with "a repo-wide prettier --write rewrites ~1000 files"
while it did nothing but echo them. Heredocs and `-m` messages were already
stripped for this reason; the rest of the surface was not.

A segment now counts only when the invocation STARTS it, optionally behind a
runner or leading env assignments. Position is not a parser, but it separates
running from quoting in the shapes that occur, and the same rule now covers the
prettier-plugin-svelte block, which had the identical flaw.

Unexpanded shell tokens are also no longer scored. `$FILE` and `$(cat list.txt)`
have no extension, so they read as bare directories and made
`prettier --write "$FILE"` — one named file — ask for confirmation. The hook
sees the command before the shell expands it, so these are not paths it can
measure; skipping them leaves the outright block on a literal `*` / `**` doing
the work it was added for.

Behaviour pinned by hand against 13 commands. Unchanged where it matters:
`--write .` and `--write src` still ask, `--write "**/*.ts"` and the svelte
plugin are still blocked outright, `pnpm run prettier:write` still runs. Newly
allowed: a named file behind a variable, a scripted file list, and any command
that merely quotes one.

A guard people route around protects nothing, which is the argument for making
it quieter rather than broader.

* fix(test-mocks): close four holes in the mock-migration machinery

An adversarial review of #3959 found the tooling that is meant to make the
remaining 345 files safe had three inert or bypassable guards and one that
could vanish from a run.

1. codemod-shared-mocks: `new RegExp(`\b${root}\b`)` builds a BACKSPACE, not a
   word boundary, so both "this export is referenced outside the factory,
   refuse" checks could never fire. The codemod would silently delete a
   factory-supplied constant the test body still names. No damage in the
   migrated batch — every dropped property was checked — but the tool ships.

2. guarded-specifiers: every part of the system matched only the literal `~/…`
   spelling, so `vi.mock('../../logging/client')` was invisible to the guard,
   the allowlist generator and the codemod at once. mockPattern now also
   matches the relative spelling on its trailing segments, and the one live
   instance is rewritten to the canonical form. Deliberately over-broad: for a
   guard, a false positive costs an allowlist line and a false negative costs
   the invariant.

3. gen-mock-allowlist ratcheted on `files.length`. Migrating one file while
   adding a direct mock in another leaves the count unchanged, so the new
   violation gets written into the list with the guard green. Now refuses on
   membership.

4. no-direct-shared-module-mock parsed the generated allowlist at module scope.
   That file is 500+ entries and conflicts on every parallel branch, so a bad
   merge resolution makes it malformed — and a throw there collects ZERO tests
   and reports no failures, removing the guard from the run instead of failing
   it. Now read lazily inside the tests, which is the reason `scan()` already
   was.

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

* feat(test-mocks): make withSysReadDeadline a seam on the canonical redis mock

`setup.ts` spread `~/server/redis/sys-read-deadline` into the canonical
`~/server/redis/client` factory, so every migrated file ran the REAL wall-clock
deadline wrapper and a test had no way to inject a sysRedis read timeout. Nine
files across two slices could not migrate: converting them would have left their
timeout legs green and asserting nothing.

The seam's registered default is the real implementation, not a pass-through.
REDIS_SYS_READ_TIMEOUT_MS is in TEST_ENV_DEFAULTS at 2000, so the wrapper is
armed today; a pass-through default would disarm a live guard in every file in
the worker to give nine files a lever.

Resolved by a lazy import. A static import of sys-read-deadline evaluates its
`import { env }` before setup.ts's hoisted `vi.mock('~/env/server')` factory has
initialised, and the file collects zero tests with "Cannot access
'__vi_import_4__' before initialization".

Verified: the guard fails without the seam (1 failed / 11 passed) and passes with
it (12/12); 6 fake-timer files that touch sysRedis are 178/178 both halves; 34
caller-derived files are 396/396 both halves.

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

* test(mocks): convert the last two files blocking the guard

Both arrived on main via #3866 and were named by the allowlist ratchet, which
refused to grow rather than absorb them. Conversions and verification are
liz's; taken from her worktree by direct fetch, two files only, so this PR
keeps the scope it was reviewed with rather than pulling in her whole slice.

model-file.deregister.service -- mockDbWrite aliased both clients, but the only
entry point is deleteFile, which is dbWrite throughout, so the read half was
dead. Its redis and one-export logging mocks went too.

storage-resolver.deregisterByFile -- logging mock converted, and the
~/env/server mock deliberately KEPT. The tests mutate envValues per case, so
lifting it would leave the local alive and disconnected and every later
assignment would write to an object nothing reads -- the class where a run
cannot see the difference. It is a pending specifier, so it does not hold the
canonical gate. Anyone reading the allowlist later will see this file listed
for env: that is a recorded refusal, not an unfinished job.

Her probes, one per file, each aimed at that file's own claim:

  mis-route the client to dbRead        7 of 8 fail, Could not find entity
  point logToAxiom at a fresh vi.fn()   7 of 13 fail

The second is the discriminating check for the shielding family and the one
that was missing when deregisterBatch/deregister went wrong: the file was green
before the conversion and green after, and neither green was informative. The
mutation is the entire result.

Verified here on this branch:

  the two files            21 passed (8 + 13, matching the static priors)
  no-direct-shared-module   4 passed
  shared-mocks             12 passed
  allowlist                canonical 345 -> 345, generator exits 0

* fix(test-perf): stop the per-file comparator calling a failing run clean

Both findings from the adversarial review of this delta, both in the file the
add/add resolution created.

The verdict predicate walked only files present in BOTH runs, so a candidate
that ADDS a file whose tests fail was invisible to it. With the collected count
rising at the same time, it printed:

  CLEAN: 6/5 tests collected, 1 failed

a summary contradicting itself inside the tool written because summary lines
cannot be trusted. Same blind spot as the addedEmpty case one step over, and
that one was already handled.

Verified both ways against synthetic fixtures, a control and a candidate
differing only by an added failing file:

  before   CLEAN: 6/5 tests collected, 1 failed        exit 0
  after    REGRESSION: 6/5 tests collected, 1 failed   exit 1

Also corrects the usage text and the docstring example, which named
compare-runs.mjs -- a different tool sitting beside it that takes the same two
positional arguments and answers a different question. Someone reading the
error would have run the wrong one and got a clean-looking summary.

* style(test-perf): prettier the per-file comparator (CI gates on added files)

* style(test-perf): prettier the branch's added files so the lint gate passes

CI's ESLint + Prettier job gates on ADDED files, and six of this branch's
fifteen added code files were never formatted -- codemod-shared-mocks,
gen-mock-allowlist, residual-mocks, run-pilot, logging.mock and
no-direct-shared-module-mock. Only the last of those arrived recently; the rest
have been failing that check for as long as the branch has existed, which is a
second reason it could not have merged.

logging.mock.ts is loaded by every test file, so this was verified rather than
assumed after reformatting:

  allowlist generator            canonical 345 -> 345, exits 0
  no-direct-shared-module-mock   4 passed
  shared-mocks                  12 passed
  shared-mocks-isolation         passed
  feedback.service               9 passed
  storage-resolver.deregisterByFile  13 passed
  total                         42 passed across 5 files

* fix(test-mocks): use a top-level import type for the seam's module shape

The lazy `typeof import(...)` annotation tripped
@typescript-eslint/consistent-type-imports, which is the one BLOCKING error in
the lint gate's added-files pass -- exactly the trap CLAUDE.md records.

The runtime import at the seam stays lazy for the documented reason: this
module is loaded from setup.ts, vi.mock is hoisted above it, and a static value
import evaluates sys-read-deadline before the env mock's factory has
initialised, collecting ZERO tests. A type-only import is erased and cannot do
that, and the comment says so, so nobody fixes it back into a value import.

  eslint on the 10 added lintable files   0 errors (was 1), exits 0
  prettier                                clean
  shared-mocks / -isolation / guard       20 passed

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 18:06:34 -06:00