Files
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

1158 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Migrate test files off per-file `vi.mock` of a shared infra module and onto the
* canonical mocks in `src/__tests__/mocks/`. See docs/testing/shared-module-mocks.md.
*
* node scripts/test-perf/codemod-shared-mocks.mjs --dry # whole repo, report only
* node scripts/test-perf/codemod-shared-mocks.mjs --write <file>... # convert named files
* node scripts/test-perf/codemod-shared-mocks.mjs --report out.json # machine-readable
*
* It converts only the shapes it can prove are equivalent, and REFUSES anything else —
* every refusal is reported with a reason. A file is either fully converted for a
* specifier or left completely alone; there is no partial rewrite, because a leftover
* `vi.mock` on a canonical specifier re-poisons the whole worker.
*/
import { readFileSync, writeFileSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { globSync } from 'fs';
import ts from 'typescript';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const TARGETS = {
'~/server/db/client': {
roots: ['dbRead', 'dbWrite'],
mock: 'dbMock',
from: '~/__tests__/mocks/db.mock',
},
'~/server/redis/client': {
roots: ['redis', 'sysRedis'],
mock: 'redisMock',
from: '~/__tests__/mocks/redis.mock',
},
'~/server/logging/client': {
roots: ['logToAxiom'],
mock: 'loggingMock',
from: '~/__tests__/mocks/logging.mock',
flat: true,
},
};
const argv = process.argv.slice(2);
const dry = argv.includes('--dry') || !argv.includes('--write');
const reportPath = argv.includes('--report') ? argv[argv.indexOf('--report') + 1] : null;
const listPath = argv.includes('--list') ? argv[argv.indexOf('--list') + 1] : null;
const only = [
...argv.filter((a) => !a.startsWith('--') && a !== reportPath && a !== listPath),
...(listPath
? readFileSync(path.resolve(repoRoot, listPath), 'utf8')
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
: []),
];
function main() {
const results = [];
for (const file of only.length ? only.map((f) => path.resolve(repoRoot, f)) : listTestFiles()) {
const src = readFileSync(file, 'utf8');
if (
!Object.keys(TARGETS).some(
(t) => src.includes(`vi.mock('${t}'`) || src.includes(`vi.mock("${t}"`)
)
)
continue;
const outcome = convert(file, src);
results.push(outcome);
if (!dry && outcome.converted.length) writeFileSync(file, outcome.text);
}
const converted = results.filter((r) => r.converted.length);
const refused = results.filter((r) => r.refusals.length);
console.log(
`${results.length} candidate files | ${converted.length} convertible | ${refused.length} with refusals`
);
const byReason = {};
for (const r of refused)
for (const x of r.refusals) byReason[x.reason] = (byReason[x.reason] ?? 0) + 1;
for (const [reason, n] of Object.entries(byReason).sort((a, b) => b[1] - a[1]))
console.log(` ${String(n).padStart(4)} ${reason}`);
// A hand-written constant that DIFFERS from the real one is a test asserting against a
// key production never emits. Printed separately because it is a finding, not a blocker.
const drifted = results.flatMap((r) =>
[...r.refusals, ...(r.findings ?? [])]
.filter((x) => x.mismatches)
.map((x) => ({ file: path.relative(repoRoot, r.file), ...x }))
);
if (drifted.length) {
console.log(`\nCONSTANTS THAT DRIFTED FROM THE REAL VALUE (${drifted.length})`);
for (const d of drifted) {
console.log(` ${d.file.replace(/\\/g, '/')}`);
for (const m of d.mismatches)
console.log(
` ${m.path}: real ${JSON.stringify(m.expected)} vs test ${JSON.stringify(m.actual)}`
);
}
}
if (reportPath) {
writeFileSync(
path.resolve(repoRoot, reportPath),
JSON.stringify(
results.map((r) => ({
file: path.relative(repoRoot, r.file).replace(/\\/g, '/'),
converted: r.converted,
refusals: r.refusals,
})),
null,
2
)
);
}
if (dry) console.log('\n(dry run — pass --write to apply)');
}
/** Real constants a test factory may hand-write beside the client, and where they live. */
const CONSTANT_EXPORTS = {
REDIS_KEYS: 'REDIS_KEYS_UNPREFIXED',
REDIS_SYS_KEYS: 'REDIS_SYS_KEYS',
};
const UNKNOWN = Symbol('not-statically-evaluable');
let realConstantsCache = null;
/**
* Statically evaluate the real key tables out of `@civitai/redis`.
*
* Parsed rather than imported: the codemod is plain node and the source is TypeScript. Both
* tables are nested object literals of string literals, so a static walk is exact.
*
* `REDIS_KEYS` is `applyCacheKeyPrefix(REDIS_KEYS_UNPREFIXED)`, which is the identity unless
* `CACHE_KEY_NAMESPACE` is set — it is not, under test — so the unprefixed table is the value
* a test sees.
*/
function realConstants() {
if (realConstantsCache) return realConstantsCache;
const file = path.join(repoRoot, 'packages/civitai-redis/src/client.ts');
const sf = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true);
const found = {};
for (const stmt of sf.statements) {
const decls = ts.isVariableStatement(stmt) ? stmt.declarationList.declarations : [];
for (const d of decls) {
if (!ts.isIdentifier(d.name) || !d.initializer) continue;
for (const [exported, source] of Object.entries(CONSTANT_EXPORTS))
if (d.name.text === source) found[exported] = staticValue(d.initializer);
}
}
for (const [exported, source] of Object.entries(CONSTANT_EXPORTS))
if (!found[exported] || found[exported] === UNKNOWN)
throw new Error(`Could not statically read ${source} from ${file} — refusing to guess.`);
realConstantsCache = found;
return found;
}
/** Nested object/string literal to a plain value, or UNKNOWN. */
function staticValue(node) {
let n = node;
while (ts.isAsExpression(n) || ts.isParenthesizedExpression(n)) n = n.expression;
if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) return n.text;
if (ts.isNumericLiteral(n)) return Number(n.text);
if (n.kind === ts.SyntaxKind.TrueKeyword) return true;
if (n.kind === ts.SyntaxKind.FalseKeyword) return false;
if (ts.isArrayLiteralExpression(n)) {
const out = [];
for (const el of n.elements) {
const v = staticValue(el);
if (v === UNKNOWN) return UNKNOWN;
out.push(v);
}
return out;
}
if (ts.isObjectLiteralExpression(n)) {
const out = {};
for (const p of n.properties) {
if (!ts.isPropertyAssignment(p)) return UNKNOWN;
const key = ts.isIdentifier(p.name) || ts.isStringLiteral(p.name) ? p.name.text : null;
if (key === null) return UNKNOWN;
const v = staticValue(p.initializer);
if (v === UNKNOWN) return UNKNOWN;
out[key] = v;
}
return out;
}
return UNKNOWN;
}
/**
* A divergent test value that was obviously never meant to be a real key: it shares no
* `:`-delimited segment with the real one, or is a stub of four characters or fewer, or is
* explicitly namespaced `test:`. Reporting these buries the handful that read as plausible
* real keys, which are the ones a human should look at.
*/
function isPlaceholder(actual, expected) {
if (typeof actual !== 'string' || typeof expected !== 'string') return false;
if (actual.length <= 4) return true;
if (actual.startsWith('test:')) return true;
const segments = new Set(expected.split(':'));
return !actual.split(':').some((seg) => segments.has(seg));
}
/** Every path where `candidate` is not a value-identical subset of `real`. */
function subsetMismatches(candidate, real, prefix) {
if (candidate === UNKNOWN || real === undefined)
return [
{ path: prefix, expected: real, actual: candidate === UNKNOWN ? '<not literal>' : candidate },
];
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate))
return candidate === real || JSON.stringify(candidate) === JSON.stringify(real)
? []
: [{ path: prefix, expected: real, actual: candidate }];
if (typeof real !== 'object' || real === null)
return [{ path: prefix, expected: real, actual: candidate }];
return Object.entries(candidate).flatMap(([k, v]) =>
subsetMismatches(v, real[k], `${prefix}.${k}`)
);
}
function listTestFiles() {
return globSync('src/**/*.test.ts', { cwd: repoRoot }).map((f) => path.join(repoRoot, f));
}
/** Behaviour assignments to emit for the file currently being converted. Module-level
* because `convert` is not reentrant and threading it through every helper buried the shape
* of the walk. Reset at the top of each call. */
let lifts = [];
/** A file that resets implementations between tests cannot carry a module-scope lift: the
* assignment would be wiped before the first test runs. `clearAllMocks` is fine — it clears
* calls, not implementations. */
let liftingAllowed = true;
/** Names declared at the top level of the file being converted. A lifted expression may only
* reference these: anything else was declared inside the factory or the `vi.hoisted` block,
* both of which the conversion deletes. */
let topLevel = new Set();
function convert(file, text) {
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
lifts = [];
liftingAllowed = !/(resetAllMocks|restoreAllMocks)/.test(text);
topLevel = topLevelNames(sf);
const refusals = [];
/** Reported alongside the conversion rather than blocking it. */
const findings = [];
const convertedTargets = [];
/** identifier name -> Set of canonical expressions it was bound to */
const bindings = new Map();
/** ExpressionStatement ranges to delete */
const deletions = [];
const importsNeeded = new Set();
for (const stmt of sf.statements) {
const call = asViMockCall(stmt);
if (!call) continue;
const target = literalText(call.arguments[0]);
const spec = TARGETS[target];
if (!spec) continue;
// No factory: automock. The canonical registration already covers the module.
if (call.arguments.length === 1) {
deletions.push(stmt);
convertedTargets.push(target);
importsNeeded.add(target);
continue;
}
const obj = factoryObject(call.arguments[1]);
if (!obj) {
refusals.push({
target,
reason: 'factory is not a plain object literal (importOriginal / block body)',
});
continue;
}
const local = [];
let ok = true;
const originalNames = obj.__originalNames ?? new Set();
for (const prop of obj.properties) {
if (isOriginalSpread(prop, originalNames)) continue;
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) {
ok = false;
refusals.push({
target,
reason: 'non-literal property in factory (spread or computed key)',
});
break;
}
const root = prop.name.text;
// A factory that hand-writes part of a real constant next to the client. The
// canonical registration spreads the original, so the hand-written copy is
// redundant WHEN IT MATCHES — and when it does not, the test has been asserting
// against a key production never emits, which is a finding rather than an obstacle.
if (CONSTANT_EXPORTS[root]) {
// The canonical registration spreads `@civitai/redis/client`, where both tables are
// defined — so the factory's copy is redundant and can be DELETED rather than
// translated. Proving the literal equals the real constant was the wrong question:
// it is unanswerable for a call expression like `completeKeys({ … })`, and it is not
// what decides safety. What decides safety is whether the test asserts on a key
// string, and a test that never names the constant cannot. (arabella, taxonomy §2.)
if (new RegExp(`\\b${root}\\b`).test(text.replace(prop.getText(), ''))) {
refusals.push({
target,
reason: `${root} is referenced outside the factory — check what it asserts`,
});
ok = false;
break;
}
const literal = staticValue(prop.initializer);
const mismatches =
literal === UNKNOWN ? [] : subsetMismatches(literal, realConstants()[root], root);
// Reported, not refused: a divergent copy is a test asserting against a key
// production never emits, and the redness when the real value swaps in IS the
// finding.
// Two kinds of divergence, and only one is a finding. A PLACEHOLDER the test never
// meant as a real key ("rl", "kill", "test:paid-access") is noise. A value that
// reads as a plausible real key and is wrong is the thing worth someone's attention.
// Encoded as a rule rather than as a list, so a placeholder invented next month is
// also covered. (ivy's Class A / Class B split, as the discriminator she derived.)
const substantive = mismatches.filter((m) => !isPlaceholder(m.actual, m.expected));
if (substantive.length)
findings.push({
target,
reason: `${root} differed from the real constant`,
mismatches: substantive,
});
continue;
}
// Any export other than the client roots is already supplied by the registration,
// which spreads the whole package (`safeError`, `withSysReadDeadline`, the key
// tables). The factory only declared it because replacing the module wholesale meant
// it had to. Same safety question as the constants: droppable unless the test names it
// outside the factory, in which case it may be asserting on the stub.
if (!spec.roots.includes(root)) {
// 🔴 Available is not the same as redundant. The registration does supply the REAL
// export — but a factory that replaces it with a SPY is using it as a control
// surface, and dropping that silently removes the test's ability to drive the
// behaviour it is checking. `session-verifier.test.ts` injected a timeout through
// `withSysReadDeadline`; with the spy gone the deadline never fired and two fail-open
// legs asserted nothing while still passing. (archer's probe.)
//
// So only plain DATA drops. Anything with behaviour — a spy, an identifier, a
// function — is refused.
if (staticValue(prop.initializer) === UNKNOWN) {
refusals.push({
target,
reason: `factory replaces "${root}" with behaviour — it is a control surface, not a redundant re-export`,
});
ok = false;
break;
}
if (new RegExp(`\\b${root}\\b`).test(text.replace(prop.getText(), ''))) {
refusals.push({
target,
reason: `factory declares "${root}", and the test references it — check what it asserts`,
});
ok = false;
break;
}
continue;
}
if (spec.flat) {
if (
!collect(
prop.initializer,
`${spec.mock}.${root}`,
local,
refusals,
target,
spec.flat === true
)
)
ok = false;
} else if (ts.isObjectLiteralExpression(prop.initializer)) {
for (const modelProp of prop.initializer.properties) {
if (!ts.isPropertyAssignment(modelProp) || !ts.isIdentifier(modelProp.name)) {
ok = false;
refusals.push({ target, reason: 'non-literal property inside a client object' });
break;
}
const name = modelProp.name.text;
if (ts.isObjectLiteralExpression(modelProp.initializer)) {
for (const m of modelProp.initializer.properties) {
if (!ts.isPropertyAssignment(m) || !ts.isIdentifier(m.name)) {
ok = false;
refusals.push({ target, reason: 'non-literal property inside a model object' });
break;
}
if (
!collect(
m.initializer,
`${spec.mock}.${root}.${name}.${m.name.text}`,
local,
refusals,
target
)
)
ok = false;
}
} else if (
!collect(modelProp.initializer, `${spec.mock}.${root}.${name}`, local, refusals, target)
) {
ok = false;
}
}
} else if (
!collect(
prop.initializer,
`${spec.mock}.${root}`,
local,
refusals,
target,
spec.flat === true
)
) {
ok = false;
}
if (!ok) break;
}
if (!ok) continue;
for (const [name, expr] of local) {
if (!bindings.has(name)) bindings.set(name, new Set());
bindings.get(name).add(expr);
}
deletions.push(stmt);
convertedTargets.push(target);
importsNeeded.add(target);
}
if (!convertedTargets.length) return { file, text, converted: [], refusals, findings };
// A local bound to two canonical paths is the dbRead/dbWrite aliasing case: one spy
// served both clients, so a write satisfied a read assertion. There is no mechanical
// rewrite that preserves that — the file has to name the client it exercises.
const edits = [];
const hoistedRemovals = [];
const keepLeadingComment = new Set();
const lifted = [];
for (const [name, exprs] of bindings) {
if (exprs.size > 1) {
refusals.push({
target: 'multiple',
reason: `local "${name}" aliases ${[...exprs].join(' and ')} — needs a human`,
alias: [...exprs],
});
return { file, text, converted: [], refusals, findings };
}
const expr = [...exprs][0];
const decl = findDeclaration(sf, name);
if (decl) {
if (!isPlainSpyInitializer(decl.initializer, expr)) {
// Same rule as a factory leaf: behaviour on the declaration lifts onto the canonical
// node rather than blocking the file.
const lift = liftingAllowed ? liftedAssignment(decl.initializer, expr) : null;
if (!lift) {
refusals.push({
target: 'multiple',
reason: `declaration of "${name}" is not a bare vi.fn()/vi.hoisted(() => vi.fn())`,
});
return { file, text, converted: [], refusals, findings };
}
lifts.push(lift);
}
edits.push({
start: decl.initializer.getStart(sf),
end: decl.initializer.getEnd(),
replacement: expr,
});
continue;
}
// `const { a, b } = vi.hoisted(() => ({ a: vi.fn(), b: vi.fn() }))` — the dominant
// shape in this repo. The hoisted object usually also feeds mocks of OTHER modules,
// so only this name's binding and property come out; the rest of the object stays.
const hoisted = findHoistedBinding(sf, name);
if (!hoisted) {
refusals.push({
target: 'multiple',
reason: `no module-scope declaration found for "${name}"`,
});
return { file, text, converted: [], refusals, findings };
}
if (!isPlainSpyInitializer(hoisted.initializer, expr)) {
const lift = liftingAllowed ? liftedAssignment(hoisted.initializer, expr) : null;
if (!lift) {
refusals.push({
target: 'multiple',
reason: `hoisted entry "${name}" is not a bare vi.fn()`,
});
return { file, text, converted: [], refusals, findings };
}
lifts.push(lift);
}
hoistedRemovals.push(hoisted);
lifted.push(`const ${name} = ${expr};`);
}
// Group hoisted removals by their statement so a fully-emptied `vi.hoisted` goes away
// whole rather than leaving `const {} = vi.hoisted(() => ({}))`.
const byStatement = new Map();
for (const h of hoistedRemovals) {
if (!byStatement.has(h.statement)) byStatement.set(h.statement, []);
byStatement.get(h.statement).push(h);
}
for (const [stmt, removals] of byStatement) {
const { pattern, object } = removals[0];
if (
removals.length === object.properties.length &&
removals.length === pattern.elements.length
) {
// Keep any comment above it: a comment over a `vi.hoisted` block usually explains the
// TEST, not the spy declarations, so taking it with the statement loses real prose. A
// comment over a `vi.mock` is about the mock and goes with it.
deletions.push(stmt);
keepLeadingComment.add(stmt);
continue;
}
for (const r of removals) {
edits.push(dropListItem(r.element, pattern.elements, text));
edits.push(dropListItem(r.property, object.properties, text));
// The local the returned property named, when nothing else in the block read it.
if (r.localStatement) {
let end = r.localStatement.getEnd();
while (end < text.length && (text[end] === '\r' || text[end] === '\n')) end++;
let start = r.localStatement.getStart(sf);
// Take the indentation with it, or the removal leaves a whitespace-only line.
while (start > 0 && (text[start - 1] === ' ' || text[start - 1] === '\t')) start--;
edits.push({ start, end, replacement: '' });
}
}
}
for (const stmt of deletions) {
let end = stmt.getEnd();
while (end < text.length && (text[end] === '\r' || text[end] === '\n')) end++;
const start =
stmt.getFullStart() === 0 || keepLeadingComment.has(stmt)
? stmt.getStart(sf)
: leadingStart(stmt, sf, text);
edits.push({ start, end, replacement: '' });
}
// Imports AND the lifted consts go in one insertion after the last import. Anchoring the
// consts on the `vi.hoisted` statement instead put the insertion point inside a range the
// deletion pass then removed, and the two edits overlapped — which silently ate the first
// character of the inserted `const` and the doc comment above it.
// A test's own local can be named exactly what the canonical mock is imported as —
// `const { redisMock } = vi.hoisted(...)` is real, and lifting it produced the
// self-referential `const redisMock = redisMock.redis;`. Where that happens the import is
// aliased and every generated expression re-prefixed, so the local keeps its name and the
// test body still needs no edits.
const taken = new Set([...bindings.keys(), ...topLevelNames(sf)]);
const alias = {};
for (const target of importsNeeded) {
const { mock } = TARGETS[target];
alias[mock] = taken.has(mock) ? `canonical${mock[0].toUpperCase()}${mock.slice(1)}` : mock;
}
const reprefix = (line) =>
Object.entries(alias).reduce(
(acc, [from, to]) =>
from === to ? acc : acc.replace(new RegExp(`\\b${from}\\.`, 'g'), `${to}.`),
line
);
const importLines = [...importsNeeded]
.map((t) => TARGETS[t])
.map((s) =>
alias[s.mock] === s.mock
? `import { ${s.mock} } from '${s.from}';`
: `import { ${s.mock} as ${alias[s.mock]} } from '${s.from}';`
)
.filter((line) => !text.includes(line));
const inserted = [...importLines, ...lifted.map(reprefix), ...lifts.map(reprefix)];
if (inserted.length) {
const lastImport = [...sf.statements].reverse().find((s) => ts.isImportDeclaration(s));
const at = lastImport ? lastImport.getEnd() : 0;
edits.push({ start: at, end: at, replacement: `\n${inserted.join('\n')}` });
}
edits.sort((a, b) => b.start - a.start);
let out = text;
for (const e of edits) out = out.slice(0, e.start) + reprefix(e.replacement) + out.slice(e.end);
return { file, text: out, converted: convertedTargets, refusals, findings };
}
/** Record `name -> canonicalExpr` for an identifier value; accept a bare `vi.fn()` (the
* canonical node already covers it); refuse anything carrying its own behaviour. */
function collect(init, expr, local, refusals, target, allowLift = true) {
if (ts.isIdentifier(init)) {
local.push([init.text, expr]);
return true;
}
if (isBareViFn(init)) return true;
// An anonymous async-noop logToAxiom is the canonical default, and nothing can assert on
// it (no local name), so it drops.
if (expr.endsWith('loggingMock.logToAxiom') && isAsyncNoopSpy(init)) return true;
// `findUnique: vi.fn(async () => null)` and friends restate the canonical default, so
// dropping them is a no-op. Compared against THIS path's default, not a general one.
if (isDefaultEquivalent(expr, init)) return true;
// A pure passthrough — `findMany: (...args) => localFn(...args)` — exists only so the
// factory can reach a hoisted local. Once the factory is gone the wrapper has no
// purpose, and binding the local straight to the canonical node is the same function.
const passthrough = passthroughTarget(init);
if (passthrough) {
local.push([passthrough, expr]);
return true;
}
// Behaviour the canonical default does not cover. It does not have to be refused — it is
// already an expression, so it can be LIFTED onto the canonical node as an explicit
// assignment. Callers decide whether lifting is safe for this file (see liftGuard).
// 🔴 Only a METHOD position may be lifted. At a client ROOT (`redis`, `dbRead`) the value
// is a whole client OBJECT, and wrapping an object as a spy's implementation silently
// loses every method on it — `scanIterator` yields nothing, `del` is gone, and a test
// asserting "nothing was deleted" goes GREEN. Found by archer's five-file probe; the file
// converted with zero refusals and lost eight assertions.
const lift = liftingAllowed && allowLift ? liftedAssignment(init, expr) : null;
if (lift) {
lifts.push(lift);
return true;
}
refusals.push({ target, reason: `inline behaviour at ${expr} (${init.getText().slice(0, 60)})` });
return false;
}
/**
* Rewrite a factory leaf's behaviour as an assignment on the canonical node.
*
* findMany: vi.fn(async () => [1]) -> node.mockImplementation(async () => [1])
* get: vi.fn().mockResolvedValue(null) -> node.mockResolvedValue(null)
* findUnique: mocks.userFindUnique -> node.mockImplementation((...a) => mocks.userFindUnique(...a))
*
* The last form keeps the test's own spy in the call path, so `expect(mocks.userFindUnique)
* .toHaveBeenCalled()` still holds — the assertions do not move.
*/
function liftedAssignment(init, expr) {
// 🔴 The expression is moving to MODULE scope, out of the factory (and possibly out of a
// `vi.hoisted` block body) that the conversion deletes. If it references a name declared
// in there, the lifted line is a `ReferenceError` at import — which collects zero tests.
// `$queryRaw: vi.fn(queryRaw)` where `queryRaw` was a const inside the hoisted body is the
// real case. (arabella's run.) Same shape as the `resetAllMocks` precondition, one scope in.
if (!referencesOnlyTopLevel(init)) return null;
// `vi.fn(fn)` / `vi.fn(async function* () {})`
if (isBareViFnWithArgs(init) && init.arguments.length === 1)
return `${expr}.mockImplementation(${init.arguments[0].getText()});`;
// `vi.fn().mockResolvedValue(v)` and the other single-call configurators.
if (
ts.isCallExpression(init) &&
ts.isPropertyAccessExpression(init.expression) &&
/^mock(ResolvedValue|RejectedValue|ReturnValue|Implementation)$/.test(
init.expression.name.text
) &&
isBareViFn(init.expression.expression) &&
init.arguments.length === 1
)
return `${expr}.${init.expression.name.text}(${init.arguments[0].getText()});`;
// A bare function the factory used directly as the export.
if (ts.isArrowFunction(init) || ts.isFunctionExpression(init))
return `${expr}.mockImplementation(${init.getText()});`;
// A spy reached through an object — `mocks.findMany`, `h.get`. Wrapped rather than
// rebound, because the test asserts on that object's property, not on this node.
if (ts.isPropertyAccessExpression(init) && ts.isIdentifier(init.expression))
return `${expr}.mockImplementation((...args: unknown[]) => (${init.getText()} as (...a: unknown[]) => unknown)(...args));`;
return null;
}
/**
* The canonical post-reset defaults, keyed on the last path segment. Kept in step with
* src/__tests__/mocks/{db,redis}.mock.ts — a value here that the mock does not actually
* default to would make the codemod delete real behaviour.
*/
function canonicalDefaults() {
return {
findMany: '[]',
findUnique: 'null',
findUniqueOrThrow: 'null',
findFirst: 'null',
findFirstOrThrow: 'null',
count: '0',
groupBy: '[]',
aggregate: 'null',
$queryRaw: '[]',
$queryRawUnsafe: '[]',
$executeRaw: '0',
$executeRawUnsafe: '0',
get: 'null',
hGet: 'null',
hGetAll: '{}',
mGet: '[]',
sMembers: '[]',
zRange: '[]',
lRange: '[]',
keys: '[]',
exists: '0',
del: '0',
sCard: '0',
set: "'OK'",
setEx: "'OK'",
};
}
/**
* A whole client built as an object literal of spies —
* `mockDbRead: { $queryRaw: vi.fn(), collection: { findFirstOrThrow: vi.fn() } }` — bound to
* the factory as `dbRead: mockDbRead`.
*
* Equivalent to the canonical client root when every leaf is a bare `vi.fn()` or restates
* that leaf's own canonical default, because binding the root makes each leaf vivify at
* exactly the path the literal named. A leaf carrying real behaviour is refused: it would be
* silently dropped.
*/
function isEquivalentClientLiteral(node, canonicalExpr) {
if (!ts.isObjectLiteralExpression(node)) return false;
for (const prop of node.properties) {
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) return false;
const childExpr = `${canonicalExpr}.${prop.name.text}`;
const init = prop.initializer;
if (isBareViFn(init) || isDefaultEquivalent(childExpr, init)) continue;
if (isEquivalentClientLiteral(init, childExpr)) continue;
// A leaf carrying behaviour does not disqualify the literal — the behaviour lifts onto
// the canonical node. `$executeRaw: vi.fn().mockResolvedValue(undefined)` beside four
// bare spies was the whole reason these clients refused. (arabella, taxonomy §3.)
if (liftingAllowed) {
const lift = liftedAssignment(init, childExpr);
if (lift) {
lifts.push(lift);
continue;
}
}
return false;
}
return node.properties.length > 0;
}
/** True when an inline spy just restates this path's canonical default. */
function isDefaultEquivalent(canonicalExpr, node) {
const expected = canonicalDefaults()[canonicalExpr.slice(canonicalExpr.lastIndexOf('.') + 1)];
if (!expected || !ts.isCallExpression(node)) return false;
let returned = null;
if (isBareViFnWithArgs(node) && node.arguments.length === 1) {
const fn = node.arguments[0];
if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && fn.body && !ts.isBlock(fn.body))
returned = fn.body;
} else if (
ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.text === 'mockResolvedValue' &&
isBareViFn(node.expression.expression) &&
node.arguments.length === 1
) {
returned = node.arguments[0];
}
if (!returned) return false;
while (ts.isParenthesizedExpression(returned) || ts.isAsExpression(returned))
returned = returned.expression;
return returned.getText().replace(/\s+/g, '') === expected.replace(/\s+/g, '');
}
/** True when every free identifier in `node` is declared at the file's top level. Locally
* bound names (a parameter, a local const) are fine — they travel with the expression. */
function referencesOnlyTopLevel(node) {
const bound = new Set();
let ok = true;
const declare = (name) => {
if (!name) return;
if (ts.isIdentifier(name)) bound.add(name.text);
else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name))
for (const el of name.elements) if (ts.isBindingElement(el)) declare(el.name);
};
const walk = (n) => {
if (!ok) return;
// Types are erased before this code runs, so an identifier in an annotation is not a
// runtime reference. Walking them refused `vi.fn(async (a: { data: any }) => …)` for
// naming `any`.
if (ts.isTypeNode(n) || ts.isTypeParameterDeclaration(n)) return;
if (ts.isFunctionLike(n)) for (const p of n.parameters) declare(p.name);
if (ts.isVariableDeclaration(n)) declare(n.name);
// The `.b` of `a.b` is a property, not a reference; only the leftmost is.
if (ts.isPropertyAccessExpression(n)) {
walk(n.expression);
return;
}
if (ts.isPropertyAssignment(n) && ts.isIdentifier(n.name)) {
walk(n.initializer);
return;
}
if (ts.isIdentifier(n)) {
const name = n.text;
if (!bound.has(name) && !topLevel.has(name) && !GLOBALS.has(name)) ok = false;
return;
}
n.forEachChild(walk);
};
walk(node);
return ok;
}
/** Ambient names a lifted expression may legitimately reference. */
const GLOBALS = new Set([
'undefined',
'null',
'true',
'false',
'Promise',
'Array',
'Object',
'String',
'Number',
'Boolean',
'Date',
'Math',
'JSON',
'Error',
'Symbol',
'Map',
'Set',
'RegExp',
'BigInt',
'console',
'process',
'globalThis',
'vi',
'expect',
'unknown',
'any',
'void',
'never',
'Record',
'Partial',
'Awaited',
'ReturnType',
'Buffer',
'AbortController',
]);
/** `(...args) => ident(...args)`, including the `(...(args as X))` cast spelling. */
function passthroughTarget(node) {
const fn =
ts.isCallExpression(node) && isBareViFnWithArgs(node) && node.arguments.length === 1
? node.arguments[0]
: node;
if (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) return null;
if (fn.parameters.length !== 1 || !fn.parameters[0].dotDotDotToken) return null;
const param = fn.parameters[0].name;
if (!ts.isIdentifier(param)) return null;
let body = fn.body;
if (!body || ts.isBlock(body)) return null;
while (ts.isParenthesizedExpression(body)) body = body.expression;
if (!ts.isCallExpression(body) || !ts.isIdentifier(body.expression)) return null;
if (body.arguments.length !== 1) return null;
const arg = body.arguments[0];
if (!ts.isSpreadElement(arg)) return null;
let spread = arg.expression;
while (ts.isParenthesizedExpression(spread) || ts.isAsExpression(spread))
spread = spread.expression;
if (!ts.isIdentifier(spread) || spread.text !== param.text) return null;
return body.expression.text;
}
function isBareViFnWithArgs(node) {
return (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'vi' &&
node.expression.name.text === 'fn'
);
}
/** `vi.fn().mockResolvedValue(undefined)`, `vi.fn(async () => undefined)`,
* `vi.fn(() => Promise.resolve())` — all the spellings of "resolves to nothing". */
function isAsyncNoopSpy(node) {
if (!ts.isCallExpression(node)) return false;
const text = node.getText().replace(/\s+/g, '');
return (
text === 'vi.fn().mockResolvedValue(undefined)' ||
text === 'vi.fn(async()=>undefined)' ||
text === 'vi.fn(async()=>{})' ||
text === 'vi.fn(()=>Promise.resolve())' ||
text === 'vi.fn(()=>Promise.resolve(undefined))'
);
}
function isBareViFn(node) {
return (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'vi' &&
node.expression.name.text === 'fn' &&
node.arguments.length === 0
);
}
/** Delete one element of a comma-separated list, taking the separator with it. */
function dropListItem(item, list, text) {
const index = list.indexOf(item);
const start = item.getStart();
let end = item.getEnd();
if (index < list.length - 1) {
while (end < text.length && text[end] !== ',') end++;
end++; // the comma
while (end < text.length && (text[end] === ' ' || text[end] === '\r' || text[end] === '\n'))
end++;
} else {
// Last item: eat the PRECEDING comma instead, or a trailing one if present.
if (text[end] === ',') end++;
}
return { start, end, replacement: '' };
}
/** Every name declared at the top level of the file — imports, consts, functions, classes. */
function topLevelNames(sf) {
const names = new Set();
const add = (node) => {
if (!node) return;
if (ts.isIdentifier(node)) names.add(node.text);
else if (ts.isObjectBindingPattern(node) || ts.isArrayBindingPattern(node))
for (const el of node.elements) if (ts.isBindingElement(el)) add(el.name);
};
for (const stmt of sf.statements) {
if (ts.isVariableStatement(stmt))
for (const d of stmt.declarationList.declarations) add(d.name);
else if (ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt)) add(stmt.name);
else if (ts.isImportDeclaration(stmt) && stmt.importClause) {
add(stmt.importClause.name);
const b = stmt.importClause.namedBindings;
if (b && ts.isNamedImports(b)) for (const el of b.elements) add(el.name);
else if (b && ts.isNamespaceImport(b)) add(b.name);
}
}
return names;
}
function findHoistedBinding(sf, name) {
for (const stmt of sf.statements) {
if (!ts.isVariableStatement(stmt)) continue;
for (const d of stmt.declarationList.declarations) {
if (!ts.isObjectBindingPattern(d.name) || !d.initializer) continue;
const call = d.initializer;
if (
!ts.isCallExpression(call) ||
!ts.isPropertyAccessExpression(call.expression) ||
!ts.isIdentifier(call.expression.expression) ||
call.expression.expression.text !== 'vi' ||
call.expression.name.text !== 'hoisted'
)
continue;
const fn = call.arguments[0];
if (!fn || (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) || !fn.body) continue;
// A block body builds its clients as locals and returns them by name:
// vi.hoisted(() => { const dbRead = {...}; return { mockDbRead: dbRead }; })
// Track those locals so the returned identifier can be resolved to its literal.
const locals = new Map();
let body = fn.body;
if (ts.isBlock(body)) {
let returned = null;
for (const s of body.statements) {
if (ts.isVariableStatement(s)) {
for (const local of s.declarationList.declarations)
if (ts.isIdentifier(local.name) && local.initializer)
locals.set(local.name.text, { declaration: local, statement: s });
continue;
}
if (ts.isReturnStatement(s) && s.expression) returned = s.expression;
}
if (!returned) continue;
body = returned;
}
while (ts.isParenthesizedExpression(body)) body = body.expression;
if (!ts.isObjectLiteralExpression(body)) continue;
const element = d.name.elements.find(
(e) =>
ts.isIdentifier(e.name) && e.name.text === name && !e.propertyName && !e.dotDotDotToken
);
const property = body.properties.find(
(p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === name
);
if (!element || !property) continue;
// Resolve `mockDbRead: dbRead` to `dbRead`'s literal. The local is only removable if
// nothing else in the block reads it — counted textually, and conservatively: two
// occurrences means the declaration plus this one reference.
let initializer = property.initializer;
let localStatement = null;
if (ts.isIdentifier(initializer) && locals.has(initializer.text)) {
const local = locals.get(initializer.text);
const uses = (fn.getText().match(new RegExp(`\\b${initializer.text}\\b`, 'g')) ?? [])
.length;
if (uses === 2) localStatement = local.statement;
initializer = local.declaration.initializer;
}
return {
statement: stmt,
pattern: d.name,
object: body,
element,
property,
initializer,
localStatement,
};
}
}
return null;
}
function isPlainSpyInitializer(node, canonicalExpr = '') {
if (!node) return false;
if (isBareViFn(node)) return true;
if (canonicalExpr && isEquivalentClientLiteral(node, canonicalExpr)) return true;
// `vi.fn().mockResolvedValue(undefined)` is exactly the canonical logToAxiom default,
// so binding it to the canonical node loses nothing. Deliberately NOT generalised: for
// any other node the default differs and dropping the behaviour would change the test.
if (canonicalExpr.endsWith('loggingMock.logToAxiom') && isAsyncNoopSpy(node)) return true;
// vi.hoisted(() => vi.fn())
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'vi' &&
node.expression.name.text === 'hoisted' &&
node.arguments.length === 1
) {
const fn = node.arguments[0];
if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && fn.body && !ts.isBlock(fn.body))
return isBareViFn(fn.body);
}
return false;
}
function findDeclaration(sf, name) {
for (const stmt of sf.statements) {
if (!ts.isVariableStatement(stmt)) continue;
for (const d of stmt.declarationList.declarations)
if (ts.isIdentifier(d.name) && d.name.text === name) return d;
}
return null;
}
function asViMockCall(stmt) {
if (!ts.isExpressionStatement(stmt)) return null;
const call = stmt.expression;
if (!ts.isCallExpression(call)) return null;
const callee = call.expression;
if (!ts.isPropertyAccessExpression(callee)) return null;
if (!ts.isIdentifier(callee.expression) || callee.expression.text !== 'vi') return null;
if (callee.name.text !== 'mock') return null;
return call;
}
function literalText(node) {
return node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
? node.text
: null;
}
/**
* The object literal a `vi.mock` factory returns, in every spelling this repo uses:
*
* () => ({ … })
* async (importOriginal) => ({ ...(await importOriginal()), … })
* async (importOriginal) => { const actual = await importOriginal(); return { ...actual, … }; }
*
* The `importOriginal` spread is dropped rather than refused: the canonical registration in
* setup.ts spreads the original itself, so a file re-spreading it adds nothing. Only the
* OVERRIDES have to be accounted for.
*/
function factoryObject(node) {
if (!node || (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node))) return null;
const originalNames = new Set();
if (node.parameters.length) {
if (node.parameters.length > 1) return null;
const p = node.parameters[0].name;
if (!ts.isIdentifier(p)) return null;
originalNames.add(p.text);
}
let body = node.body;
if (!body) return null;
if (ts.isBlock(body)) {
// Collect `const actual = await importOriginal()` so the return's `...actual` is
// recognised as the original rather than as unknown state.
let returned = null;
for (const stmt of body.statements) {
if (ts.isVariableStatement(stmt)) {
// A local that IS the original gets remembered, so the return's `...actual` is
// recognised. Any other local — a `make()` key-proxy helper, a shared spy — is left
// alone rather than refused: the whole factory is being deleted, so its locals go
// with it and their role does not need to be knowable.
for (const d of stmt.declarationList.declarations)
if (
ts.isIdentifier(d.name) &&
d.initializer &&
isOriginalCall(d.initializer, originalNames)
)
originalNames.add(d.name.text);
continue;
}
if (ts.isReturnStatement(stmt) && stmt.expression) {
returned = stmt.expression;
continue;
}
return null;
}
if (!returned) return null;
body = returned;
}
while (ts.isParenthesizedExpression(body)) body = body.expression;
if (!ts.isObjectLiteralExpression(body)) return null;
body.__originalNames = originalNames;
return body;
}
/** `await importOriginal()`, `importOriginal()`, `await vi.importActual('…')`. */
function isOriginalCall(node, originalNames) {
let call = node;
if (ts.isAwaitExpression(call)) call = call.expression;
if (!ts.isCallExpression(call)) return false;
if (ts.isIdentifier(call.expression)) return originalNames.has(call.expression.text);
return (
ts.isPropertyAccessExpression(call.expression) &&
ts.isIdentifier(call.expression.expression) &&
call.expression.expression.text === 'vi' &&
call.expression.name.text === 'importActual'
);
}
function isOriginalSpread(prop, originalNames) {
if (!ts.isSpreadAssignment(prop)) return false;
let expr = prop.expression;
while (ts.isParenthesizedExpression(expr) || ts.isAsExpression(expr)) expr = expr.expression;
if (ts.isIdentifier(expr)) return originalNames.has(expr.text);
return isOriginalCall(expr, originalNames);
}
/** Start of the statement including any comment lines directly above it, so deleting a
* `vi.mock` takes its explanatory comment with it rather than orphaning it. */
function leadingStart(stmt, sf, text) {
const ranges = ts.getLeadingCommentRanges(text, stmt.getFullStart()) ?? [];
return ranges.length ? ranges[0].pos : stmt.getStart(sf);
}
// Called LAST, after every `const` table below is initialised — a call up top reads them
// in their temporal dead zone.
main();