Commit Graph

206 Commits

Author SHA1 Message Date
Zachary Lowden e76db50b34 fix(blocks): stop enumerating capabilities in the ai:write:budgeted consent copy (#4891)
* fix(blocks): stop enumerating capabilities in the ai:write:budgeted consent copy

Operator-confirmed 2026-09-16: an inline customComfy graph CAN generate video.
That makes the sentence #4879 shipped -- "...generating images and running
language models" -- UNDER-name a reachable capability, which is the worse
direction for consent: the user agrees to "images" while the app spends their
Buzz on video.

The copy is now generic:

  "Run AI generation services that spend the viewer's Buzz, with a per-call cap"

WHY GENERIC RATHER THAN A BETTER LIST. Enumerating was tried three times and was
wrong three times, each caught by a later audit round:

  - "training models"  -- over-promised; no implemented billing mode can carry
                          a variable-cost step, so training cannot be registered
  - "and video"        -- cut as unreachable on enum reasoning that covered every
                          arm EXCEPT the one bounded by no enum
  - that cut itself    -- wrong: customComfy mode:'inline' forwards an arbitrary
                          graph and the read path applies no media-type check

Each failure costs a re-consent of every live grant, because consent is stored
per (user, app) and no lookup reads a version. A generic term cannot be
falsified by a capability arriving or turning out to be reachable, which is the
property that makes ONE re-consent sufficient instead of a fourth. The guard is
re-pointed to match: it now pins the SHAPE (no modality nouns) rather than
asserting a list of things believed unreachable -- which is what it did before,
with `video` on that list, asserting something false about the product.

Watched red: 3 of 4 assertions fail against the sentence currently on main.

Also fixes the three comment defects flagged during #4879's audit ladder and
deliberately left out of it to avoid widening that diff mid-ladder:
  - civitai-redis client.ts and block-registry.service.ts both put the
    revocation marker TTL at 15 minutes; it is MAX_BLOCK_TOKEN_LIFETIME_SECONDS
    = 14400s (4h), the dev-token lifetime it must outlive
  - block-revocation.service.ts said the marker is written when "the publisher
    is banned"; no such writer exists -- revokeInstance has exactly two
    production call sites, and block-scope.middleware.ts marks publisher-ban
    "(Phase 2)"

The SQL's "decide this before you run anything" block is replaced by the answer
and its consequence, including the precondition a merge does NOT satisfy:
civitai deploys from `release`, not `main`, so the generic sentence must be
confirmed rendering in the production consent modal before the grants are
re-taken.

194 files / 4929 tests green; typecheck 0 errors; prettier and eslint clean on
every changed file -- the last of those is the gate I missed on #4879 and only
caught at merge time.

* fix(blocks): use "AI work", not "AI generation services", and record decision 6 as superseded

Round 0 of the audit ladder on this PR found two defects in it. Both are fixed
here.

1. The proposed copy reused the one root word the re-consent exists to retire.

   This file's own `.sql` header says, and has said since #4879: "The per-call
   cap half is still true. 'Generations' is not: the scope reaches hosted LLM
   inference (chatCompletion, registered and live), which is not a generation
   in any sense a reader of that sentence would have understood." That sentence
   is the entire justification for revoking 15 live grants.

   "Run AI generation services that spend the viewer's Buzz" is built on the
   same root. On Civitai the word is narrower still -- "Generate" and
   "Train a LoRA" are two distinct top-level actions -- so the draft read
   NARROWER than the sentence already on release, on both capabilities the
   widening actually added. It satisfied "do not enumerate" and still
   re-committed the defect it was written to fix.

   The copy is now "Run AI work that spends the viewer's Buzz, with a per-call
   cap" -- #4879's sentence with only the enumerating clause deleted.

   A new guard pins this, because the draft was caught by a human reading it
   and nothing else: `does not rebuild the sentence on the word the scope
   outgrew` matches /generat/ as a prefix, so generation/generations/
   generating/generative are all refused. Watched RED against the rejected
   draft, failing with its own assertion message and not to a neighbouring
   guard: 3 red / 2 green, the two greens being `per-call cap` and `does not
   enumerate capabilities`, both correct since the draft enumerates nothing.
   The draft is also added to SUPERSEDED, labelled as never-shipped.

2. The copy silently reversed an operator-taken decision.

   Decision 6 of appblocks-no-allowlist-decision-2026-09-15.md §5a required
   this sentence to say "an app may train a model on the viewer's Buzz" in
   words. Neither the PR body nor any comment recorded that it was being
   dropped.

   Operator, 2026-09-16: decision 6 is SUPERSEDED, because training is not
   reachable -- isBillingModeImplemented accepts 'prepaidFixed' only -- so
   naming it would BANK permission for a widening that has not shipped, which
   is the silent scope escalation this table exists to prevent. Recorded in
   the constant, in the test's guard comment, and in the .sql, each with the
   reason and the re-take obligation for when #599 lands.

Also fixes two things in the .sql that the round found actively wrong against
live production:

- The ORDERING block's recognition procedure told the operator to check that
  "the new sentence names language models and training explicitly". After the
  release cut at 21:49Z, production DOES name language models -- so that test
  now PASSES against a sentence the arc has already retracted, which is
  verbatim the failure the same file calls "THE ONE WAY TO GET THIS WRONG".
  Replaced with a character-for-character match plus the ?ref=release read.

- Two blocks gave opposite standing instructions: one said the sentence names
  only what is reachable and grants are re-taken whenever that changes, the
  other said a generic term makes ONE re-consent sufficient. Reconciled: the
  generic wording removes the MODALITY treadmill; a change of KIND still costs
  a new sentence and fresh grants, and training is the live example.

Trimmed ~35 lines of narrative from the constant's comment block, which
duplicated in a seventh place an arc already recorded in SUPERSEDED next to
the test that goes red.

Verification: consent-copy suite 5/5 green; mutation control above; prettier
and eslint clean on both changed TS files, each with a positive control
watched to fire (prettier flags a misformatted probe and prints an explicit
"No files matching the pattern" on a non-match; eslint errors on a planted
no-var) -- this arc has run six rounds without ever running either.

* fix(blocks): stop the comment fixes from re-committing the rot they fix

Round 1 findings 3 and 4, both in the comment-fix half of this PR.

- block-revocation.service.ts: the new docblock says no publisher-ban writer
  exists and "do not reason about a ban path from here", and fourteen lines
  later the catch comment still said "an uninstall/toggle/ban write path".
  A reader landing on revokeInstance saw a ban writer named inside the
  function whose docblock had just denied one. Dropped the word.

- client.ts and block-registry.service.ts: both new comments restated the TTL
  as a literal "14400s / 4h". block-token-lifetimes.ts says in its own header
  that restating it as a number is what let the revocation TTL sit at 15min
  for the whole time dev tokens lived 4h -- so the fix for a wrong hardcoded
  figure introduced two fresh ones, in the same shape, guarded by nothing:
  the live block-revocation-ttl.test.ts asserts ttl >= lifetime derived from
  real signed tokens, so raising the dev lifetime keeps every test green and
  silently makes both comments wrong in the same direction as the 15-minute
  claim. Both already named MAX_BLOCK_TOKEN_LIFETIME_SECONDS; the parenthesised
  number is now gone and each says why it is not restated.

Prettier and eslint clean on all three files.

* fix(blocks): drop depth-dependent line cites this PR itself invalidated

Round 2 findings F1 and F4.

F1 is the ladder's own signature failure: the previous round's comment growth
moved the target of a line-number cite onto the semantically OPPOSITE call.
`block-registry.service.ts:2393` held `revokeInstance` inside
`toggleEnabled(false)` on main and at the merge-base; after round 1 (+1) and
round 2 (+1) of comment edits it holds
`await BlockRevocation.clearInstance(...)` -- the revoke CLEAR.

Two files cited it: the one-off SQL's operator-facing runbook, and
`scope-grant.service.ts` (untouched by this PR, so nothing in the diff flagged
it, and neither file is touched on main since the merge-base, so the drift
survives the merge). An operator working the SQL header's "no endpoint exists
whose purpose is revocation" bullet would open :2393, land on the clear, and
conclude that toggling an install off REMOVES markers -- inverting the
containment reasoning that bullet list exists to support.

Fix is to delete the `:NNNN` suffixes rather than re-derive them. The function
names are unambiguous and depth-independent; a line number in a comment is a
cross-reference that goes stale silently and that no test pins.

F4: the client.ts comment told the reader to "read the constant" and named a
bare `block-token-lifetimes.ts`. That file is
`src/server/services/block-token-lifetimes.ts` in the Next app -- a different
workspace package with no import path from `packages/civitai-redis` -- so the
pointer was unfollowable from where it was written. Repo-relative path now,
plus the reason it cannot simply be imported.

prettier + eslint clean on both changed files; eslint's zero earned against a
planted no-var control on the same path and config (round 2 flagged that the
previous round's eslint zero was unproven).

* fix(blocks): sweep the publisher-ban claim, and bound the SQL's ~15min window

Round 3 findings. Two of the three are addressed here; the third was WRONG and
is refuted in the file rather than applied.

## The publisher-ban sweep (round 3's 🟡-3, round 2's F2)

This PR corrected "or the publisher is banned" LOUDLY in
`block-revocation.service.ts` -- "NO SUCH WRITER EXISTS ... do not reason about
a ban path from here" -- and nowhere else, leaving five other sites still
describing a ban as a live revocation-marker writer. That made the tree
self-contradictory in the direction that matters: a moderator reading
`apps.router.ts` would believe banning a publisher kills that publisher's live
block tokens within seconds. It does not; they run to natural `exp`.

Swept all five: `apps.router.ts`, `apps-shared.router.ts`,
`block-bridge-auth.service.ts`, and the two guard tests' header comments. Each
now carries the retraction rather than a bare deletion, so the next reader sees
that the claim was checked and found false. Verified by enumeration: the only
four remaining occurrences of the phrase in `src/` are all retractions.

Round 2 routed this to "the follow-up PR that will gate publishGenerationOutputs
on posts:write:self". Round 3 was right to reject that: no such PR or issue
exists, and adding a scope gate to one endpoint has no reason to touch five
comments about who writes a revocation marker. That was an object with no
closing condition and no owner. Fixing it here instead.

## The ~15 MINUTES window (round 3's 🟡-2) -- REFUTED, and the refutation is
## now in the file

Round 3 read item 1's "900s (300s settings-scoped, 4h dev)" together with its
"up to ~15 MINUTES" conclusion and called the window 16x understated. Round 2
had examined the same thing and cleared it. Round 2 was right:

  - dev-token SCOPES do not come from `app_user_scope_grants` at all.
    `dev-scoped-mint.service.ts` derives them from the manifest, clamped to
    DEV_TOKEN_SCOPE_ALLOWLIST and the app's OAuth ceiling -- so revoking a
    consent row cannot narrow a dev token;
  - dev-token SPEND never consults consent.
    `if (claims.dev === true) return { ...platform, consent: null }` returns
    before `getConsentBuzzBudget` is reached.

So a dev token is unaffected by this script in both directions, and waiting 4h
would not close anything. Applying round 3's fix would have made the runbook
wrong in the opposite direction -- telling an operator to wait 16x longer than
the real consent-token window.

What WAS missing is that item 1 never said which population the bound covers.
It does now, with the mechanism and an explicit "do not fix this number upward",
because two audit rounds disagreed about it and the next one should not have to
re-derive it.

Verification: 41 tests across the consent-copy, revocation-TTL, ban-session and
bridge-token guard suites pass. prettier/eslint on the changed files add no new
findings -- `apps.router.ts` is unformatted on HEAD already (confirmed against a
clean checkout) and carries 7 pre-existing no-empty-function errors before and
after this edit, so the edit was kept minimal rather than running prettier
--write, which would have reformatted 787 lines of unrelated code.

* fix(blocks): sweep the ban claim by MEANING, not spelling, and fix the authority it cites

Round 4 findings. The previous round's sweep was the finding this time.

## The sweep matched a SPELLING, and the claim has another word order

`no-unguarded-block-bridge-token.test.ts:940` still read "a revoked install, a
suspended app and a banned publisher all still drive them until the token
expires". My enumeration matched `publisher ban` and `publisher-banned`;
"a banned publisher" is neither, so it survived a sweep that reported itself
complete -- in the same file whose header comment 930 lines above it WAS swept.

It is also the worst surviving instance, because it is a live assertion
message rather than a comment: it prints when the guard test goes red, so a
developer adding a bridge proc reads it at exactly the moment they are deciding
what to do, and it tells them routing through authorizeBlockBridgeToken
contains a banned publisher. It does not -- the guard's three steps are token
validity, the revocation marker and app_blocks.status, and toggleBan writes
none of the three.

Re-enumerated by MEANING this time (any word order, case-insensitive, over src/
and packages/, with a positive control confirming 141 files match "publisher"
so the pipeline was live). Every remaining co-occurrence is a retraction or the
unrelated PublisherSubscriptionBanner component.

## The authority four retractions cite said the opposite

Two retractions offered `block-scope.middleware.ts`'s "(Phase 2)" marking as
their ONLY evidence, and that line read "Uninstall, toggleEnabled(false), and
(Phase 2) publisher-ban all write a marker" -- main verb asserting all three DO.
"(Phase 2)" cannot carry "this one does not", and it does not mean unshipped in
this tree: dev-scoped-mint.service.ts and blocks.router.ts both label live,
tested surfaces "(Phase 2)". A reader following the pointer landed on a
contradiction of the thing it was cited for.

That line now states the enumeration itself -- two production call sites, named
-- so the citations resolve to evidence rather than to a label.

## DEV_BUZZ_BUDGET_CAP was paired with the wrong reason

The SQL said item 2's cap-lift cannot compound onto a dev token because it
"carries its own DEV_BUZZ_BUDGET_CAP". That cap is 250 PER CALL, not a daily
bound, and a dev token still reserves against the full platform per-day ceiling.
The actual reason is the claims.dev short-circuit named in the clause before it.
Reworded so an operator cannot read it as "dev tokens are capped at 250/day",
and the dev-token holder population widened from "the mod" to authors too, who
hold them via the cookie dev-tunnel branches.

Verification: 34 tests across the bridge-token-guard, revocation-TTL and
consent-copy suites; prettier and eslint clean on both changed files.

Also noting, since it cannot be amended into the commit it belongs to: the
"291 rules" figure in the round-3 correction comment does not reproduce --
measured 285-287 depending on the src/ path. The load-bearing half (2 rules and
no no-var under the packages/ root:true boundary config) reproduces exactly.

* fix(blocks): finish the ban-claim citation chain, and stop asserting a false universal

Round 5 findings, all four.

A. Four files cited "block-scope.middleware.ts marks publisher-ban (Phase 2)"
   as their evidence. Round 4 rewrote that line -- and the replacement
   explicitly says "(Phase 2)" could NOT carry that meaning and labels shipped
   things elsewhere in this tree. So every pointer cited an authority that had
   just repudiated the inference form the citation used. All four now carry the
   two-call-site enumeration instead of the label.

B. A false universal this ladder introduced last round, in the block those four
   files are pointed at: "toggleBan (user.service.ts) calls only
   invalidateSession". It is a ~250-line function that also unpublishes every
   model the user owns, cancels their subscription, blocks their media, deletes
   their user links and flags their comments. A maintainer asking "what does
   banning do?" would have read the authoritative-looking enumeration and
   concluded a ban only logs you out. The correct narrow form was five lines
   away in the test message the same commit wrote: it writes none of the THREE
   THINGS THIS GUARD CHECKS. Now says that, and says explicitly not to read it
   as "a ban only logs you out".

C. `appblocks-no-allowlist-decision-2026-09-15.md` was cited three times,
   including from the payload, as though it were a path in this repo. It is not
   in this repo at all -- it lives in the private civitai/talos-infra clone. The
   one operator who most needs to check the superseded decision could not open
   it and had no way to know why. All three sites now name the repo.

D. Round 4's fix put four lines of dated retraction history into a LIVE
   assertion message -- the surface round 4 had just identified as the worst
   place for exactly this, because it prints when CI goes red. Moved to the file
   header; the message is back to the instruction a developer needs.

Verification: 34 tests across bridge-token-guard, revocation-TTL and
consent-copy. prettier clean on all 7 touched files. eslint reports 10 errors,
all pre-existing `no-empty-function` in apps-shared.router.ts (same count as
before this edit; line numbers shifted +1 by an added comment line) -- read
without a pipe, since `eslint | grep; echo rc=$?` reports grep's status.

This is the last round. Rationale in the PR comment: rounds 3-5 have audited
prose the ladder itself wrote, the executable payload has been unchanged since
57b9f8638e, and this round's fixes twice broke grammar in sentences written to
fix earlier grammar. Remaining known-imperfect items are recorded there rather
than fixed, so the next reader sees them as open rather than absent.
2026-09-16 19:37:36 -05:00
Zachary Lowden 4a1535b2c7 feat(blocks): deny platform-internal orchestrator step types, and rewrite the ai:write:budgeted consent copy (#4879)
* feat(blocks): deny platform-internal orchestrator step types, and rewrite the ai:write:budgeted consent copy

App Blocks are moving to full orchestrator access with no per-app allowlist.
Under that direction the bound stops being "an allowlist of things we said yes
to" and becomes "everything except the things that are ours". This adds that
exception set, and re-words the consent sentence the widening invalidated.

The denylist is 15 of the 50 step types the LIVE orchestrator spec carries
today (the pinned @civitai/client knows only 47): scanners, moderation
classifiers, hashing/model ingestion, the catalog's own "Platform internals"
entries, and web egress.

training and chatCompletion are deliberately ALLOWED. Moving training out of
the platform-internal set reverses an earlier classification and was an
explicit operator decision -- it creates model versions and is the largest
per-call Buzz cost in the catalog, so the new consent copy names it in words
and a test pins it rather than leaving it to a comment.

ageClassification, mediaRating and wdTagging are in the denylist on a
moderation-oracle argument that is mine, not the operator's, and both the
module docblock and a named test mark them reversible: with moderation moved
to the publish boundary, an app that can call the platform's own classifiers
can iterate content until it passes.

SCOPE: the denylist bites in exactly one place today, and the code says so.
No current wire arm lets a block name an arbitrary $type -- kind:'step' is
enum-bound to REGISTERED_STEP_IDS and textToImage/customComfy build their own
-- so the live property is "a platform-internal type cannot be REGISTERED as a
block step", not "a block cannot submit one". When the wide kind:'steps' arm
lands it must call assertStepTypeAllowed on the submitted $type itself.

The guard was UNREACHABLE where it was first placed. Sitting after clause (9)
it never executed: the posture/$type agreement clause rejected a mutated entry
earlier, with its own error. Only the test asserting
PlatformInternalStepTypeError SPECIFICALLY exposed that -- a generic
.toThrow() would have been green over dead code. It is now clause (0a), first
after the identity check, which also gives an author the right error.

Consent copy: the per-call cap half of "Submit generations with a per-call Buzz
cap" is still true; "generations" is not. Rewriting the string does NOT re-ask
anybody -- consent is stored per (user, app) and the lookup never reads the
version column it stamps -- so the re-consent SQL is included, as raw SQL that
nothing in this repo executes. It is applied BY HAND and ONLY AFTER the copy is
confirmed live; reversed, users re-consent to the old sentence and both halves
still look done.

Tests, with the matrix watched rather than assumed:
  - consent copy: 4/4 RED at origin/main, green here
  - denylist seam: removing clause (0a) kills exactly the seam test, on the
    absent PlatformInternalStepTypeError -- so the guard is reachable and its
    deletion is detected
  - blocks + schema suites: 194 files / 4928 tests green; typecheck 0 errors

Refs clawgate #598. Does not close task 188 (user-facing withdraw), which is
adjacent and deliberately untouched.

* fix(blocks): round-0 audit — retract 3 denylist entries, fix a false immutability claim, reconcile the mediaHash note

Round 0 (requirements & deletion) found four things, all verified before acting.

1. The exported denylist was NOT immutable and the docblock said it was.
   `Object.freeze(new Set([...]))` is inert: Set.add/delete write internal
   slots, not properties. Measured -- `.delete('xGuardModeration')` SUCCEEDED
   on the frozen Set and the guard silently stopped denying it. A false safety
   claim is worse than none, because it stops the next reader looking. Now a
   frozen ARRAY (which genuinely rejects push/splice) plus a module-private
   lookup Set that is never exported, and a test that pins it by attempting
   the mutation. The same trap is documented one container over on
   STEP_TYPE_ACCEPTABLE_POSTURES -- this module had repeated the mistake it
   warns about.

2. ageClassification / mediaRating / wdTagging RETRACTED to ALLOW, 15 -> 12.
   The moderation-oracle argument was mine, was flagged reversible, and did
   not survive attack: it buys loop SPEED not capability denial (the same
   grading signal returns from the publish boundary anyway); it denied the
   good-citizen case, sharply so for ageClassification, where it stopped an
   app checking whether an image depicts a minor BEFORE touching it; and
   wdTagging is a WD14-family tagger, in the set only by association. This is
   also the reading closer to the operator's own class list. The retraction
   and its three reasons are recorded rather than deleted, and pinned by a
   test, so they are not re-derived.

3. Denying mediaHash silently falsified a plan eight lines up in the file the
   diff edits: "adding it later is one file plus one line here -- which is the
   entire point of the registry". Following it would now be a BOOT failure,
   not a test failure, since the registry fails fast at import. The two
   judgements genuinely conflict; that note now records which one is newer and
   that reopening the decision comes first.

4. The unused PLATFORM_INTERNAL_STEP_TYPE_LIST export is gone. Zero
   production consumers -- its only test asserted that an export existing for
   the test was sorted.

Also fixes a test comment that said "clause (9a)" where the code says (0a) --
mildly worse than a typo here, because clause (9) is exactly where the guard
was dead.

12 denied / 38 allowed, all 12 re-verified present in the live spec.
194 files / 4928 tests green; typecheck 0 errors.

Still open and NOT addressed here: the consent copy promises "training
models", which no wire arm and no implemented billing mode can deliver
(isBillingModeImplemented accepts 'prepaidFixed' only). That is a requirement
question for the operator, not a code fix.

* fix(blocks): the consent copy names only what is REACHABLE — drop the training promise

Round 0 finding F1, operator-resolved: do not promise a capability that does
not exist.

"training models" is gone from the sentence. Training is ALLOWED by the
denylist, but it is not reachable: no wire arm accepts it, and
isBillingModeImplemented accepts 'prepaidFixed' ONLY, so a variable-cost
training step cannot even be registered. The registry's own Tranche-1 note
rejected imageGen and imageUpscaler for exactly that reason.

Why it mattered rather than being a wording nit: the re-consent SQL is about
to revoke live grants so users re-agree. Re-consenting to a capability that
has not shipped BANKS the permission -- and when the wide wire does land,
nothing re-prompts, because consent is stored per (user, app) and no lookup
reads the version column it stamps. app_user_scope_grants exists to stop
exactly that; its migration header names it "silent scope escalation". The
fix preserves the re-prompt for the widening that actually needs it.

"running language models" stays: chatCompletion is registered and live, so
that half is a real widening beyond "generations" and is the gap this
re-consent legitimately closes.

Adds a NEGATIVE guard asserting the copy does not promise training, with the
reason in the test, so it is not pre-loaded back in by someone who reads the
denylist and assumes reachability follows. The SQL header now records the
same, including that a future capability means changing the sentence AND
re-taking the grants again -- the intended cost, not an oversight.

194 files / 4929 tests green.

* fix(blocks): round-1 audit — drop the unreachable "video" promise, pin the whole denylist, correct the SQL

Round 1 (nine axes) confirmed the guard is reachable and correctly placed
(deleting clause (0a) AND moving it below clause (1b) were both killed by the
seam test), and confirmed the consent mechanism claim. It then found five real
things. All verified before acting.

1. [BLOCKING] The copy promised "and video", which is NOT reachable -- the
   identical defect the previous commit removed "training" for, one word over.
   blockWorkflowBodySchema has three members; textToImage is bounded to
   BLOCK_IMAGE_WORKFLOW_TYPES (txt2img/img2img/img2img:edit), both registered
   recipes are image, both registered steps are convertImage/chatCompletion,
   and workflow.schema.ts calls a non-image media class "a later phase".
   Dropped.

   The guard written to prevent exactly this was NARROWER THAN ITS OWN NAME:
   titled "does NOT promise a capability that is not reachable yet" while
   asserting a single literal, 'training'. A description claiming a CLASS over
   a body checking one member -- and the gap was already occupied. It is now
   named for what it does, checks the capabilities we have twice caught
   ourselves pre-promising, and says in the file that it is a SPELLED guard
   that a reword walks past. The real control is the whole-string pin plus a
   human enumerating the wire arms, and that is now written down as the rule:
   reach for the WIRE, not the denylist -- a $type being allowed says nothing
   about whether any arm accepts it.

2. The denylist set was 3-of-12 mutation-covered. Measured: deleting
   modelClamScan, imageScanning, shieldstralModeration, mediaHash,
   modelParseMetadata, comfyNodepackSnapshot, qwenImageBench, webScrape or
   webSearch left the suite FULLY GREEN. Nothing else in the repo pinned
   membership. Now pinned as the whole sorted set -- re-verified by deleting
   webScrape, which previously survived and is now killed.

3. The SQL's central claim was wrong in the LOOSENING direction. It said a
   revoked row "reaches no spend path" and the read path is "fail-closed
   today". Neither holds for already-minted tokens: block JWTs have no
   revocation list and no jti, default TTL 900s (300s settings, 4h dev). Worse,
   the revoke removes the user's OWN CAP FIRST -- getConsentBuzzBudget returns
   null for a revoked row and reserveBlockBuzzSpendForClaims then takes the
   no-consent-reservation branch, falling back to the platform ceiling alone.
   A user with a 500/day app budget has it lifted for the life of their token.
   Header now states the window and the inversion, and says to run it when
   spend is quiet.

4. The SQL's verification gate told the operator to compare a column named
   `revoked_now` that the query never emitted, against a total that conflated
   this run with prior ones. Now emits revoked_by_this_run and
   revoked_before_this_run separately, keyed on now() -- which is
   transaction_timestamp() and therefore the same value STEP 2 wrote
   (statement_timestamp() would NOT be, and would have reported zero).
   The bare COMMIT is also gone: under `psql -f` every statement ran and it
   committed before a human could read the gate, making the gate decorative.
   It is now commented out with instructions to step through interactively.

5. Documented that the row-level revoke drops co-granted scopes
   (user:read:self and friends) until re-consent, since that will generate
   support reports that look unrelated to Buzz.

Also: stale clause label in a test comment, and two docblock claims that
contradicted the file they sit in (one still said the scope reaches "model
training"; one asserted a live grant count the SQL explicitly says not to take
from a document).

194 files / 4930 tests green; typecheck 0 errors.

NOT addressed, deliberately: round 1's finding that no ratchet forces a future
kind:'steps' arm to call assertStepTypeAllowed. A ratchet for a call site that
does not exist yet would be a declaration with zero instances; it has to land
with the arm. Recorded in the PR instead.

* fix(blocks): round-2 audit — a revocation primitive DOES exist, the runbook omitted BEGIN, and the source docblock still asserted the refuted claim

Round 2 was a delta re-audit of the round-1 fixes. It confirmed 3 of 8 claims
outright and found that 4 were only partially discharged. All verified before
acting. Every finding this round is in PROSE the previous round wrote -- which
is the documented shape of a fix round's next defect, and it held.

1. "Block tokens are JWTs with NO revocation list" -- FALSE, and I wrote it.
   BlockRevocation (block-revocation.service.ts) sets a per-blockInstanceId
   Redis marker with TTL = max token lifetime, and it IS checked on the
   block-scope path: block-scope.middleware.ts, block-bridge-auth.service.ts,
   apps.router.ts, apps-shared.router.ts. Claiming otherwise told the operator
   not to look for the mitigation that exists. Corrected, with the three
   caveats that decide whether it helps: it is per-INSTANCE not per-user, this
   SQL does NOT trigger it (a Postgres write sets no Redis marker), and
   isRevoked FAILS OPEN on a Redis error.

2. The interactive recipe never said BEGIN. The now() reasoning added last
   round is correct Postgres semantics but is entirely conditional on a
   transaction the recipe omitted. Followed literally under autocommit, STEP 2
   self-commits, the ROLLBACK escape disappears, and STEP 3 evaluates now() in
   a DIFFERENT transaction -- so revoked_by_this_run reads 0 and
   revoked_before_this_run reads N: the exact inverse of the documented pass
   condition, on a run that succeeded. BEGIN is now step zero and shown in the
   sequence.

3. The -f warning described a hazard the same commit had removed: it warned
   about a COMMIT that is now commented out. What -f actually does now is leave
   the transaction open to EOF and roll back at disconnect -- AFTER STEP 3 has
   printed a convincing success readout. Safe direction, useless signal. The
   warning now says that instead.

4. The video question is elevated from a comment to a BLOCKING precondition at
   the top of the SQL. customComfy mode:'inline' is the one arm bounded by no
   enum, and the read path does not filter by media type -- workflow.service.ts
   pushes every available output.blobs[].url into imageUrls with no check. So
   if a stock-node graph can emit video, the copy UNDER-names a reachable
   capability, which is the worse direction for consent. Answer before running,
   not after; re-taking grants is the expensive half.

5. scope-grant.service.ts still asserted, in the docblock a code reader hits
   first, exactly what the SQL header now calls false: "a revoked user's token
   ... can reach no spend path at all". Two in-tree documents disagreeing about
   the one fact the operator needs. Rewritten, including the counter-intuitive
   ordering -- the revoke drops the user's own ceiling BEFORE it drops the
   scope -- and the "currently unreachable" framing is corrected, since this
   PR ships the hand-applied writer that makes the branch reachable.

194 files / 4930 tests green; typecheck 0 errors.

* fix(blocks): round-3 audit — BlockRevocation is AUTOMATIC not operator-invoked, and the mitigation I told the operator to use is not followable

Round 3 found three things, all of them in prose, two of them introduced by
round 2's own fix. Verified before acting; the shipped behaviour is unchanged.

1. "operator-invoked" is WRONG. `revokeInstance` has exactly two production
   call sites and both are AUTOMATIC -- uninstall (block-registry.service.ts
   :2358) and toggleEnabled(false) (:2393). There is no admin router, no tRPC
   procedure, no script; confirmed by enumerating every non-test reference.
   The consequence is the opposite of what the word implies: the middleware's
   403 branch is HOT, not cold, because a marker appears whenever a USER acts.
   Round 2 set out to stop two in-tree documents disagreeing about this and
   made it three.

2. The instruction round 2 added -- "revoke the affected instances as a
   SEPARATE action" -- CANNOT BE FOLLOWED, which is worse in a runbook than
   the omission it replaced. There is no operator surface; the only manual
   routes are toggling every install off (which also DISABLES it, a different
   and user-visible outcome) or hand-writing the Redis key. And the file gives
   no way to enumerate the instances anyway: it works on
   app_user_scope_grants, keyed (user_id, app_block_id), while
   block_instance_id lives on block_user_subscriptions. The header now says
   what is actually available -- time the window, do not try to close it --
   and says why, rather than sketching an untested join.

3. The (0a) placement note cited "clause 7-ish" as the guard that pre-empted
   it. The clause that rejects a posture/$type mismatch is (1b) at :1667;
   clause (7) is the resource-policy gate and is unrelated. The mechanism the
   note describes is correct -- only the label was wrong -- but a reader
   checking (7), finding nothing that shadows (0a), would conclude the
   placement note was bogus and move the guard back down. Now cited by label
   and line.

Also: the test docblock said assertStepInvariants has "a dozen" clauses; it
has 20 labelled ones.

Round 3's once-per-ladder re-derivation against the current head checked every
count, version, path and cross-reference this PR asserts -- denylist size and
membership, REGISTERED_STEP_IDS, the three schema members, both recipes, the
BLOCK_IMAGE_WORKFLOW_TYPES bound, isBillingModeImplemented, the 900/300/14400
token lifetimes, the single non-test call site of assertStepTypeAllowed, and
every backticked file reference in the six touched files. All correct except
the clause label above.

194 files / 4930 tests green; typecheck 0 errors.

NOT fixed, deliberately out of scope: packages/civitai-redis/src/client.ts:2324
and block-registry.service.ts:2387 both describe the revocation marker TTL as
15 minutes; it is MAX_BLOCK_TOKEN_LIFETIME_SECONDS = 14400s (4h). Real, but in
files this PR does not otherwise touch -- widening the diff mid-ladder is how
ladders stop terminating. Flagged on the PR instead.

* fix(blocks): round-4 audit — "no tRPC procedure" was false, and the same commit contradicted itself

Round 4 found one thing. It is the THIRD wrong claim in a row about the same
sentence, which is why the fix states a narrower property instead of reaching
for another confident one.

The sequence, kept so nobody derives a fourth:
  draft 1: "BlockRevocation is operator-invoked"                    -- FALSE
  draft 2: "there is no admin router, no tRPC procedure, no script" -- ALSO
           FALSE, on the middle term
  now:     "no call site exists whose PURPOSE is revocation"

Both call sites ARE reachable over tRPC -- blocks.router.ts:1848
(uninstallFromModel) and :1810 (toggleEnabled), both protectedProcedure -- and
assertCanManageBlocks early-returns for moderators (:1521). So a mod CAN set a
marker deliberately, against any user's install on any model. Verified by
enumerating every reference and then the callers of both methods.

Worse than an isolated error: the SAME COMMIT contradicted itself. The SQL
header said one available by-hand path is "toggling every affected install OFF
in the UI" -- which IS a tRPC procedure -- while scope-grant.service.ts said no
tRPC procedure existed. Its "only ways" enumeration also omitted
uninstallFromModel, which is mod-reachable for any model and writes the same
marker.

The property that actually holds, and the one an operator needs: you cannot set
a marker WITHOUT also uninstalling or disabling that install. Every route
carries a separate, user-visible outcome -- which is why none of them is a
quiet token revocation, and why the runbook's advice is unchanged: time the
window, do not try to close it.

Also corrects the inherited consequence. "A marker appears because a USER
acted, not because someone chose to write one" was drawn from the false claim
and is gone; the 403 branch is hot either way, because ordinary users hit both
paths routinely.

194 files / 4930 tests green; typecheck 0 errors. Delta is comment-only.

Still flagged, still deliberately unfixed: the two out-of-PR comments putting
the revocation TTL at 15 minutes (it is 14400s). Correct cite is
block-registry.service.ts:2388 -- my previous message said :2387, off by one.

* fix(blocks): round-5 audit — DELETE the "403 branch is HOT" claim instead of writing a fourth reason for it

Round 5 caught the shape this ladder keeps producing, in the commit that was
supposed to have stopped producing it.

Round 4 correctly deleted the REASON draft 2 gave for "the middleware's 403
branch is HOT" -- and then kept the CONCLUSION and supplied a fresh
justification: "because ordinary users hit both paths routinely". That is
draft 4 of one sentence, and it is false: both mutations carry
enforceAppBlocksFlag (blocks.router.ts:1811, :1849), and the live
app-blocks-enabled flag is base-false with a moderators-only segment
(:398, :442; app-blocks-flag.ts:207). An ordinary user cannot reach either
mutation at all.

So the fix is a DELETION. The comment now states that the branch's exercise
rate is NOT ESTABLISHED, records both dead justifications so neither is
re-derived, and says explicitly not to write a third -- the rate depends on
live Flipt state and install behaviour, neither readable from source. If you
need the number, measure it.

This is the rule the previous four rounds kept not applying: when a claim has
lost its reason, write that it has none. A justification found under pressure
to supply one is a hypothesis, not a finding.

What round 5 CONFIRMED, by complete enumeration rather than reading: draft 3
("no call site exists whose PURPOSE is revocation") is TRUE -- exactly two
writers, both side-effects, no admin script, job, webhook, queue consumer or
cascade; suspension uses a different mechanism entirely. "You cannot set a
marker without also uninstalling or disabling that install" is TRUE, and both
re-entry paths clear the marker so a disable/re-enable round trip leaves no
residual. All five cited line numbers exact. The two files now agree -- the
round-4 self-contradiction is gone.

Also fixes a counter this ladder introduced: "Four things decide whether it
helps you" headed FIVE bullets, because round 4 split two bullets into three
and left the total. Same class as the round-4 defect (a statement inconsistent
with its own commit's content), so the total is removed rather than corrected
-- a number kept beside what it counts will drift again. The dropped draft-2
wording now also carries an explicit retraction marker, matching the file's
three other retractions.

194 files / 4930 tests green; typecheck 0 errors. Delta is comment-only.

* style(blocks): prettier the two files the changed-files gate flagged

The ESLint + Prettier check was RED on the PR head: the new consent-copy test
file was unformatted, and scope-descriptions.constants.ts was too (only the
added-files arm reported, so the modified file would have slipped through).

Mine, and a gap in my own verification -- I ran the unit suites and typecheck
after every audit round and never once ran lint or prettier. Tests and
typecheck cannot see formatting, so a green local run said nothing about this
gate.

Formatting only. Verified the pinned consent STRING is byte-identical after
prettier, since a test asserts it exactly; eslint clean on the whole diff;
194 files / 4930 tests green; typecheck 0 errors.
2026-09-16 14:47:17 -05:00
Justin Maier 32239d2d4d feat(licensing): turn the SellMerge write paths on (#4874)
* feat(licensing): turn the SellMerge write paths on

The contract half of yesterday's expand/contract. Every pod has known the label
for eleven hours and prod carries zero rows using it, so the paths that were
held back can open: the schema @default and the upload form's default set go
back to five values, the "Sell merges using this model" option returns, and the
refine that refused the member at the upsert contract is deleted.

Measured before writing this, prod replica: 424,641 models hold Sell and lack
SellMerge, and 0 hold SellMerge. Nothing wrote it while the paths were shut.

The two decision cases are INVERTED rather than deleted, because what they guard
was never really about SellMerge. Restrictions are emitted by ABSENCE, so a
member missing from a default set produces no clause and therefore GRANTS the
permission -- silently, with no badge a creator would notice and no type error.
Both now derive the expected set from the enum (Object.values minus None) rather
than from a hand-written list, so the next member added without wiring reddens.
A list would have needed updating by whoever forgot.

Both cases are renamed to what they now assert; a stale test name is a comment
that survives every refactor. The decision docblock is deleted rather than
reworded, since the decision expires with this PR.

Controls: dropping SellMerge from the schema default, from the form default, or
from the option list each redden, as does re-adding a withholding refine, as
does the field ceasing to validate members at all -- that last one is what stops
the accept-loop passing vacuously. An innocent comment reword stays green.

The backfill does NOT ride this PR. It runs after this is deployed, because
until then the refine rejects a save carrying the member and the edit form
resubmits it untouched.

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

* fix(licensing): assert the three sites AGREE, not that every member is granted

Five lanes on #4874; two findings were against my own work.

The docblock stated the absence rule backwards -- it claimed a member missing from
the granted array produces no clause and so GRANTS the permission. The emitter
returns '' when the array INCLUDES the member, so absence emits the clause and
RESTRICTS. The real hazard is one file over and about the clause map, not the
default set. The parent's commit message repeated the inversion.

The guard asserted a policy: both defaults equal to every enum member requires the
next CommercialUse addition to be granted to every model by default, which forbids
the expand/contract manoeuvre shipped eleven hours ago. Rewritten as the invariant
-- the two defaults and the option list must AGREE -- so withholding a new member
everywhere at once stays legal, while a member granted by default and missing from
the options is caught.

Three attempts, each caught by a control: a whole-file count broke because Rent is
a substring of RentCivit and Sell of SellMerge; a per-member count misfired on the
cascade's legitimate references.

The render-site guard pins that the list is mapped whole. It does NOT cover the
disabled predicate -- no text guard expresses that without naming members, which
reintroduces the policy. Stated in the test as a gap.

From the other lanes: the local-dev DDL was missing the enum value, so a fresh
checkout would reject the first model create; the migration header and the backfill
endpoint carried claims this PR made false.

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

* chore(seed): seed the five-value permission shape alongside the legacy ones

No seeded row granted SellMerge, so the sell/merge split was unexercisable in
local data. Tracked despite scripts/local-dev being gitignored -- the ignore
rule does not apply to already-tracked files.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:10:43 -06:00
Zachary Lowden 4aab099c91 fix(app-blocks): a no-user flag eval returns the flag's BASE value, not a deny (#4807)
* fix(app-blocks): a no-user flag eval returns the flag's BASE value, not a deny

Several docblocks in app-blocks-flag.ts derived a security property from an
inference that does not hold:

    no user -> a global eval that can never match a segment -> fail-closed

The premise is true. A no-user call reaches Flipt as entityId 'global' with an
empty context, and every identity/tier/cohort segment we have is a
STRING_COMPARISON_TYPE constraint that reads the context, so none can match. The
conclusion does not follow from it: when no rollout matches, Flipt answers with
the flag's own base 'enabled' value. The denial came from the BASE being false,
not from the segment miss — so a base-true widening of app-blocks-author or
app-blocks-enabled turns every no-user branch from a deny into a pass.

MEASURED against the real @flipt-io/flipt-client-js wasm engine over a real
evaluation snapshot (new test app-blocks-flag.base-enabled-flip.test.ts):

  base enabled:true  + a non-matching SEGMENT_ROLLOUT, no entityId/context -> true
  base enabled:false + the same rollout,                no entityId/context -> false
  unknown flag key,                                     no entityId/context -> false

Both flags are base-false with segment rollouts today (civitai/flipt-state,
civitai-app/default/features.yaml), so nothing is exposed now. This is the latent
gate closed before any base-true flip, not a live defect.

Code:
- isAppBlocksAuthorEnabled: an undefined user now returns false structurally
  instead of falling through to a global eval. Enumerated: all 10 call sites pass
  { user }; none wants a global eval of this key.
- blocks.router assertAppBlocksEnabledForTokenUser / assertViewerIsAppDeveloper:
  refuse an unhydratable token subject before consulting the flag, with a distinct
  message. Same shape apps.router.ts already uses.
- apps-shared.router resolveSharedContext: separate a VANISHED subject (refuse)
  from an ANON token (keep the global eval — that widening is intended). The
  read ops list/get had no second belt behind the flag.
- isAppBlocksEnabled's no-user branch is deliberately KEPT: it has a real caller
  (pages/api/v1/developer/block-manifests.ts) that wants the base value.

Comments: every fail-closed / fail-safe claim in app-blocks-flag.ts swept and
given an accurate statement of what makes it closed; a new GLOBAL-EVAL SEMANTICS
block records the mechanism and the measurement once.

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

* refactor(app-blocks): make the compiler the guard — require a subject on the author capability

Round-1 review: the structural guard I said needed type-level nullability analysis
is analysis this repo already runs on every PR (tekton / typecheck, App unit tests
+ typecheck). Measured, then implemented.

isAppBlocksAuthorEnabled's parameter is now REQUIRED and non-nullable:
  (opts?: { user?: SessionUser })  ->  (opts: { user: SessionUser })

Making it required errored at exactly 2 of the 10 call sites — both bare
middleware(...) whose ctx.user is not narrowed by the protectedProcedure they are
attached to (app-listings.router.ts, app-collaborators.router.ts). Each now refuses
explicitly instead of handing a possibly-undefined subject to an authz gate. The
other 8 already held a non-null subject. The runtime `if (!user) return false`
branch this makes dead is DELETED rather than kept as defence in depth, and the two
runtime tests that pinned it are deleted with it: they could only be re-added behind
an `as never` cast, i.e. testing a path the type system forbids while reading as
coverage. What replaces them is the typecheck gate, plus one test pinning the
residual the docblock now states — a cast-defeated call THROWS, never returns true.

isAppBlocksEnabled keeps its optional overload. That asymmetry is now justified on
SEMANTICS (a kill-switch answers "is the feature on at all", which a subject-less
machine path may legitimately ask and which the base value is; a capability answers
"may THIS subject", unanswerable without one) rather than on its sole no-arg caller
existing — block-manifests.ts is dormant, so that justification is the half that can
vanish.

Also:
- Delete fixtures/flipt-base-enabled-flip.snapshot.json (152 lines). Derived at
  runtime from the sibling fixture by re-key + enabled flip. A checked-in twin cannot
  track the original — re-capturing the source means re-anonymising it, so the copy
  silently keeps the old segment shape while claiming production fidelity, and it had
  already lagged a segment.
- Consolidate ~60 lines of duplicated harness into fixtures/flipt-fixture-server.ts,
  shared with the pre-existing real-flipt-client integration suite. Only the snapshot
  differs between them; the instrument is the same.
- Stop enumerating live flag state in the GLOBAL-EVAL SEMANTICS header — that is the
  same rot class the paragraph warns about. Keep the imperative, point at flipt-state.
- apps-shared: say READ_OPS rather than naming two of its four members.
- Replace a rejects.not.toMatchObject(...) positive control, which passes on any
  other rejection, with the specific NOT_FOUND / 'Block install not found' outcome.

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

* fix(app-blocks): watchlist the two new refusals — the type guard cannot see a bundler

Round-1 review, 2 x yellow. Both taken.

WATCHLIST (the important one). My stated defence for deleting the helper's runtime
branch — "deleting it is a type error rather than a silent re-opening" — is a claim
about SOURCE. Release 5.1.18 is the case where the source is correct and the emitted
artefact is not: the bundler dropped two of three returns from a function in
app-blocks-flag.ts and served the whole App-store catalog to anonymous callers
(civitai#3983). That is why scripts/compiled-branch-watchlist.mjs exists, enforced at
Dockerfile:103, and two of the three refusals this PR adds are pure runtime branches
whose loss silently restores the exposure it closes. Both are now listed:

  shared-storage-subject-refusal  apps-shared.router.ts   — lost, every READ_OPS op
                                   serves shared rows to a vanished subject
  block-token-subject-refusal     blocks.router.ts        — lost, the kill-switch is
                                   evaluated with no subject on 16 runtime procs

assertViewerIsAppDeveloper's guard is deliberately NOT listed, and that is measured:
isAppBlocksAuthorEnabled takes a non-nullable subject and dereferences it at once, so
losing that guard throws rather than passes.

VERIFIED AGAINST A REAL BUILD, not just added. Full `next build` (green), then the
gate exactly as the Dockerfile runs it:
  positive   26,600 maps scanned; both new entries report OK with their control
             anchors mapped (an unmapped control is exit 2, not a pass)
  negative   required anchors repointed at unmapped comment lines in the same
             functions -> exit 1 naming both entry ids, controls still mapping
  deletion   the required line removed in a throwaway source copy, one entry at a
             time -> exit 2 naming that entry and its anchor

The two refusal messages were identical strings under different codes, so neither was
anchorable and "separable in a log" was not true. The author-gate message is now
'app-authoring subject could not be resolved'.

ROT (the second yellow). The isAppBlocksAgenticReviewEnabled docblock I wrote for this
rot class had the rot: it called the absent-flag half "load-bearing while this flag
does not exist in Flipt", but app-blocks-agentic-review has existed since 2026-07-21
(live: base false, `moderators` segment). No live exposure — all three call sites
check isModerator first — but it is a false claim in the one paragraph my own sweep
rewrote. Re-derived every app-blocks flag's live state rather than fixing only the
reported line, and found a second instance I had authored: the dev-tunnel docblock
offered "absent" as a live unconditional closure for a flag that also exists. Both
corrected; the only flag in this file genuinely absent from Flipt is
app-blocks-backpay-enabled.

Nits: assertAppBlocksEnabledForTokenUser has 16 call sites, not 17 (17 is the
parseSubjectUserId count; the substantive claim was right). deriveSnapshotFromFlagShape
now rejects an EMPTY rollouts array, which its error text already claimed to reject.

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

* docs(app-blocks): resolve the two docblocks that answer the reader twice, oppositely

Follow-on from the same re-check that found the agentic-review rot. Eleven docblocks
in this file carry an inherited "the flag does NOT exist in Flipt at merge time"
sentence; the new header frames all of them as as-merged history, which is the
deliberate treatment (substituting a fresher enumeration would be the same rot one
generation on).

But in exactly TWO docblocks that inherited sentence sits in the SAME block as a
live-state sentence I wrote, so the reader gets opposite answers eight lines apart:

  isAppListingsEnabled          "does NOT exist"  vs  "Both are base-`false` today"
  APP_BLOCKS_SHARED_STORAGE_FLAG "does NOT exist" vs  "Closed today because the base
                                                       is `false`"

Both inherited sentences moved to past tense and marked as as-merged notes. Nothing
else changed: the other eleven are untouched, because they carry no competing claim.

Criterion for the split, so it can be re-applied: fix where a paragraph contradicts
itself; leave where the header already governs.

Re-verified after the edit rather than carrying the previous measurement over — these
are comments, but they shift line numbers in a WATCHLISTED module, and source/artefact
correspondence is exactly what this PR stopped assuming. Fresh `next build` (green),
then the compiled-branch gate: positive OK on all three entries (26,600 maps; controls
mapped), negative control still exit 1 naming both new entries.

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

* docs(app-blocks): say at each watchlisted refusal that it is watchlisted

The compiled-branch gate names the entry, module, line and reason when it fires — but
only at Docker build time, after someone has already deleted the branch and pushed. The
two refusals this PR puts on the watchlist carried no signal in the file itself, so a
reader deciding whether the guard is load-bearing had nothing to go on. Two comments,
one at each site, naming the entry id and what a bundler can do to a pure runtime
branch.

Deliberately scoped: moving or rewording the throw is fine (the gate resolves its
anchor from source at run time), deleting it is not.

Re-verified after the edit, for the same reason as the previous commit: comments shift
line numbers in watchlisted modules. Fresh `next build` (green), gate positive OK on
all three entries, negative control still exit 1 naming both new entries. That is the
third independent repetition of both controls.

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

* fix(app-blocks): re-anchor the watchlist on format-robust text, and stop telling people to reword it

Round 2. Five defects, all introduced by this PR's own later commits.

F1 (the important one). The comment I added in 1f3fd96 said "Moving or rewording the
throw below is fine". Moving is fine; rewording is NOT, and neither is reformatting —
the anchor is an exact substring, a miss is die(2), and exit 2 is deliberately not
downgraded by --warn-only, so the first red is the production image build. Worse, both
anchors baked in the FORMATTING (a trailing `});` and a trailing `,`), and the
apps-shared line was 95 chars at printWidth 100 — so adding a `cause` or a metrics
counter, which apps.router.ts already has, reflows it and reds the build.

Fixed at the anchor, not just in the prose:
  shared-storage  required -> `if (userId != null && !subjectUser) {`  (the CONDITION)
                  control  -> `isAppBlocksSharedStorageEnabled(`       (stops at the paren)
  block-token     required -> `'runtime block token subject could not be resolved'`
                  control  -> `await isAppBlocksEnabled({ user })`

Measured, resolution phase isolated (it runs before any map is read):
  OLD anchors + old source                      resolution OK    (control)
  OLD anchors + the apps-shared reflow          RESOLUTION FAILED
  NEW anchors + the same apps-shared reflow     resolution OK
  NEW anchors + apps-shared message reword      resolution OK  (anchor is the condition)
  NEW anchors + blocks.router property-add      resolution OK
  NEW anchors + blocks.router message reword    RESOLUTION FAILED  <- inherent, documented
  NEW anchors + unmodified copy                 resolution OK  (control)

The last row cannot be fixed by choosing better text: `if (!user) {` is not unique in
blocks.router.ts (the author gate uses the same condition), so that entry must anchor a
literal. It is sound only because the literal is now unique APP-WIDE — nothing can
intern it from elsewhere — and that constraint is written into the entry, the site
comment and a new anchor-authoring rule in the watchlist header.

F5, which is the same edit. The kill-switch refusal was byte-identical in code AND
message to apps.router.ts:148, so an operator could not tell which gate refused.
Renamed to 'runtime block token subject could not be resolved' — the message is new in
this PR, so no existing consumer sees the change — which clears the collision and
supplies the unique literal F1 needs.

F2. 220fdd8e0 created a new "answers the reader twice, oppositely" by its own criterion:
the dev-tunnel docblock lost the absent-flag case three lines above an untouched
sentence that relies on it. That sentence is a live-state claim, so the file header does
not govern it. Made state-independent, matching the treatment its sibling edit in the
same commit already used.

F3. The nit fix swapped one wrong count for another. There are 16 parseSubjectUserId
sites, not 17: the site at :5674 guards BOTH gates, so the two sets overlap and must not
be added. (19 raw occurrences = 16 calls + the import + two comments.)

F4. The fixture guard checked only that some rollout exists while its message and the
consuming test's title both claim a SEGMENT rollout — so a re-capture with a threshold
rollout would pass, and a 100% threshold would sail past the base-false backstop too.
Now checks SEGMENT_ROLLOUT_TYPE, and a new test drives all three refusal arms plus a
positive control, so the guard is reachable rather than merely present.

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

* fix(app-blocks): round 3 — stop writing the count down at all, and widen the fixture guard to its own docstring

Round 3 returned no 🔴, two 🟡 and two 🟢. This fixes both 🟡 and the 🟢 that is a
false claim in a doc governing how anchors are authored. Each was re-measured here
before being accepted; the auditor was right on all three.

🟡-1. The previous commit's "fix by FORM" was self-falsifying. It replaced a wrong
total with `grep -c 'await assertAppBlocksEnabledForTokenUser'` -> 16, and that line
CONTAINS the pattern, so it matches itself:

    31d768fba2 (base)   16
    5ad903c5c1          16
    origin/main         16
    7f029a6947 (merge)  17   <- the comment counted itself

Run without a path constraint it is 18, because apps.router.ts:139 declares a
DIFFERENT function of the same name. So a maintainer reading "16 such parse sites"
and running the very next line would get 17, conclude the doc was stale by one, and
reinstate the off-by-one that round 1 introduced and round 2 wrote the paragraph to
kill — the fourth consecutive wrong count in one parenthetical.

No number and no command now. The paragraph says why both are unsafe here and tells
the reader to enumerate. It also records that it has been wrong four rounds running,
each time by writing a figure down, so the next editor knows the cost before reaching
for a fifth.

Note for the record: the merge commit's own justification for that rewrite was wrong
in two specifics — it named `assertAppBlocksEnabledForTokenUser` where the count is
about `parseSubjectUserId`, and said the "import" did not exist when it does (line
15). Not force-pushed; the correction lives here beside it.

🟡-2. `flipt-fixture-server.ts` checked `.some(r => r.type === 'SEGMENT_ROLLOUT_TYPE')`
while its own comment names the hazard as a THRESHOLD rollout. `.some()` does not
cover a threshold rollout captured ALONGSIDE the segment one: the segment rollout is
still present, the guard passes, and `base-false-control` then evaluates true for
every subject — the exact attribution failure the fixture exists to prevent, restored
by a routine snapshot re-capture with a green suite. A guard whose description was
wider than its implementation.

Now requires a segment rollout AND no threshold rollout, with DISTINCT text per arm so
neither can die to the other's error. The test grew the arm that was uncovered, and
each arm now carries its own expected message rather than sharing one regex.

🔴 The first cut of this fix was itself wrong and an existing test caught it: keying
the message on `hasThreshold` alone made a THRESHOLD-ONLY template announce a rollout
"alongside its segment rollout" that was not there. `hasSegment &&` is load-bearing;
the comment says so.

Mutation-tested, each dying for its OWN reason on a DIFFERENT arm:
  M1  drop the threshold arm (pre-fix behaviour)  -> red: "expected [Function] to throw an error"
  M2  key the message on hasThreshold alone       -> red: message mismatch on the threshold-only arm
M1 is red at the pre-change code, so the new arm is regression coverage rather than an
invariant guard.

🟢-3. `compiled-branch-watchlist.mjs` asserted "comment churn above it cannot break
it", full stop. False: `resolveAnchor` scans every line including comments and >1
match is a hard error, so a comment merely CONTAINING the anchor text breaks
resolution exactly as a reword does — and rule 1's "prefer the shortest fragment"
makes a collision likelier, not less. This sentence had already misled a reader into
treating a docblock edit in a watchlisted module as free. Corrected, and it now says
the first red is this gate's own unit suite rather than the image build, which is the
only reason it is not deploy-blocking.

NOT fixed, deliberately: 🟢-4, that F5's new message is a strict SUPERSET of the
sibling it was renamed to be separable from, so a Loki grep for the shorter string
still returns both. The fix is another reword of a WATCHLISTED anchor plus its two
consumers, which is not a change to make at the tail of a ladder to close a 🟢.
Recorded as a ranked item instead.

Verified: 5 suites / 163 tests green (assert-compiled-branches, app-blocks-flag,
app-blocks-flag.base-enabled-flip, blocks.router.flag-gate-hydrate,
apps-shared.router); `pnpm typecheck` 0 errors.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 23:12:42 -05:00
Zachary Lowden 61a6b068aa Fix lost output before notification on background task exit (#4442)
When the dev-server test queue's waiter functions (cmdTestWait in cli.mjs,
runQueued in test-unit-run.mjs) finish polling and call process.exit(),
Node terminates immediately without flushing pending stdout/stderr writes.
This means:

  - The exec/process tool's completion notification fires while the
    capture file is still 0 bytes
  - An agent checking the output sees an empty file and has no reason
    to doubt the 'green' notification it just received
  - Both the exit code and the log agree, but both are wrong — a red
    run reads as green

Fix: replace process.exit() with drainThenExit()/exitAfterDrain() in
every waiter exit path that has just printed output. The helper writes
an empty string to both streams and waits for the kernel-level flush
callback before exiting. A 500ms safety timer prevents hangs if a
stream is in error state.

Fixes civitai/civitai issue 868ktvqf9
- The exit code is still from exitCodeFor() — no rule change
- The output file is now non-empty when the notification fires
- Applied to all three exit paths in cmdTestWait + the terminal path
  in runQueued

Co-authored-by: DevPod Agent <agent@devpod.local>
2026-09-10 09:56:41 -05:00
briant c681b72c9d chore(clickhouse): drop the one-shot user_activity_rollup apply script
The backfill it existed for ran on 2026-09-04 and the table is now kept current
by the user-activity-rollup cron, so this is a tool with no remaining caller.

Nothing is lost by deleting it. The statements themselves live in the tracked
migration (src/server/clickhouse/migrations/2026-09-04-user-activity-rollup.sql)
along with the measured actuals from the run, and the script is recoverable from
a78e58362e if the table ever has to be rebuilt from scratch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 16:32:06 -06:00
briant a78e58362e chore(clickhouse): apply script for user_activity_rollup, and the real backfill numbers
The migration is 30 statements against a cloud console that drops connections,
which is how half a backfill happens without anyone noticing. The script runs
them in order with backoff retries on transport errors only — a syntax error
fails immediately rather than burning five attempts and burying the message —
and prints a resume command naming the partition it died on. Dry run by
default; --apply writes.

Applied to prod 2026-09-04, 246s, no retries needed. Replaced the predicted
"~7.4M distinct users" with the actual 10,719,260 in both the migration and the
script's threshold: the estimate only counted pageViews and hand-waved the rest,
and the other three sources turned out to add 3.36M accounts that have loaded no
page since 2024-09-26.

The split is also the arm-by-arm correctness check, so it is written down:
10,719,260 total less 3,361,661 with no country leaves 7,357,599, against the
7,356,496 distinct users pageViews held a few hours earlier — and only pageViews
can set a country.

Verified end to end after applying: the page's own query returns in 0.56s for a
real 15,000-follower set, and 86.2% of those followers have a country, matching
the figure measured independently against pageViews before the table existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 16:27:34 -06:00
Justin Maier 8085d81651 chore(articles): remove the official-articles backfill script
It was a one-off and it has run. Justin's call: not worth keeping in the repo,
and not worth a PR to remove.

What it did, recorded here because the code is going and the fact should not go
with it. Prod, 2026-09-04:

  --user-ids 12042163,1,3,43555,5418
  --exclude-ids 6222,6339,6454,6338,5344,28893 --apply

214 articles marked — Maxfield 65, JustMaier 58, CivitaiOfficial 51, Faeia 38,
theally 2. Read back afterwards rather than trusting the update count: 214 total,
0 marked outside those five accounts, all six excluded ids confirmed still false.

Excluded five abandoned drafts titled "test"/"rtert", and 28893 "Farewell,
Civitans!" — a departing staff member's goodbye rather than Civitai speaking.
CivBot's 438 automated posts were deliberately left out.

Anything marked from here on is marked by a moderator: the ⋯ menu on an article,
the toggle in the editor, or `article.mjs --official`. To reverse the backfill,
take the file back out of history:

  git show <this commit>^:scripts/backfill-official-articles.mjs > backfill.mjs
  node backfill.mjs --user-ids 12042163,1,3,43555,5418 --unmark --apply

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XHZuQDTCG159qcPrCkP7SF
2026-09-04 15:37:50 -06:00
Zachary Lowden 4ede37c9c5 ci: re-pin BASE_TESTS to 37,857 — the unit shard gate is red on main (#4621)
* ci: re-pin BASE_TESTS to 37,857 — the unit shard gate is red on main

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

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

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

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

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

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

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

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

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

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

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

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

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

* ci: retrigger preview checks (empty)
2026-09-04 16:18:02 -05:00
Justin Maier ac9117af6a feat(articles): official toggle in the editor, and an official filter on the feed (#4630)
* feat(articles): official toggle in the editor, and an official filter on the feed

Follows #4624, which added the column, the moderator-only mutation and the badge.
Justin asked for three more things: the mark reachable from the article editor
rather than only the ⋯ menu, the agent CLI able to set it, and a way to browse
official articles. The CLI half is in the civitai-user-skill repo; this is the app.

🔴 A non-moderator's `isOfficial` is DROPPED, not refused. That distinction is the
whole design and it is not defensive coding: the edit form seeds itself from the
article, so an owner editing an article a moderator had marked would send the flag
straight back. Refusing there locks the author out of their own article with a bare
UNAUTHORIZED and nothing explaining it — which is exactly the defect the review
found in the tag version of this feature (#4618). Dropping it means their save
succeeds and changes nothing about the mark, because `upsertArticle` leaves the
column alone when the field is undefined. Both halves are tested, and the
"still saves" half is the one a future tidy-up will break.

The editor toggle sits in the moderator-only panel beside Locked properties, and
the form omits the key entirely for everyone else rather than sending a value the
server would discard.

The feed filter is a `Civitai Official` chip in the filters dropdown, plus
`?isOfficial=true` so an official feed is a link you can send. **Only `true`
filters.** An explicit `false` is treated as absent, deliberately: nobody browses
FOR community articles, and a `false` that filtered would let a stale url quietly
hide every official article from someone's feed. That is asserted on the emitted
SQL rather than on the arguments — a test that only checked the service was CALLED
with the flag would pass for a service that ignored it, which is what "the filter
does nothing" looks like to a user.

Controls, every mutation red, pristine 8 passed (5 + 3):
  non-mod value passed through instead of dropped → 2 failed
  refuse instead of drop                          → 3 failed
  drop becomes `false` instead of undefined       → 2 failed
  WHERE clause removed                            → 1 failed
  WHERE clause applied unconditionally            → 2 failed
  `false` filters too                             → 1 failed

⚠️ One of those tests was written wrong first and is worth the warning it now
carries: the SQL helper read only the template strings, but `getArticles`
interpolates its WHERE clause as a VALUE, so it captured SQL that could never
contain the filter — and both negative assertions passed against it. Every
assertion in that file now carries a `FROM "Article" a` control so an empty
capture fails loudly instead of quietly.

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

* feat(articles): backfill script for the official mark

`isOfficial` starts false on every existing article, so the badge is invisible
until the back catalogue is marked. The rule is `constants.system.officialUserId`
(12042163) — the same id `resource-select.service.ts` already uses to mean
"official" — with `--user-id` to point it elsewhere.

🔴 Dry run by default. It writes nothing without `--apply`, and `--unmark`
reverses it. The column is a public provenance claim, so a backfill that marks
the wrong author is a false claim on somebody else's writing.

Two things it prints that nobody asks for and everybody wants afterwards: the
database it is actually connected to (`.env` here has been swapped between dev
and prod before), and the count of articles marked by SOMEONE ELSE that this rule
would not have set — so a hand-marked article cannot be silently overwritten by
the rule without you seeing it first.

The apply path re-reads the count afterwards rather than trusting `rowCount`, and
exits non-zero if the two disagree.

Exercised against dev: dry run listed 49, `--apply` updated 49, read-back
confirmed 51 (2 were marked by hand first). NOT run against prod.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:07:18 -06:00
briant 938d281e4c fix(tests): make five suites pass on Windows
All eleven failures were path and spawn portability bugs in the tests, not
defects in the code under test, so CI never saw them. Every fix is a no-op on
Linux, so no guard is weakened.

- credential-detection-superset.guard, appModeratorMessageForm.callSites:
  path.resolve() yields '\' on Windows while the ledgers are written with '/',
  so both compared two spellings of the same module. The credential guard was
  reporting bearer-token.ts as neither scanned nor declared.
- blocks/tools/registry: new URL().pathname is '/C:/...', which readFileSync
  resolved to 'C:\C:\...'. Pass the URL itself.
- assert-component-suite-ran: the gate names the missing file by its on-disk
  path, so the separator is the platform's; the assertion hard-coded a posix
  one.
- test-perf/trace-flush: node_modules/.bin/vitest is an extensionless shell
  script that spawnSync cannot execute, so the child produced no output and
  every assertion failed as an empty stdout rather than as anything about the
  tracer. Spawn node with vitest.mjs, as the other two spawn sites in that file
  already do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:17:19 -06:00
Zachary Lowden 6de64ee5a8 fix(tests): unzero the preview / component-tests tier, and make a zero-collected run say so (#4531)
* fix(tests): unzero the component tier — one mock factory was aborting the whole run

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* style(tests): format the gate test file

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

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

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

---------

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

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

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

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

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

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

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

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

Four audit findings, none deploy-blocking.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 21:10:32 -05:00
Justin Maier c930757e2c feat(cosmetic-phash): widen the lane to 256 bits, and make the badge tunable (#4454)
* feat(cosmetic-phash): widen the lane to 256 bits, and make the badge tunable

At 64 bits the near-match panel could not separate a copy from a coincidence.
Measured over the 1,719 hashable cosmetics, the two badges reported as
imitations of official artwork ranked 7th and 116th of their corpus against a
1st percentile of 18 — inside the noise. The orchestrator now offers
`perceptualDct256`, and at 256 bits the same two rank 1st and 5th. Both were
confirmed by eye, along with three pixel-identical re-uploads of official
badges that the submission-time sha256 cannot see, because official cosmetics
carry no `imageHash`.

The sweep drains the corpus on `pHashVersion`, so bumping the lane is the
backfill.

`COSMETIC_SIMILARITY_CLOSE_RATIO` is unchanged at 0.125, and that is a finding
rather than an omission: it selects the same five cross-creator pairs anywhere
between 8 and 40 of 256, so the value sits on a plateau rather than an edge.
Being a fraction of the width is what carried it across the upgrade.

Three things that were silent before:

- A lane bump that moved only two of the three fields left the third
  disagreeing, and nothing failed. Two of those three mutations passed the
  suite. `COSMETIC_PHASH_LANE` is now asserted to spell one lane in all three
  fields, which kills both.
- The near-identical decision moved out of the component. It is now decided
  server-side and carried on the match, because the threshold is operator-
  tunable at runtime and a client recomputing it from a bundled constant
  disagrees with the server until every tab reloads.
- The threshold reads from a `KeyValue` row so it can be moved without a
  deploy. Read per call rather than memoised — a TTL would delay the change by
  the length of the TTL, which is the whole point of the row. It degrades to
  the built-in default on a malformed or out-of-range value rather than
  throwing, because this gates a badge and a throw would take out the ranking
  underneath it. Out-of-range is rejected, not clamped: clamping 1.5 to 1 would
  badge every match as near-identical and read as a working threshold.

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

* fix(cosmetic-phash): close the gaps the five review lanes found

The knob shipped wired to nothing, and the tests said otherwise.

Two mutations survived the previous commit's suite: replacing the new `close`
expression with a constant, and dropping the KeyValue read in favour of the
built-in default. Both passed 36/36. `close` had no assertion at either layer,
and every `getSimilarCosmetics` test reset the KeyValue mock to undefined, so
the whole suite only ever exercised the fallback path. One test now drives the
ranking at the default ratio and again at an operator-set one, and both
mutations die.

`getCosmeticSimilarityCloseRatio` guarded the value it read but not the read.
An unguarded rejection escapes `getSimilarCosmetics` AFTER the ranking is
complete, and the card renders that as "this artwork was not compared against
anything" — false, and the exact confusion the card exists to remove. The read
is wrapped; the docblock now describes what the code does.

Three of the five malformed-value cases were passing on residue.
`loggingMock.logToAxiom` is reset once per FILE, so a later case was satisfied
by the first case's warning. With a per-test clear and `toHaveBeenCalledTimes`,
a mutant that returns the default silently kills five tests instead of two.

Dropped a test that asserted a ratio it claimed was "what the UI thresholds
on". It wasn't: both imitation pairs sit above the shipped 0.125, so it checked
nothing the neighbouring test didn't already pin exactly, and it hardcoded /256
so the next lane bump would have divided by the wrong denominator in silence.

Boundary corrected. `0` is now accepted — "only an exact match is
near-identical" is a coherent request, and rejecting it fell back to the LOOSER
built-in, i.e. more red badges than the operator asked for. `1` is rejected
instead, being the same degenerate outcome `1.5` was already refused for.

`normalizeCosmeticHashHex` now refuses a hash wider than the lane. `padStart`
returns an over-wide hash unchanged, so it would be stored at full width under
the current version — accepted as a comparison target, then excluded from every
candidate set by the length filter, reporting "nothing was close" forever on a
row that looks correctly hashed.

Adds `scripts/oneoffs/drain-cosmetic-phash-lane.ts`. The mitigation the PR
depends on — crossing the two-lane window in minutes rather than the sweep's
~2h15m — existed only as a scratch file. It loops the tested sweep rather than
issuing its own UPDATE, so the rules about what a correct row looks like stay
in one place.

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

* fix(cosmetic-phash): make a refused hash say so, and stop the drain script hiding a timeout

The previous commit added a silent failure while removing silent failures.
`normalizeCosmeticHashHex` now throws on an over-wide hash, and the sweep's
catch was a bare `} catch {` with no log — so a lane whose `hexLength` was left
behind would hash every row (billed), throw at store, count them all as
`failed`, stamp them, and retry once a day forever, with nothing anywhere
distinguishing that from dead artwork. The catch now names the row and the
reason. It logs fire-and-forget with its own `.catch`, because a logger that
throws inside a catch converts a diagnosable failure into a lost one.

The drain script asserted a cause it cannot know. `failed` counts rows the
sweep could not store this run, and three different things land in it
identically: dead CDN artwork, an orchestrator still working when the 30s wait
elapsed, and a hash the store refused. The old closing line called all of them
"permanently unhashable", which is a guess printed in the voice of a
measurement — and the operator's next move differs for each. It now says what
was counted and points at the per-row log.

Dropped the drain script to the cron's 200/5 from 500/10. `getPerceptualHash`
returns `undefined` for both a real failure and a workflow still running, and
the sweep stamps either for 24h. More concurrency means more timeouts, so the
script written to shorten the window a row spends outside the lane could have
EXTENDED it to a day for a slice of the corpus — then printed "done", because a
stamped row drops out of the predicate and the next batch comes back empty. The
speed was always in removing the 15-minute gap, not in working a tick harder.
`MAX_BATCHES` 40 -> 20 with the smaller batch, ~2x the corpus rather than 11x.

Corrected the lane docblock, which still described the pre-guard symptom: a
stale `hexLength` no longer reaches a comparison-time throw, it fails at the
store and writes nothing.

The new log path is asserted rather than assumed — deleting it turns the sweep
suite red, with the unmutated control green either side.

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

* docs(cosmetic-phash): say what the drain script's failure count can and cannot tell you

Comment and console output only; no logic.

The closing line has now asserted a cause twice and been wrong both times, in
opposite directions. First it called every failure "permanently unhashable".
Then, correcting that, it claimed the cause "is not recorded per row" and sent
the operator away — but two of the three paths to an undefined hash DO log, and
so does the store-refused throw. That version would have had an operator ignore
records that exist, including the one class whose correct action is neither
"nothing" nor "re-run" but "fix the lane".

The coverage is now spelt out rather than summarised, because summarising it is
what went wrong twice: a relative media url and any network error or abort log
as `perceptual-hash`; a refused width logs as `cosmetic-phash-sweep`; a workflow
that simply did not succeed logs nothing, and that silent case is both the 30s
timeout and dead artwork — the two most likely reasons a row is in the count.

Also drops "re-run tomorrow" for "re-run once 24h have elapsed". The stamp is a
24-hour window, not a calendar day, so a next-morning re-run of an overnight
drain finds every row still suppressed, breaks immediately on an empty batch and
prints a clean zero — indistinguishable from a completed drain, which is the
silent-failure shape this script keeps trying not to reproduce.

And it no longer calls a repeat failure dead artwork. A width that disagrees
with the lane fails identically on every run forever, an unset
NEXT_PUBLIC_IMAGE_LOCATION fails for every row, and timeouts are correlated
rather than independent draws — so "failed twice" does not imply "dead".

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:20:10 -06:00
Zachary Lowden c576281835 fix(test-perf): the module tracer never flushed under Vitest 4, so a traced run wrote nothing (#4441)
* fix(test-perf): the module tracer never flushed under Vitest 4, so a traced run wrote nothing

scripts/test-perf/trace-setup.ts flushed its counters only from process.on('exit') and a 15s
interval. Vitest's forks pool kills its workers rather than letting them exit, so the exit
handler never ran, and the README's own workflow - "trace one file at a time" - is a 5-10s run
that never reached the interval either.

The failure mode is the expensive kind: .test-perf/trace was never created at all, and
trace-report.mjs answered "no .test-perf/trace - run a traced suite first", which reads as
operator error rather than as a dead instrument. Since graph.mjs's model of what a worker loads
is validated against this tracer, the validation was unreproducible for as long as it was dead.

Flush from afterAll, which runs inside the worker before the pool can kill it. The exit handler
and the interval stay as backstops.

Verified on origin/main in a clean worktree: before the change the documented workflow creates no
trace directory; after it, trace-report.mjs prints a real report (1 worker snapshot, 45 distinct
modules).

The regression test spawns a real traced run and requires a snapshot naming the module the
fixture imported - asserting that this file contains the string "afterAll" would pass just as
happily with the hook registered somewhere it never fires. Both mutants die for their own
reason: reverting to the old exit-only flush fails on "expected 0 to be greater than 0", and a
flush that writes an empty snapshot fails on the module-name assertion. It runs in 1.7s.

TESTPERF_TRACE_DIR is new, so that test can redirect its snapshots instead of clobbering a trace
someone is reading.

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

* fix(test-perf): name the traced project apart, or its dep cache eats the unit suite's

Audit round 1 on this PR found the regression test was deploy-blocking, and reproducing it
independently was worse than the report: 16 of 53 files red, not 5 of 6.

Vitest keys a project's dep-optimizer cache on sha1(projectName) and Vite's config hash includes
the plugin names, so the traced project - which spread the unit project and kept its name while
adding the tracer plugin - resolved to the SAME node_modules/.vite/vitest/<hash>/deps_ssr as the
normal unit suite and hashed differently. Vite responds by deleting and re-bundling that
directory, while unrelated workers in the same shard are importing chunks out of it:
"Cannot find module '.../deps_ssr/prom-client.js'". assert-shard-ran.mjs would not have caught
it either - losing ~400 of ~5539 tests stays inside its band.

Naming the traced project unit-trace gives it its own cache dir. Same 53-file selection:
53 passed, 0 deps_ssr errors, two cache dirs on disk.

Also from the audit:
- trace-report.mjs now honours TESTPERF_TRACE_DIR, which trace-setup.ts already read. Exporting
  it used to reproduce this PR's own bug: snapshots on disk, report says "run a traced suite
  first".
- The trace dir is cleared at the start of each traced run. The report SUMS every snapshot it
  finds and snapshots outlive their run, so a second run silently reported roughly doubled
  numbers - a wrong number rather than a missing one.
- Snapshots are keyed by a per-worker id rather than bare pid. forks gives one process per file
  so pid was unique there, but threads puts every worker in one process (measured: 3 files gave
  3 snapshots on forks, 1 on threads), and per-file flushing would have several workers writing
  one path concurrently.
- The flush no longer swallows its error. A silently unwritten snapshot is the exact failure
  this PR exists to close.
- The child run's interval backstop is pushed to an hour, so afterAll is the only path that can
  satisfy the test. Otherwise a child that lives past 15s passes it through the timer with or
  without the fix.
- README: the traced invocation is --project unit-trace, and the paragraph claiming a multi-file
  traced run keeps only the last file's counters is no longer true.

Fixing the clear-at-start introduced its own regression, caught by re-running the battery: it
removed the caller-supplied directory rather than its snapshots, turning a clean assertion into
an ENOENT crash. It now unlinks *.json only.

Six mutants, six deaths, each on its own assertion: no afterAll -> expected 0 to be greater than
0; empty snapshot -> expected [] to include the fixture module; project renamed back to unit ->
startup error, no "1 passed"; clearing disabled -> expected 2 to be 1; bare pid -> filename shape.
The bare-pid case is killed by a filename-shape PROXY and is labelled as one in the test - the
real property needs a threads-pool child with several workers, which these tests do not build.

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

* fix(test-perf): close the rename's own coverage hole, and stop the fix round's new sharp edges

Delta re-audit of the previous round. Its verdict was "safe to merge"; its findings were not
empty, and the findings are what decide.

The gap that mattered: every assertion named unit-trace, so renaming the project back to unit in
BOTH the config and the test passed green while fully restoring the cache collision that took 16
of 53 files red. The previous tip was literally that configuration and shipped green. There is
now a case that runs the traced config with --project unit and requires it to FAIL with "No
projects matched", so the two spellings can no longer be reverted together in silence.

The bare-pid guard was a filename-shape proxy, and the audit was right that the real property is
cheap to test: two more three-line fixtures and one --pool=threads child. It now asserts the DATA
survived - all three fixture modules present in the merged snapshot - rather than the filename.
Reverting to a bare pid loses two workers' counters to a clean last-wins overwrite and the case
goes red.

That mutant also exposed a coupling the audit did not see: the writer's filename format and the
clear's delete predicate were two copies of one rule, and with a bare pid the clear silently
stopped matching, so a second run summed on top of the first. Both now come from
trace-snapshot-name.ts.

Other audit findings:
- trace-report.mjs created no .test-perf/ before writing trace.json, so with TESTPERF_TRACE_DIR
  set on a fresh clone the documented workflow crashed with a raw ENOENT after doing all the
  merge work. mkdirSync first.
- TESTPERF_TRACE_INTERVAL_MS ran through bare Number(), so abc/empty/-5 all became a 1ms
  synchronous whole-snapshot write loop inside every worker - measured 45 fires in 50ms - which
  charges its own cost to the measurement. Validated with the same idiom vitest.config.mts uses.
- The snapshot clear deleted every top-level *.json in a caller-supplied directory. Scoped to
  this tool's own filename shape, and wrapped: a throw there happens at config load and would
  abort the whole run with a raw stack.
- trace-config.mts advertised a bench.mjs invocation that its own rename breaks (bench hardcodes
  --project unit). Docstring corrected rather than widening bench's filter, which would drag
  unit-native into every yardstick run.
- Two comments corrected against measurement: the flush fires once per test FILE, not per suite,
  and the thread-pool failure is a silent last-wins overwrite, not a truncated read.
- README now discloses what the rename costs - a second 87MB dep bundle - and that under --watch
  only the first re-run clears.

Seven mutants, six dead on their own assertion: no afterAll -> expected 0 to be greater than 0;
empty snapshot -> expected [] to include; clearing disabled and bare pid -> expected 2 to be 1;
full rename -> expected +0 not to be 0. M7, reverting the interval validation, SURVIVES: no test
supplies garbage to that knob, so it is an unpinned defensive guard and is called one here rather
than counted as covered.

🔴 One of the previous round's mutants did not actually apply - backtick escaping - and its
"survived" reading was a fact about the harness, not the code. Every mutation in this round
asserts its own target was found before the run.

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

* fix(test-perf): pin the two behaviours the last round asserted but never checked

Round 3 of the delta audit. Verdict was "ship"; it still found two guards that no test could see,
and both are now killed by a mutant.

- The clear's PROTECTIVE half was unpinned. Widening SNAPSHOT_FILE_RE back to /\.json$/ left every
  test green while destroying a caller's other JSON - exactly what the README promises it will not
  do, and the whole reason trace-snapshot-name.ts exists. Only the drift direction was covered.
  Test 1 now writes important-notes.json into the trace dir and requires it to survive.
- The interval validation was called an unpinnable defensive guard. That was giving up early: the
  audit pointed out this PR had just established the pattern for pinning it. resolveIntervalMs is
  now a pure helper in the shared module with a nine-case table, no child process, and reverting
  it turns seven cases red.

Fixing that found a real hole in my own guard: Number.parseInt reads '1e10' as 1, which is the
1ms flush loop the check exists to prevent, and a case bare Number() handled correctly. It now
uses Number with an n >= 1 bound.

Also from the audit:
- The clear caught its error around the whole loop, so one undeletable snapshot abandoned every
  other stale file - and the survivors are summed into this run's numbers. Now per entry.
- The docstring said a typo'd --project unit "fails loudly". True, but not harmlessly: the clear
  runs at config load, before the project filter, so that invocation empties the trace directory
  first. Said so.
- The suffix comment claimed once per worker; measured once per test FILE under isolation.
- Fixture c's stated rationale was wrong - two files already discriminate, because the tracer
  re-initialises per file rather than per worker, so a scheduler batching them cannot hide an
  overwrite. Deleted it and the threads case runs on two.

Nine mutants, nine deaths, each on its own assertion: no afterAll / bare pid / writer filename
drift -> expected 0 to be greater than 0; empty snapshot -> expected [] to include; clearing
disabled -> expected 2 to be 1; full rename -> expected +0; interval validation reverted ->
expected NaN to be 15000; regex widened -> expected false to be true. Every mutation asserts its
target matched exactly once before the run.

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

* fix(test-perf): the interval guard had an upper bound problem, and the test asserted the broken value

Round 4 of the delta audit, and it caught my round-3 fix landing on the same pathology from the
other side.

resolveIntervalMs bounded the input below but not above. setInterval stores its delay in a 32-bit
signed int, so anything over TIMEOUT_MAX (2147483647) is clamped to 1ms - measured on Node
24.19.0, 1e10 fired 281 times in a 300ms window with "TimeoutOverflowWarning: Timeout duration was
set to 1", and it reproduces end to end in a real traced run. That is the ~900 writes/second
inside every worker that the function's own docstring exists to prevent, reachable from the "make
it huge so it never fires" direction. Worse, the it.each table I added asserted 1e10 -> 1e10 as
the correct answer, so the defect was enshrined as deliberate. Bounded at both ends now, with
cases at MIN-1, MIN, MAX, MAX+1 and 1e10.

Four behaviours the audit showed were unpinned or unmodelled:

- The per-entry catch in the clear was real and load-bearing - 0 survivors vs 5 - and no test
  could tell it from the pre-fix shape. The clear is now an exported clearStaleSnapshots with a
  synthetic-directory test including an EISDIR entry; reverting it to a rethrow goes red.
- mergeSnapshots in the test had stopped modelling trace-report.mjs: it filtered by the snapshot
  regex while the real reader took any *.json. Running the real reader against the directory this
  PR's own test creates gave "20 distinct modules | NaN executions | NaNs self time". The reader
  now accepts snapshots by SHAPE rather than extension - it is a .mjs file and the shared
  constants are TS, so a value check is what cannot drift - and a test spawns it against a foreign
  file and requires no NaN.
- Dropping the regex's $ anchor survived green: it still matches 1234-abcd.json.bak and .json.swp,
  i.e. editor and backup files next to a caller's data. The survives-set now includes one.
- The summing merge survived green too, because with clearing on there is only ever one snapshot.
  It is now pinned where two genuinely coexist - the threads case - against the sum of a module
  common to both files.

Also: TESTPERF_TRACE_DIR used ?? in all three readers, so an exported-but-empty value kept '',
mkdirSync('') threw, no snapshot was written and the report said "run a traced suite first" - the
dead-instrument failure this whole change exists to remove. Now || with a trim, single-sourced.
And trace-setup.ts's header rationale claimed globalThis makes counters survive across files;
measured, each file writes a disjoint snapshot on both pools and the totals are correct because
the report SUMS. The stale per-worker framing is gone from the report's label too - that count is
test files.

Thirteen mutants, thirteen deaths, each on its own assertion. Every mutation asserts its target
matched exactly once before the run.

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

* fix(test-perf): guard the tool, not the test's mirror of it — closing three named gaps

Round 5 of the delta audit named three fixes and a mechanical closing condition: re-run five
specific mutants rather than open a sixth round. All three are here and all five now die.

The pattern the audit named, which is what actually cost the last two rounds: the tests were
guarding their own mirror of the tool rather than the tool.

- The interval table derived every expectation from the constant it was testing, so it could no
  longer see a WRONG CONSTANT. DEFAULT -> 1 and MAX -> 2**31 both survived a green suite, and both
  are the 1ms flush loop the function exists to prevent - reachable on the default path a
  developer hits by not setting the variable at all. That is round 3's defect one notch out,
  introduced by round 3's own fix. Two literal rows now sit beside the derived ones.
- The shipped reader's sum was unpinned. mergeSnapshots in the test is a reimplementation, and it
  was the only thing any mutant could see; the single test that ran the real reader had a
  one-snapshot fixture, where summing and overwriting are identical. The fixture now holds two
  snapshots naming one module, asserted against the totals the tool itself prints.
- The .mjs reader cannot import the shared TS resolver, so it carries its own copy of the
  empty-string rule - the same drift the shared module exists to stop, one layer out where the
  module cannot reach. Reverting it to ?? was green while reintroducing half the dead-instrument
  failure this PR removes. A second spawn with TESTPERF_TRACE_DIR='' closes it.

Free corrections from the same round: trace-config.mts still documented one snapshot "per worker"
while trace-snapshot-name.ts three files away says that is not true; a test title and the README
carried the same stale framing; fixture b's comment counted three fixtures when there are two.

Closing condition, each mutation asserted applied and its mutant text confirmed on disk before the
run: DEFAULT->1 dies on `expected 1 to be 15000`; MAX->2**31 on `expected 2147483648 to be 15000`;
reader loads += -> = and selfMs += -> = both on the printed totals; .mjs ?? on the fallback path.
28 tests, 4.6s.

Not taken, deliberately: the report still exits with a raw ENOTDIR stack when TESTPERF_TRACE_DIR
names a regular file, and isSnapshot's guards are wider than the one test covering them. Both were
called optional, neither is a live defect, and adding more scaffolding to a gitignored dev tool is
the thing this ladder has been overdoing.

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

---------

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

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

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

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

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

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

Not verified: behaviour on a push to `main`, where `continue-on-error` is false
and a red shard renders as a genuine failure. That path only exercises on merge.
2026-08-25 15:59:41 -05:00
Zachary Lowden b34d90fd1c fix(apps): make the listing-completeness advisory KIND-AWARE (on-site copy lives in block.manifest.json) (#4370)
* fix(apps): make the listing-completeness advisory KIND-AWARE

`computeListingProblems` was kind-blind. It emitted `empty-description`,
`empty-tagline` and `empty-category` with the label "Missing <field>" for
EVERY listing. On an OFF-SITE listing that is correct — the author typed that
copy into the submit wizard and can go and fix it. On an ON-SITE listing it is
wrong, not merely terse: those scalars have NO author surface other than
`block.manifest.json`, and `approveRequest`'s (3b-sync) MANIFEST-GOVERNED COPY
RE-SYNC (`publish-request.service.ts`, scoped `kind: 'onsite'`) re-derives them
from the manifest on every subsequent-version approve. An on-site author who
found some other way to set a tagline would have it reverted at the next
approve — so `/apps/mine` was telling them to do something that cannot work.

KEEP-WITH-CORRECTED-LABELS, not suppress. The gap is real (the store page
genuinely has no tagline), so hiding it would trade wrong advice for no advice;
and suppression would silently kill the on-site branch of a released CLI that
consumes these codes. The codes and severities are therefore KIND-INVARIANT and
only the three labels move.

`kind` is a REQUIRED input, which is what makes "a caller was missed" a compile
error rather than a whole surface keeping the old advice. All three callers are
threaded, each PROJECTING the column rather than restating what its own filter
implies:
  - appListings.listMine        (app-access.service)     — both kinds, one page
  - appListings.listMySubmissions (offsite-listing.service) — on-site media
    revisions appear here too, via its `{ kind: 'onsite' }` OR-branch
  - blocks.listMyPublishRequests (blocks.router)         — projects `kind` even
    though its `where` filters on it

The kind lookup is an explicit equality branch, not a `TABLE[kind] ?? default`:
`kind` is an untrusted cast off the `app_listings.kind` column, and indexing an
object literal with an inherited key (`'constructor'`) returns something truthy,
which `??` accepts and which then yields a problem with NO label. An
unrecognised kind degrades to the original labels and never throws.

No component change: `ListingProblemsIndicator` renders whatever `label` it is
given.

Tests: 12 regression cases RED at origin/main d345b654a2 / green at HEAD, plus
labelled invariant guards pinning the wire contract. 14/14 mutants killed, each
by its own guard's assertion.

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

* fix(apps): correct the empty-category DIAGNOSIS, and close three audit gaps

Follow-up to the adversarial audit of #4370. No change to the kind-awareness
itself; the wire contract is untouched (same 8 codes, same severities, off-site
labels byte-identical).

1) empty-category named the WRONG REMEDY on an on-site listing (MEDIUM).

   The old label — and the comment justifying it — argued that an on-site listing
   can only lack a category when `AppBlock.category` is null, so the manifest must
   be the fix. That reasoning skipped a reachable, DESIGNED state: the advisory
   reads `AppListing.category`, and `setMarketplaceMeta` (moderator curation)
   writes `AppBlock.category` ONLY — it never touches the listing row.

     author omits category   -> listing minted null
     moderator curates       -> AppBlock.category set, AppListing.category null
     -> advisory fires; author "sets it in the manifest"; (3a)'s null-gate does
        NOT fire, their value is DISCARDED, and (3b-sync) writes the moderator's.

   The problem firing is right (the store card genuinely shows none); the
   diagnosis was wrong. The remedy that always clears it is an approved new
   version — the manifest key matters only when no category is set anywhere. The
   label now leads with that and marks the manifest conditional, which is why it
   is deliberately asymmetric with description/tagline (for those two the manifest
   IS the whole remedy, re-derived on every sync).

   Enumerating this also turned up a THIRD writer of the column that the audit's
   "exactly two" missed: (submit-draft) in publish-request.service mints the
   pre-approval draft straight from the manifest, because no AppBlock exists yet.
   It does not rescue the old claim — it is a create, not a rewrite of an existing
   null — but the comment now lists all three.

2) A comment claimed a guard that did not exist (LOW-MEDIUM).

   `ListingProblemKind`'s docblock said assignability with `ListingKind` was
   pinned in listing-problems.kind.test.ts. That file held no such assertion. The
   hazard IS covered — by the listMine call site, under the ROOT typecheck — so
   the comment now names the real mechanism, and the test carries a belt-and-
   braces pin that says out loud it is only checked by the deliberate pass.

3) LISTING-vs-REQUEST kind was pinned only structurally.

   The audit's mutant reading `r.kind` instead of `r.appListing.kind` SURVIVED all
   132 tests, because every fixture set the two equal. Not a live hole, but the
   stated rationale is that they might diverge, so a fixture now makes them —
   both directions.

4) The fixture guard enumerated only factory output, not inline literals. It now
   scans this file's own source, with both controls (a planted bad literal it must
   flag, and a non-zero count of literals actually examined).

Battery committed (scripts/mutation/) with its results, so the claims are
auditable from artifacts rather than reconstructible from a table. Two mutants
added: M14 (the audit's survivor) and M15 (reverting the category diagnosis).

Matrix re-measured at origin/main 4bfd4c16d: 15 red at base / green at HEAD.
Mutation: 16 defined / 16 verdicts / 16 killed, every run at the 154 baseline.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:05:17 -05:00
Justin Maier 06e2651d47 fix(dev-server): stop the daemon popping a terminal window that steals focus (#4373)
`startDaemon` spawned the daemon with `shell: true`. Node applies `windowsHide`
only to the process it creates, so the flag landed on cmd.exe and never on the
node cmd.exe then started. That node got a fresh console, Windows 11 handed it to
the default terminal app, and a Windows Terminal window opened and took focus off
whatever the user was doing. Measured: the window appeared on every run of the
unpatched code and stole focus on four of six.

Dropping the shell fixes that, but it also leaves the daemon with no console of
its own — so every child it spawns is then in the same position and pops its own
window, once per dev server started, per taskkill, per queued vitest run, per
branch-watch `git`. Verified that too, so `windowsHide` goes on all of them.

Guarded by scripts/__tests__/dev-server-no-console-window.test.ts, which fails if
either half regresses (checked by mutating both back).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 08:22:06 -06:00
Justin Maier 0d1142ce4c fix(skills): a flag with no value is an error, not a silent true (#4263)
* fix(skills): a flag with no value is an error, not a silent true

metabase and cloudflare both degraded a value-taking flag whose value was
missing, empty, or --prefixed into a truthy placeholder: boolean true in one,
the string 'true' in the other. Every downstream guard tests truthiness, so
create-question posted native: { query: true } and Metabase stored a card with
no query on it — created successfully, opens blank, indistinguishable from a
permissions problem.

Both parsers now reject a missing value and accept --key=value for a value that
legitimately starts with --. create-question reads the card back and fails if
the stored SQL is not what was sent.

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

* docs(skills): document the flag-value rule in both SKILL.md files

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

* fix(skills): keep --json parsing, close the --key= hole, and separate a failed read-back from a failed create

Review of #4263 found three things. --json is advertised in metabase's own usage
text, so rejecting it was a regression introduced by the fix rather than by the
bug. The --key=value form — the one the error message sends people to — accepted
an empty value the space-separated form rejects. And a cloudflare boolean flag
no longer consumed an explicitly spelled , which shifted positional[1].

The read-back now distinguishes 'created but could not be read back' from
'created with the wrong SQL': a GET that fails must not be reported as a create
that failed, since the card exists either way.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 13:37:36 -06:00
Zachary Lowden bdb55c9ef2 feat(observability): expose Flipt eval-cache stats — a hit rate alone cannot pick the right knob (#4202)
* feat(observability): expose Flipt eval-cache stats — a hit rate alone cannot pick the right knob

The per-eval TTL cache in `@civitai/flipt` tracked `hit` internally and threw the
result away, so from outside the process it was a black box. It has two tuning
knobs — `FLIPT_EVAL_CACHE_TTL_MS` and the `evalCacheMaxEntries` ceiling — and no
signal to choose between them.

That matters because the two failure modes are indistinguishable from a hit rate
and have OPPOSITE remedies:

  * misses dominated by expired entries -> TTL-bound. A longer TTL converts them
    into hits.
  * generation rotations climbing -> capacity-bound. Entries are evicted before
    they can expire, so a longer TTL recovers NOTHING; it is an inert change that
    reads as a fix. The ceiling is the knob.

The cache key is (flag, entityId, context), so a per-user entityId multiplies the
key space by the active-user count — capacity-bound is the likelier of the two on
hot paths, which is exactly the case a bare hit rate would have hidden.

Adds cumulative counters to TtlCache (hits, misses, expiredMisses, rotations,
size), surfaces them via `getCacheStats()`, and exports them from the monolith as
`civitai_app_flipt_eval_cache_*` on the same default registry /api/metrics
scrapes. Registered by side-effect import there, matching its neighbours, so the
series exist from the first scrape — an absent series would read as "the cache is
idle" rather than "nobody loaded the module".

The package gains no new dependency: the counters are plain numbers and all
prom-client wiring lives app-side.

Verified rather than assumed. Every new guard was mutation-tested and each mutant
died by its OWN named test, with the control restored byte-identical:
  * expiredMisses bumped on every miss    -> killed by the cold-vs-expired test
  * rotation counted on every set         -> killed by the overflow test
  * promoted read not counted as a hit    -> killed by the promotion test
  * reset() dropped from collect()        -> killed by the double-count test
  * boolean stats reported for both labels -> killed by the per-kind test
Typecheck: 317 errors at origin/main and 317 on this branch, with byte-identical
error sets (zero branch-only errors) — the baseline is a stale generated client,
not this change. eslint clean on the touched files and on packages/civitai-flipt,
verified with a negative control that the linter actually processed them.

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

* fix(flipt): pin the client on globalThis — the metric was measuring ONE of TWO caches per pod

Audit found the observability this PR adds was itself half-blind, in the same
shape the immediately-preceding merged PR (#4173) reverted the pg-pool gauges for.

MEASURED, not theorised: `src/server/flipt/client.ts` is emitted TWICE in the
production server build. `[flipt] eval cache TTL:` appears exactly 2x on every
pod — 488 lines across 244 pod streams — against one `[instrumentation] Running
in nodejs runtime`. Each emitted copy owned a private wasm client, a private 60s
config poller and a private pair of eval caches, and `getFliptCacheStats` closed
over whichever copy its chunk resolved.

That is worse than a scale error, because the deliverable is a KNOB DECISION and
the bias has a direction: rotations are superlinear in per-instance key space, so
one key space split across two caches at the same ceiling rotates far less than
one cache holding all of it. The split reads as "TTL-bound" and sends the reader
to the knob that changes nothing — the exact inert change these metrics exist to
prevent.

Pins the client on `globalThis.__civitaiFliptClient` using the repo's canonical
idiom and enrols the module as SHARED_STATE in server-graph-watchlist.mjs, so a
refactor that drops the pin fails that gate. Side benefit: one wasm engine and
one config poll per pod instead of two.

Also fixes three defects the audit found in this PR's own tests:
  * The seam was untested. Deleting the side-effect import in
    src/pages/api/metrics.ts left the whole src/server/metrics/ suite green
    (131/131) — registered-but-unreached, this repo's #1 metric-death mode. Now
    asserted by loading the module that SERVES the scrape, matching the
    substitutions and bitdex-feed-serve seam tests.
  * One asserted cell was vacuous: read() ended `?? 0`, so asserting the variant
    cache's expiredMisses is 0 passed whether or not the series existed —
    the absent-vs-zero ambiguity this metric exists to remove, reintroduced in
    the test guarding it. Returns NaN on a missing label now.
  * prettier failed on the new file, making CI red. The PR body claimed "lint
    clean on the touched files"; that was true of eslint and false of prettier —
    the negative control was run on the wrong instrument.

Mutation-verified, each killed by its own named test, sources restored
byte-identical with controls green:
  * delete the side-effect import   -> seam test          (previously SURVIVED)
  * gate the variant inc on non-zero -> per-kind test      (previously SURVIVED)
  * `??=` downgraded to `=`          -> watchlist gate

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 16:36:41 -05:00
Zachary Lowden 5770b83f4e ci: give a commit on main a CI verdict again, and pin that it keeps one (#4234)
* ci: give a commit on `main` a CI verdict again, and pin that it keeps one

Nothing has produced a CI verdict for a commit on `main` since 2026-06-14. The
cause was not a decision: `.github/workflows/pr-check.yml` was the only workflow
this repo has ever had with `push: branches: [main]`, and #2547 deleted it while
moving PR checks to Tekton. Every workflow written since — lint (#3362),
submodule-pin-guard, schema-drift (#3643), windows-dev-env (#4162) — is
`pull_request`-only, so the main-push trigger was never rebuilt and nothing said
it was gone.

Tekton does not close the gap. Its `pr-check` pipeline is driven by a resource
that watches open pull requests targeting `main`; the only main-branch trigger
builds a container image for the staging deployment, runs no test suite and
posts no commit status. "Tekton covers main" is the assumption that let this
persist for two months.

The old June runs stayed `success`, so "is main green?" went on answering yes.
That is the expensive half: the signal was not wrong, it was stale, and
staleness is invisible unless you read the dates. a3e790ff2e was pushed straight
to `main` with no PR, broke model-file-scan.service.test.ts, and sat undetected
for ~7 hours — surfacing only when unrelated PRs inherited it through their
merge commits, which reads as a CI outage rather than as one bad commit.

What changes

- `lint.yml` also runs on `push` to `main`. Reusing this file rather than adding
  a second workflow keeps one definition of each tier; a duplicate would drift.
- `eslint` is gated to pull requests. Every step in it diffs against
  `origin/$BASE_REF`, and `github.base_ref` is empty on a push, so on `main` it
  would fail for reasons unrelated to the code. The consequence is stated in the
  file rather than left implied: the added-file lint/Prettier gate and the
  stray-migrations guard stay reachable only through a PR.
- `typecheck` gains an explicit push arm. It is redundant today only because
  `github.event.pull_request` is null on a push, which is an accident of
  null-comparison semantics rather than a decision.
- `unit` is report-only on pull requests exactly as before, and renders its real
  verdict on `main`. `continue-on-error` makes a run report `success` while the
  step underneath fails — on `main` that would replace the stale TRUE with a
  live one, which is worse. Whether the PR side flips to blocking is a separate,
  deliberately-unmade decision and is untouched here.
- The concurrency group varies per commit on a push. Keyed on `github.ref` it is
  one group for the whole branch, so a busy `main` would cancel its own runs,
  leave only the last commit checked, and render the losers as `cancelled` — a
  status this repo already cannot distinguish from a real failure.

Coverage

`scripts/__tests__/main-branch-ci-coverage.test.ts` asserts the outcome, not the
spelling: it names no workflow file and no job, and asks whether a push to
`main` reaches a whole-repo typecheck and the unit suite at all. Splitting or
renaming things keeps it green; losing main coverage does not, however it is
lost. It also pins the three ways this would decay quietly — an unconditionally
report-only job on the push path, a job whose steps consume PR-only context, and
a branch-wide concurrency group.

Six mutants, each killed by the test that owns the property: reverting the
workflow to its pre-change state (the historical defect) fails the coverage
assertions; `continue-on-error: true`, an un-gated base-ref consumer, a
`github.ref` concurrency group, and gating either the typecheck or the unit tier
away from push each fail exactly one test, by name.

* style: format the main-branch CI guard test (added-file Prettier gate is blocking)

* docs(ci): enumerate the push-to-main triggers instead of asserting there was one

The claim 'pr-check.yml was the only workflow this repo ever had with
push: branches: [main]' was reasoned from the current tree, not measured.
Parsing every revision of all 12 files that have ever existed under
.github/workflows/ gives TWO: docker-deploy.yml (removed 2023-11) and
pr-check.yml. Two near misses are named so they are not re-counted —
auth-app.yml filters push on tags only, which never fires on a branch push,
and the final docker-deploy.yml had main commented out under branches.

* ci: wire `db:check-generated` into CI — a four-time regression with nothing enforcing it

`packages/civitai-db-schema/src/*` is generated but tracked, so a commit whose
author's editor reformatted it lands a file that disagrees with its own
generator, and every developer then gets a spurious dirty tree after
`pnpm install`.

`db:check-generated` has existed for exactly this and was wired to nothing.
Measured 2026-08-21: it appears in no workflow. Counting wrapped type aliases in
enums.ts per commit shows it flipping four times in four days —

  470f0fd993  0   "stop prettier reformatting the generated file"
  4214ecb10b  30  re-introduced
  15c1408d3d  31  "the generator emits Prettier-formatted bytes"
  a5ed2dc83e  0   re-introduced
  d0327f55ef  31  re-fixed

— twice AFTER the commit that was supposed to end it by making the generator own
the formatting. That fix is correct. It was a convention with nothing enforcing
it, which is why it did not hold. `main` is clean right now by coincidence of
which commit landed last, not by construction.

Parked in the `packages` job for the same two reasons as the Next-SVG step above
it: it needs a completed `pnpm install` and a job that fails the build, and this
is the cheapest one that is both. postinstall has already run `db:generate`, so
the re-run costs ~1s.

Verified, both arms, on the merged base:
  green  unmodified tree                       -> exit 0
  red    the real regression shape, COMMITTED  -> exit 1, 62 changed alias lines
                                                  (= 2 x 31 aliases)

The red arm needed two attempts and the first one is the interesting half: the
same edit left UNCOMMITTED exits 0, because the script regenerates before it
diffs and overwrites the mutation. A working-tree edit cannot exercise this gate
at all. Recorded in the step comment so the next person testing it does not read
that 0 as "the gate does not work".

Also guards the vacuous-pass case: `git diff --exit-code -- <path>` exits 0 when
the path exists on neither side, so a future move/rename/untrack would leave this
silently and permanently green. `test -f` fails loudly instead. Verified an
--exit-code diff against a nonexistent sibling path exits 0 here today.

Merged origin/main in first (the branch was 9 commits behind and carried the
unwrapped flip state, so the gate was red at base on the branch tip while green
on the merged tree CI actually builds).

actionlint clean, with a positive control confirming it can go red.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 16:33:17 -05:00
Zachary Lowden 314663b1b1 fix(dev-server): give a queued test run a FILE, not a pipe — a child that exits without flushing was losing most of its log (#4190)
* fix(dev-server): give a queued test run a FILE, not a pipe — a child that exits without flushing was losing most of its log

Closes two ClickUp tickets that share one capture path but are different
defects, and the head-to-head measurement is what picked the fix.

868ktwgyc — output lost before the daemon ever received it. Node makes a child's
stdout SYNCHRONOUS when it refers to a regular file and ASYNCHRONOUS when it
refers to a pipe, so a child calling `process.exit()` discards whatever is still
queued on a pipe. Measured head-to-head, same child, same process, old
mechanism vs new:

    consumer=slow  PIPES received= 172 missing=4828  |  FILE received=5000 missing=0
    consumer=slow  PIPES received= 172 missing=4828  |  FILE received=5000 missing=0
    consumer=slow  PIPES received= 172 missing=4828  |  FILE received=5000 missing=0
    consumer=fast  PIPES received=1473 missing=3527  |  FILE received=5000 missing=0
    consumer=fast  PIPES received=5000 missing=   0  |  FILE received=5000 missing=0
    consumer=fast  PIPES received=5000 missing=   0  |  FILE received=5000 missing=0

Two separate runs would not have been a comparison; this is the same child
through both arms. Numbering the lines is what makes it readable — a bare count
cannot tell truncation from sampling, and every missing line here is a
contiguous tail, which is what identifies the mechanism.

That measurement also ruled out the two fixes the ticket proposed. The daemon
CANNOT detect the loss by reading: it sees a clean EOF with no signal to compare
against, because the bytes never reached the pipe. And "flush before exit" is
not ours to call — the child is vitest. A file needs no cooperation from the
thing losing the data.

868kubfd9 — the reader tore lines at chunk boundaries. `d.toString().split('\n')`
per chunk with no carry buffer split any line straddling a boundary in two and
recorded BOTH halves as lines: 5,000 in, 4,998 recognised, 6 fragments invented,
353,893 of 353,893 bytes. Complete delivery, corrupted log, and `logsDropped`
correctly reports 0 for it — which is what made it invisible behind a counter
readers had been trained to trust. The new reader carries the partial line.

Third thing, not in either ticket and the one that protects a reader most: the
final drain now runs BEFORE onExit. A waiter wakes on the terminal status and
then reads the log, so reporting the exit while lines are still unread produces
a complete-LOOKING log that is still filling — the same false green, reached a
different way. The old pipe path could do that, since 'exit' does not wait for
pending 'data'.

Trade-off, stated rather than buried: one file for both streams, so the
interleaving is the real one, at the cost of no longer distinguishing stdout
from stderr. Lines are recorded as `output` rather than claiming to be one or
the other — nothing renders the level for a test run (both consumers print
`entry.message`), and a label we cannot support is worse than an honest one.

Seven mutants, each alone, tree cmp-verified between:
  carry buffer removed        -> the straddle case (+2 more)
  final flush removed         -> the trailing-line case
  back to pipes               -> both seam cases
  onExit before the drain     -> the ordering case
  drain reads a single chunk  -> the 5,000-line case + straddle
  stderr to another fd        -> the shared-descriptor case
  capture file not deleted    -> the cleanup case
All killed. The ordering mutant is killed ONLY by the ordering test, which is
why it exists.

344 passed (15 files). typecheck 0 errors. New test file prettier-clean.

* fix(dev-server): the capture had no owner — a forced or shut-down run leaked two fds, an unbounded /tmp file and a live interval

Audit round on #4190. Both blockers share one root cause: `finish` was bound only
to the child's own 'exit', and there are two live paths where that event never
comes.

🔴 The sweep's force-release — the wedge this queue exists to prevent — set
`run.handle = null` and dropped the queue's last reference while the interval,
both descriptors and the capture file stayed alive inside the closure. Measured
on a forced run: 2 fds still open and the log still growing 2s after the run was
reported terminal (logIndex 75 -> 471), file at 102,465 bytes, never unlinked.
A reader fetching logs after the verdict would get lines emitted AFTER it, with
`logsDropped` reading 0. `shutdown()` had the same gap.

🔴 And it was already leaking on every CI run, from a test this PR did not
update: `dev-server-test-queue-spawn.test.ts` calls `defaultStartRun` twice and
never emits 'exit'. Not hypothetical — 15 stale `civitai-test-run-*.log` files
were sitting in this box's /tmp from my own runs, 420 KB. Verified fixed by
counting: 0 before, 0 after, where it was 2 per run.

Both closed by `dispose()` on the handle, called from the force-release and from
`shutdown()`. Idempotent, and deliberately does NOT call onExit — the caller has
already settled the run.

Also from the audit:

- A carry buffer fixes a torn LINE and does nothing for a torn CHARACTER.
  Decoding each read independently turned a 3-byte `⎯` starting at byte 65535
  into THREE U+FFFD with the original gone — and vitest builds its failure output
  from `⎯`/`✓`/`×`/`❯`, one boundary per 64 KiB. `StringDecoder` now spans reads.
  The existing straddle test is ASCII and structurally could not see this.
- The final drain no longer stops at the first short read. Truncating there would
  report `logsDropped: 0` over a clipped log — the exact thing
  `warnIfLogsDropped` exists to make impossible.
- The straddle test's positive control is pinned from both sides; `> WINDOW`
  alone is also satisfied by padding that already exceeded it, which would put
  the line wholly inside the second read and never exercise the hazard.
- The `output` level is now pinned, the read buffer is hoisted out of a 10x/s
  allocation, and the spawn-failure paths return a handle with the same shape.

Seven mutants, each alone, tree cmp-verified between:
  force-release stops disposing  -> the queue seam case
  shutdown stops disposing       -> the queue seam case
  per-read decode                -> the multi-byte case
  lines relabelled 'stdout'      -> the level case
  dispose also calls onExit      -> the dispose-contract case
  dispose not idempotent         -> the dispose-contract case
  final drain stops on short read-> SURVIVED, and it is honest that it does

That survivor is stated rather than papered over: on a regular file a short read
only happens at EOF, so no test on this filesystem can distinguish the two. It
is contract hardening, not covered behaviour.

🔴 The seam lesson again: the first version of the dispose test called
`handle.dispose()` directly, which proves the method EXISTS and not that the
queue ever calls it — a `dispose()` nobody invokes is exactly the shape this
missed the first time. The killing test now drives TestQueue and asserts on
both release paths.

MEASURED, not fixed here: a fully PASSING unit run emits 5,390 lines against
`MAX_LOG_LINES = 2000`, so now that the capture actually delivers everything the
"INCOMPLETE" warning will fire on every full-suite run — and a warning that
always fires is one people stop reading. Raising the cap has memory arithmetic
attached (`KEEP_FINISHED = 50` retained runs x the window), so it is a tuning
decision that does not belong inside a correctness fix.

349 passed. typecheck 0 errors. 0 leaked captures.

* fix(dev-server): shutdown disposed the handle and settled nothing — the fix for the leak wedged the queue

Delta re-audit round on #4190. The previous round's `dispose()` closed both
leaks and introduced one genuine regression, which is fixed here.

🔴-in-effect: `dispose()` and `finish()` share the `finished` flag, so disposing
DISABLES the child's exit callback. The sweep's force-release settles the run
itself right after disposing; `shutdown()` did not. The SIGKILLed child's 'exit'
then arrived and returned early, `onExit` never fired, the run stayed `running`
and `running.size` never dropped — so `pump()` could never start another run.
That is the wedge this queue exists to prevent, reintroduced by the fix for a
leak. Measured in both arms: 300 ms after shutdown(), `running` / size 1 at
HEAD, against `cancelled` / 0 with the dispose removed. `shutdown()` now
settles what it disposes.

Survivable today only because every caller exits the process straight after —
but each first awaits rgbProxy.stop(), authHub.stop() and stopSpokeApps(), and
if any of those hangs the daemon stays up serving a queue that can never run
anything again.

🔴 And the comment I wrote to justify that dispose was FALSE. It claimed the
capture file would otherwise "survive the daemon that created it, every time".
Measured in both arms: the file was unlinked either way, because the daemon does
linger long enough for the child's exit. The dispose earns its place by
releasing the interval and the descriptors deterministically — not by that. The
comment now says so, including that it was wrong.

Also from the re-audit, all on lines the previous round added:

- The multi-byte test was VACUOUS without a straddle — it passed with the
  StringDecoder removed entirely, because "no U+FFFD" is equally satisfied by a
  character that never crossed a boundary. Now pinned from both sides, the same
  control its sibling got last round.
- `decoder.end()` had no test. A child killed mid-sequence leaves an incomplete
  character; without the flush those bytes vanish silently instead of surfacing
  as U+FFFD.
- `dispose()` draining before releasing had no test, on the one path where the
  child is KNOWN to still be writing — `drain(true)` after `close()` emits
  nothing at all.
- A read that THROWS on the final drain was silent. Same argument as the
  short-read break beside it: it would report a clipped log with
  `logsDropped: 0`. It now emits `[capture truncated: <code>]`.
- The `!final` comment overstated its scope: on the `finish()` path the writer
  is already dead, so it changes nothing there. It has effect only on `dispose()`.

Battery, each mutant alone, tree cmp-verified:
  shutdown disposes but never settles -> the new queue-level case
  dispose closes before draining      -> the drain-before-release case
  decoder.end() removed               -> the incomplete-character case
  final-drain read error silent       -> the truncation-marker case
  dispose leaves the interval running -> SURVIVED

That survivor is stated, not papered over: once the descriptors are closed the
stray interval's read just throws and breaks, so it has no observable effect on
the log. The ordering is still correct and still load-bearing — fd numbers are
reused, so a surviving reader could splice another file's bytes into this run —
but nothing pins it without exposing internals.

🔴 The harness lied to me first. Four mutants reported SURVIVED with an EMPTY
test count, because the runner list was an unquoted `$T` and zsh does not
word-split — vitest matched zero files and printed project globs. That is the
exact trap I wrote into the auditors' own briefing. The battery now refuses to
report a verdict when no `Tests` line is present, and the baseline control
proves one appears.

353 passed. typecheck 0 errors. 0 leaked captures.

* fix(dev-server): freeing the SLOT must not sit downstream of IO that can throw — and I was wrong that the stray interval was harmless

Round-3 delta re-audit. It refuted two of my own claims with measurements; both
are corrected here rather than argued with.

🔴-in-effect: `dispose()` does real IO and calls back into `onLog`, and it sat
ABOVE the detach/release/settle that free the slot — in both `shutdown()` and
the sweep's force-release. A throw there skips all three and the queue is wedged:
the identical failure the settle was added to fix, one round earlier. Measured
with a throwing dispose: status `running`, running.size 1, against `cancelled`
/ 0 when it returns normally. It also aborts the loop, leaving every remaining
run unkilled, and because both daemon handlers are async the rejection means
`process.exit(0)` is never reached — SIGINT would appear to do nothing.

Worse, this round ADDED a throw source inside that block: `err.code` raises on a
null/undefined throw. The repo already documents this hazard one file over
(`scripts/test-unit-run.mjs` uses `err?.message ?? String(err)` with a comment
saying why). Now `err?.code ?? err?.message ?? String(err)`, and both dispose
call sites are guarded — releasing the slot matters more than releasing the fds.

🔴 RETRACTION. I reported the `clearInterval` mutant as surviving with "no
observable effect once the descriptors are closed". That is measurably false,
and my own test comment said so while I argued the opposite. Descriptor NUMBERS
are reused: once the next `openSync` claims the freed fd, a surviving interval
reads whatever file now owns it. Measured: 4,246 lines of an unrelated file
spliced into a terminal run's log, against 0 with the clearInterval in place.
It is now pinned rather than argued away.

That test needed a second try to be able to fail at all. `openSync` returns the
LOWEST free descriptor, and the tail reads from `readFd` — the HIGHER of the
pair the capture just closed. Claiming one descriptor takes `writeFd`'s number
and the mutant goes unobserved, which is exactly how the first version passed.
It now claims both.

🔴 SECOND RETRACTION. Last round's message said the multi-byte test was
"vacuous without a straddle" and passed with the StringDecoder removed. The
re-audit re-ran that mutant against the round-2 file: KILLED. The padding was
already `READ_WINDOW - 1`, so the straddle always existed — the "survived" came
from my own broken harness, the one that reported four false SURVIVED verdicts
with an empty test count. The added assertions were still worth having, but the
justification I gave for them was wrong.

The residual behind it was real and is fixed: the test held a private copy of
`64 * 1024`. Widen the module's buffer and both boundary controls go vacuous
while staying green — proven: at 128 KiB the whole suite passed with the
StringDecoder deleted outright. The window is now exported and imported, so the
control cannot drift from what it controls.

Also: the test named 'drains before releasing, and stops the tail before closing
the descriptors' asserted only the first half. Renamed to what it checks, and
the second half is now its own test.

Battery, each mutant alone, tree cmp-verified, every verdict gated on a `Tests`
line being present:
  dispose leaves the tail running        -> the fd-reuse case
  shutdown's dispose unguarded           -> frees-the-slot-on-shutdown
  sweep force-release dispose unguarded  -> frees-the-slot-on-force-release
  read window widened + decoder removed  -> the multi-byte case

356 passed. typecheck 0 errors. 0 leaked captures, 0 leaked probes.

* test(dev-server): assert the exact terminal status per release path, not either-of

`toContain(['cancelled','timeout'])` passes with the WRONG status for the path
under test — a shutdown reported as a timeout, or the reverse. That is the shape
of assertion that lets a real mix-up through, and this file now has two paths
that settle differently.

Controlled: making shutdown settle 'timeout' now fails two tests; before this it
failed neither.

* fix(dev-server): a throwing log consumer leaked both descriptors and the file, and the healthy path's tail guard was never pinned

Round-4 delta re-audit. It reported no 🔴 and confirmed the previous round's
fixes hold — each killed by a mutant dying for its own reason, which is the
first round that has been true. These are its two 🟡 follow-ups, folded in
rather than deferred.

Draining calls back into `onLog`. With the close outside a `finally`, a consumer
that throws left BOTH descriptors open and the capture file on disk — measured:
fds 20 before and 20 after, file still present, 1 of 3 lines lost. Per run, in a
daemon that runs for days. And the slot-freeing guard added last round would
then swallow it, so it was silent as well as leaky. Both `finish()` and
`dispose()` now close in a `finally`.

That guard is no longer silent either. Swallowing the error hid a clipped log
behind `logsDropped: 0`, which is the one outcome the module's own comment says
the log contract rules out — and which the `[capture truncated]` marker two
lines below exists to prevent. Both sites now `addLog(run, 'error', …)`.

🔴 The tail guard covered the RARER path. `finish()` and `dispose()` have the
identical clearInterval -> drain -> close shape, and last round pinned only
`dispose()`. `finish()` runs on every normal child exit, so the stray-interval
hazard the round proved is not harmless was pinned on the edge case and left
open on the common one. Removing `finish()`'s clearInterval survived the whole
suite; it now fails.

Also: the fd-reuse tests had no self-check that the reclamation they depend on
actually happened. If `openSync` ever stops handing back the released numbers,
`readSync` gets EBADF, `drain()` breaks silently, and the test goes on passing
while protecting nothing. Both now assert the descriptor was reclaimed.

And a sentence on why `kill(true)` beside the guarded dispose is NOT guarded —
`defaultStartRun`'s kill body is wholly inside its own try/catch and the daemon
always uses that runner — so the asymmetry does not have to be re-derived.

Battery, each alone, every verdict gated on a `Tests` line:
  finish() loses clearInterval   -> the healthy-path tail case (was surviving)
  dispose closes outside finally -> the throwing-consumer case
  finish closes outside finally  -> the throwing-consumer case

CORRECTION to the previous message: I described `err?.code ?? err?.message ??
String(err)` as a fix. It is an EQUIVALENT MUTANT — `readSync` only ever throws
a Node SystemError, so reverting it is undetectable on any reachable input. It
costs nothing and I have kept it, but it is defensive hardening, not covered
behaviour, and I should not have implied otherwise.

358 passed. typecheck 0 errors. 0 leaked captures, 0 leaked probes.

* fix(dev-server): the test asserted deletion of files it did not own, and the round's headline change was untested

Round-5 delta re-audit. No 🔴 and no regression introduced — the first time that
has been true twice running. These are its three follow-ups.

🔴-in-practice: the throwing-consumer test snapshotted /tmp by PREFIX and
asserted every match was deleted. That matches captures owned by the operator's
own daemon, which creates one whenever it runs a queued test — so the suite goes
red with no defect present. Not theoretical: during the audit a single foreign
file made the UNMUTATED test fail, and made three mutants report a false KILLED.
The filename carries the owning pid for exactly this reason; both snapshots are
now scoped to `civitai-test-run-${process.pid}-`. Controlled: with a foreign
file planted, 359 pass and the file is left untouched.

The headline change of the previous round was untested. `finish()` and
`dispose()` have the identical drain-then-close shape and the throwing-consumer
test drove only `dispose()` — reverting `finish()`'s try/finally survived the
whole suite. `finish()` is the path every healthy run takes. It now has its own
case driving `child.emit('exit', 0)`, and the revert fails.

Nothing asserted the `capture release failed` line either, so both catch sites
could go back to a silent swallow with the suite green — which would restore
exactly the hidden-clipped-log outcome the line was added to prevent. Asserted
now; both silent-swallow mutants fail.

🔴 CORRECTION. The previous message claimed I had added a comment explaining why
`kill(true)` is unguarded while its neighbour is. That comment was NOT in the
tree — my edit's anchor did not match and I did not verify that one landed. The
claim itself was true and the auditor re-derived it independently, but I stated
as done something I had not checked. It is there now. This is the second false
claim I have put in a commit message on this branch; the pattern in both cases
was asserting an edit without grepping for it afterwards.

Battery — the three that survived the last round:
  finish() reverts try/finally  -> the normal-exit release case
  sweep catch goes silent       -> the sweep force-release case
  shutdown catch goes silent    -> the shutdown case
All killed. Every verdict gated on a `Tests` line.

359 passed. typecheck 0 errors. Clean /tmp before and after the shipped suite:
0 captures, 0 probes. (The one file that appeared mid-battery was the M3 MUTANT
leaking by design — content `boom one/boom two/x`, the `x` proving the
descriptor stayed open, which is what the kill detects.)

* test(dev-server): a positive control on the pid-scoped /tmp filter, so an empty match cannot pass as a no-op

Scoping the snapshot to this process fixed the cross-process false failure, and
introduced the opposite hazard: `for (const f of mine) expect(...)` over an
EMPTY list is a loop that runs zero assertions and passes. If the capture
filename format ever changes, both deletion tests would go on passing while
protecting nothing — the same vacuous-green shape this branch has now hit in
three different places.

Measured at exactly 1 match, and pinned there. Controlled: breaking the scope so
it matches nothing fails both tests, where before it would have been silent.

* test(dev-server): own captures by DELTA, so a sibling's defect stops failing this test on bookkeeping

Round-6 delta re-audit: no 🔴, no production defect, no regression — the range's
only non-test change was three comment lines. This is its one 🟡 plus two
accuracy fixes.

The capture snapshot claimed every live capture for this pid, and these cases
share a fork. So a defect in the SIBLING test's subject left its file behind and
this test failed on the file count rather than on its own assertion. The mutant
still died — at the wrong line, which silently over-credits whatever this test
was meant to cover. That is the same wrong-reason-kill problem this branch has
been chasing since round one, arriving in the fixture instead of the guard.

Now a before/after delta, so ownership is exact. Controlled: with dispose()'s
try/finally removed, ONE test now fails — the dispose one, at
`expected [Function] to throw an error`, its own assertion. Previously BOTH
failed and the finish() case reported
`expected [ …(2) ] to have a length of 1 but got 2`.

Two comments corrected:

- "it is the only `new TestQueue`" was false repo-wide — three more exist in the
  test file, two passing a startRun. The load-bearing claim (production never
  injects a runner) is true; the sentence was not, and the next reader greps it.
- The throwing-consumer leak is NOT reachable through the daemon today: its
  `onLog` is `addLog`, which cannot throw. The round-5 comment described it as
  happening "per run, in a long-lived daemon". It is cheap insurance against a
  future consumer, and now says so.

359 passed. typecheck 0 errors. 0 leaked captures.
2026-08-21 01:56:20 -05:00
briant c92c71e2a5 Merge remote-tracking branch 'origin/main' into moderator-feedback
# Conflicts:
#	scripts/__tests__/dev-server-daemon-port.test.ts
#	scripts/__tests__/typecheck-apps.test.ts
#	src/__tests__/source-nul-bytes.test.ts
#	src/components/Apps/__tests__/appListingStatChips.test.ts
#	src/server/services/__tests__/model-file-hash-writers.test.ts
#	src/tests/build/standalone-boot-graph.test.ts
#	src/utils/__tests__/rating-label.test.ts
2026-08-20 17:53:11 -06:00
briant ba7504aac3 fix(tests): make seven suites pass on Windows
17 tests across 7 files failed on a Windows checkout and pass on CI. None was a
product defect; all were portability bugs in the harness code, and two of them
were reporting the thing under test as broken when it was not.

Path separators. `path.relative` yields backslashes on Windows while the ledgers
these tests compare against are written with `/`, so the walk matched nothing and
the ledger read as "no consumers" — `rating-label`, `model-file-hash-writers`,
`dev-server-daemon-port`. Normalised at the walk.

`new URL(..).pathname` gives `/C:/…`, which resolves to `C:\C:\…` and ENOENTs —
`appListingStatChips` now uses `fileURLToPath`.

`standalone-boot-graph` asserted on `esm/index.js` in output where Node prints a
NATIVE path.

`dev-server-daemon-port` spawned `import(<absolute windows path>)`, which ESM
rejects with ERR_UNSUPPORTED_ESM_URL_SCHEME because the drive letter parses as a
scheme. The child exited 1 and the test — "can be imported without throwing" —
reported the daemon as broken for a reason unrelated to the daemon. Now
`pathToFileURL`.

Two are genuine environment limits and are SKIPPED rather than weakened, so the
gap stays visible. The three symlink CONTROLs in `source-nul-bytes` need
elevation on Windows (EPERM, reproduced); they are gated on a capability probe
rather than `process.platform`, so Linux CI and an elevated shell still run them,
and the main NUL-byte assertion is untouched. `typecheck-apps` puts a
`#!/usr/bin/env bash` fake `pnpm` on PATH with `chmod 0755` — neither means
anything on Windows — so it is skipped there; its `:` PATH separator is fixed to
`path.delimiter` regardless.

Counts, since skips can hide regressions: 19502 passed + 27 skipped + 17 failed
before, 19506 passed + 40 skipped + 0 failed after. Same 19546 total; the 13 new
skips are exactly the 3 symlink controls and the 10 typecheck-apps cases.

Also clears two pre-existing eslint errors in a file already being touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:23:17 -06:00
Zachary Lowden 94510da5f3 fix(metrics): bulkhead + pg-pool gauges emitted zero series — a globalThis flag guarding a per-graph prom registry (#4173)
* fix(metrics): a globalThis flag guarding a per-graph registry made the bulkhead gauges emit nothing for 74 days

`civitai_app_heavy_bulkhead_active` / `_rejects` have produced ZERO series since
they shipped in #2428 (2026-06-07), while every link in the chain looked healthy:
registered in code, merged to release, in the running image, on a hot route, on
pods emitting hundreds of thousands of other series.

Two independent defects, and fixing either one alone leaves the metric inert.

1. REGISTRY. Next.js compiles instrumentation.ts into a separate webpack bundle
   from the pages/API bundle, and prom-client is not in serverExternalPackages,
   so each graph gets its own `client.register`. /api/metrics scrapes the PAGES
   graph's default registry plus the globalThis-pinned instrumentationRegistry;
   the instrumentation graph's default registry is scraped by nobody.

   The gauges were wrapped in `if (!global.heavyBulkheadGaugeInitialized)`, which
   pairs a PROCESS-scoped flag with a GRAPH-scoped registry. That is worse than
   no guard: instrumentation.node.ts -> eventloop-longtask -> prom/client runs at
   pod start, claims the flag, registers into its own unscraped registry, and the
   pages graph then takes the early-out and registers nothing. Now registered via
   registerInstrumentationMetric, which dedupes against the same shared registry
   it writes to, so the two scopes agree.

2. STATE. The gauges are collect()-based over `bulkheadSnapshot()`, and
   request-bulkhead's slot/reject Maps were module-local — so whichever graph won
   registration read its own permanently empty maps. Pinned on globalThis, the
   same mechanism instrumentationRegistry and __civitaiRedisMetrics already use.
   Admission control was always correct in the graph serving requests; this makes
   the cap genuinely per-POD rather than per-pod-per-graph, which is what it was
   always documented to mean.

The nine `node_postgres_*` pool gauges carried defect 1 via `pgGaugeInitialized`
and were measured at 0 series too. Fixed in the same commit: same bug, same file.

Measured on production before the fix, which is what isolates the flag as the
variable rather than the registry or the graph:
  civitai_app_heavy_bulkhead_{active,rejects}   0 series  (default reg + flag)
  node_postgres_* (9 gauges)                    0 series  (default reg + flag)
  civitai_app_image_ingestion_backlog         640 series  (shared registry)
  images_search_* (same request path)         181 series  (default reg, no flag)

Tests: 8 new cases in src/server/__tests__/prom-cross-graph-registration.test.ts,
using vi.resetModules() between dynamic imports to reproduce the two-graph split
(fresh module instance, intact globalThis — the exact asymmetry the bug lived in).
Matrix: 8/8 RED at origin/main, 8/8 green here. With ONLY defect 1 fixed, 3 pass
and the two seam cases still fail — the gauges register, get scraped, and render
nothing — which is what proves the second half is load-bearing rather than tidy-up.

Also corrects a comment in @civitai/telemetry that cited these guards as the
working precedent for pinning on globalThis. They pinned the flag, not the thing
it guarded, and every metric behind them was dead.

The Grafana panel's `or vector(0)`, which turned this absence into a drawn zero
line, is removed separately in the infra repo.

Refs #2428. Refs clawgate task 299.

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

* style: prettier — formatting only, no behaviour change

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

* fix(metrics): audit round — guard the scrape, kill 5 surviving mutants, enrol the pin in the gate

Adversarial audit findings. No deploy-blocker; these are the three 🟡s worth closing
before merge, plus the fixture defect that let them hide.

1. GUARD THE SCRAPE (src/pages/api/metrics.ts).
   `Registry.metrics()` is a Promise.all over every metric's get(), so ONE collect()
   that throws rejects the whole call — and `Gauge.set` throws on a non-number. This
   PR moved 11 new collect() bodies into that awaited path, six of them reading pool
   counters unguarded, so a single bad read would 500 the ENTIRE scrape: default
   metrics, every instrumentation metric and the Prisma series with it, precisely when
   you most need to see the pod. Both registries now degrade to losing one block, with
   a seeded `registry_scrape_failures_total{registry}` so the loss is observable rather
   than silent. The file already wrapped a strictly smaller risk the same way.

2. THE TESTS DID NOT PIN THE PG GAUGES — proven, not assumed.
   The stub gave every pool `{totalCount: 0, idleCount: 0, waitingCount: 0}`. Identical,
   default values across four pools make every transposition render byte-identical
   output, and the nine gauges are (name, help, reader) triples where a wrong pool or
   wrong counter is a one-token slip no type checker can catch. Five mutants SURVIVED a
   fully green suite. Fixture values are now pairwise distinct (11/12/13, 21/22/23,
   31/32/33, 41/42/43) and two cases assert rendered VALUES, including an absent-pool
   case that reaches the null-guard branch — which no all-pools-present fixture can.

   Battery re-run after every change in this commit, baseline green either side:
     M1 labelled write reads pgDbRead ........ KILLED (was SURVIVED)
     M2 read_idle reads .totalCount .......... KILLED (was SURVIVED)
     M3 write_waiting reads pgDbRead ......... KILLED (was SURVIVED)
     M4 drop the labelled null-guard ......... KILLED (was SURVIVED)
     M5 swap read_long/datapacket labels ..... KILLED (was SURVIVED)
     PC1 pin `??=` weakened to `=` ........... KILLED  (the original bug, one character)
     PC2 un-pin entirely ..................... KILLED  (10/10 fail)
     PC3 rejects gauge reports active ........ KILLED
   The harness itself needed two fixes first: the vitest summary is ANSI-prefixed so the
   grep matched nothing and scored every mutant SURVIVED including the positive control,
   and the copied tree's .envrc was un-allowed so nothing ran at all. Both produced a
   confident, uniform, meaningless result — hence the mutator now refuses to report when
   the file hash did not move or no summary line was found.

3. ENROL THE PIN IN THE GATE THAT EXISTS FOR THIS (scripts/server-graph-watchlist.mjs).
   The repo has a build gate that fails when a module needing process-wide identity
   loses its globalThis pin. This PR added such a pin and enrolled it nowhere, so a
   future refactor reintroduces the defect silently — which is how it happened.

   Both maps now live under ONE key, `__civitaiBulkheadState`. Two keys would mean two
   watchlist entries naming the same module, which the gate cannot express: its fixture
   emits one chunk per entry carrying only that entry's key, so each entry fails on the
   other's chunk. Measured — it red-lined the gate's own positive control. The pin also
   moves to the repo's canonical `??=` form, which the gate's test asserts against
   comment-stripped source so a key surviving only in prose cannot pass for a binding.

Verified: typecheck 0 errors (103s); prettier clean on all 5 files; 109 files / 1895
tests green across src/server/__tests__, scripts/__tests__ and src/tests/api/v1;
test:lint-rules 243/243.

Refs clawgate task 299.

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

* revert(metrics): keep the pg pool gauges OFF the shared registry — moving them made them WRONG

Reverts the pg-gauge half of this PR. The bulkhead fix is untouched.

Two independent agents flagged the same unverified risk, so I measured it, and it is
real. A collect()-based metric needs BOTH halves shared: the registry it registers
into AND the state its closure reads. The bulkhead has both — request-bulkhead.ts
pins its maps on globalThis. The pg gauges have only the first: src/server/db/pgDb.ts
globalThis-pins the pools ONLY in its `!isProd` branch (pgDb.ts:26-42), so in
production every emitted copy of that module builds its own pools. The graph that
wins registration is the instrumentation graph, whose pools serve nothing but the
ingestion-backlog query in prom/client.ts.

Measured on a preview running exactly the reverted change:

  metric                              idle    under 30 concurrent /api/v1/images
  node_postgres_read_total_count      1       1
  node_postgres_read_waiting_count    0       0
  node_postgres_write_total_count     0       0
  node_postgres_pool_total_count      1 / 0 / 0 / 0   (unchanged under load)

Frozen, plausible-looking and wrong. That is strictly WORSE than the honest absence
they have today: an absent metric prompts a question, a confident 0 ends one — the
same false-all-clear class as the `or vector(0)` this PR removes from the reject
panel, which is the defect the whole change exists to fix. Shipping it would have
re-enacted the bug while claiming to fix it.

So they go back to exactly their `origin/main` form, under `global.pgGaugeInitialized`,
with a long note at the site saying what was tried, what was measured, and why the
next person must not repeat it. The test that asserted they reach the shared registry
is INVERTED: it now pins that they stay off it, so a future well-meant move has to
change the test and read the note.

Making them real means pinning the pools in pgDb.ts for prod. That changes production
DB connection topology (today: one pool set per emitted graph, which is also worth
someone's attention on its own) and is its own change with its own blast radius, not
a rider on a metrics fix.

Consequence for the previous commit: its mutation battery covered the pg refactor that
no longer exists. The five mutants it killed are moot; the three that matter — the
`??=` pin weakened to `=`, the pin removed, and the rejects gauge reporting active —
are all on the bulkhead half and still die. Re-verified after this revert:
typecheck 0 errors, prettier clean, 109 files / 1893 tests green.

Refs clawgate task 299.

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

* fix(metrics): delta re-audit round — the pg test was VACUOUS, and the new guard had no test

A blind delta re-audit of the previous two commits returned two blockers, both of
them the half-revert signature: the test offered as the pin for the reverted pg
change asserted nothing, and a comment claimed coverage that no assertion provided.

🔴 THE INVERTED PG TEST WAS VACUOUS — it asserted about code that never ran.
`clearCrossGraphState` reset the bulkhead pin and the shared registry but NOT
`global.pgGaugeInitialized`. The first test to import prom/client set that flag, so
every later import took the early-out and the pg block never executed. Two mutants
proved it, both SURVIVING a fully green run: adding `registers:
[instrumentationRegistry]` to a pg gauge (the exact "future well-meant move" the
comment said the test would catch), and disabling the whole pg block with `if
(false)`. The test could not tell "deliberately on the default registry" from
"deleted".

Resetting the flag alone does not fix it — the re-audit found that too. `prom-client`
is EXTERNALIZED, so `vi.resetModules()` hands every "graph" the same module instance
and the same default `client.register`; re-running the pg block then throws "already
registered" and reds four tests on unmutated code. Both must be reset together, so
`clearCrossGraphState` now also clears the default registry. That is worth knowing on
its own: this suite's two "graphs" share one default registry, so no test may infer
graph identity from it. Written into the comment.

The test now pins BOTH halves — present on the default registry (proving the block
RAN) and absent from the shared one (the invariant). Without the first half the
second is satisfied by a deleted block.

🔴 A NINE-LINE COMMENT CLAIMED TRANSPOSITION COVERAGE THAT DID NOT EXIST.
The revert deleted the value assertions but left the pairwise-distinct fixture and a
comment reading "EVERY ONE OF THESE TWELVE NUMBERS IS DISTINCT, AND THAT IS THE WHOLE
POINT… Keep them distinct if you touch this." Setting all 24 numbers to 0 — the exact
configuration the comment warns against — SURVIVED. Value assertions are restored
against the default registry, so the apparatus and its comment are true again.

Battery re-run, baseline green either side:
  A-M4 pg gauge moved to instrumentationRegistry ... KILLED (was SURVIVED)
  A-M3 whole pg block disabled `if (false)` ........ KILLED (was SURVIVED)
  A-M1 all 24 fixture numbers -> 0 ................. KILLED (was SURVIVED)
  T1  write_total reads pgDbRead ................... KILLED
  PC  pin `??=` weakened to `=` .................... KILLED

🟡 THE SCRAPE GUARD HAD NO TEST — added in response to an audit finding, and nobody
had watched it go red. New suite `metrics-endpoint-registry-failure.test.ts`, 5 cases,
mirroring the sibling Prisma-failure harness. It plants a gauge whose collect() really
throws rather than stubbing `metrics()`, so it walks the production rejection path.
Verified red: dropping the guard and awaiting the registries directly fails exactly
the 3 🔴 cases (5 passed -> 3 failed | 2 passed); the positive control and the seed
test correctly stay green, since neither depends on the guard.

🟡 THE FAILURE COUNTER WAS DROPPED BY THE FAILURE IT COUNTS. It lived on
`client.register`, so a default-registry rejection removed that whole block — counter
included — leaving a permanently absent series exactly where a rising one is expected.
Moved to `instrumentationRegistry`; one test now asserts a default-registry failure is
reported in the SAME response that lost the block. Residual blind spot documented, not
hidden: an instrumentation-registry failure still loses it, but that also removes every
instrumentation metric at once.

🟡 THE RECOVERY PATH COULD THROW OUT OF THE GUARD. `registryScrapeFailures.inc` inside
the catch was unguarded while the module-scope seed of the same counter was wrapped for
exactly that reason. Unchecked `as` cast + a labelset collision would have thrown out of
the block whose job is to prevent a 500.

🟡 A HEADING FALSIFIED BY THE REVERT: "WHY EVERY GAUGE BELOW USES
registerInstrumentationMetric" sat above nine gauges that use the flag. Corrected, along
with a cross-repo "this PR" reference a civitai reader cannot follow, and the fixture
comment's description of a code shape the revert removed.

Verified: typecheck 0 errors; prettier clean on 6 files; 110 files / 1900 tests green;
test:lint-rules 243/243.

Refs clawgate task 299.

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

* style: close the four cosmetic findings from the delta re-audit

- Restore the blank line before the Buzz-escrow JSDoc that the pg revert swallowed,
  so the reverted block is now blank-line-identical to origin/main (verified: zero
  whitespace-only hunks remain in the diff for that file).
- Restore the paragraph break the single-key note ran into, so two separate
  arguments stop reading as one run-on block.
- "Destructured once" described two property reads; say what the code does.
- A backtick standing in for an apostrophe inside a single-quoted string.

No behaviour change. Verified: prettier clean, 66 files / 1208 tests green.

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

* fix(metrics): round-3 audit — unbreak two unrelated suites, and a test that passed on residue

Round 3 found a REGRESSION I shipped in round 2, reproduced independently by CI.

🔴 MOVING THE COUNTER LOOKUP TO MODULE SCOPE BROKE TWO UNRELATED SUITES.
`src/__tests__/setup.ts` wholesale-mocks `~/server/prom/client` and omitted
`instrumentationRegistry`. While that lookup sat inside the handler body nothing
noticed; hoisted to module scope it throws during module EVALUATION, so any suite
that merely IMPORTS the metrics page dies —
`No "instrumentationRegistry" export is defined on the "~/server/prom/client" mock`.

Measured per-ref, each in its own run: `f7a6ccc85f` 5 passed · `497876f896`
3 failed | 2 passed · `origin/main` 5 passed. Casualties were
metrics-endpoint-seeds-substitutions and metrics-endpoint-seeds-bitdex-feed-serve —
both import-only suites, which is exactly why every metrics-SPECIFIC suite stayed
green: they each mock the module themselves. The blocking `Unit tests` job was red
with the same error at the same line.

Fixed in one place: the mock now supplies a REAL throwaway `Registry`, not a stub,
because consumers call `.metrics()`, `.getSingleMetric()` and register into it — a
vi.fn() stub would let a suite assert against a registry that never held anything.

🔴 MY GETTER-BASED pgDb MOCK RED-LINED A REPO-WIDE GATE.
`pgDbMock.parity` scans `vi.mock` factories for the literal `name:` spelling, so
`get pgDbRead()` — which does supply the export — read as "missing: pgDbRead,
pgDbReadLong, pgDbWrite". Introduced in round 1 and missed by two audits. The
factories are back to literal properties; the absent-pool case uses `vi.doMock` with
its own COMPLETE factory rather than mutating a getter. I did not loosen the gate to
fit my code.

🟡 THE SEEDING TEST PASSED ON RESIDUE. It ran last, and by then two earlier cases had
incremented both labels on a module imported once per file — so deleting both
`inc(…, 0)` calls SURVIVED the full-file run and failed only in isolation. The
seeded-at-0 assertion moves into the POSITIVE CONTROL, which runs first, where it is
the only place it can mean anything. The trailing case now asserts something the first
cannot: both label series are still rendered while one registry is failing.

🟡 THE FIXTURE HAD A LIVE HALF AND A DEAD HALF. `beforeEach` overwrote `pools` from
`POOL_DEFAULTS`, so the twelve numbers in the `pools` initializer were never read —
zeroing them SURVIVED while zeroing the copy was caught, and the nine-line "keep these
distinct" warning sat above the dead half. One declaration now, mutated in place.

🟢 Also: the absent-pool case covered 2 of the 3 labelled gauges (`?? 0` -> `?? -1` on
the idle guard SURVIVED); the new suite un-mocked prom/client without mocking pgDb, so
a real `pgDbRead.connect()` fired once per run; and a docblock named the wrong tests.

Battery re-run over BOTH suites, baseline 15/15 green either side:
  M1 scrape guard removed .............. KILLED (3)
  M2 seeding inc(...,0) removed ........ KILLED (1)   <- SURVIVED before this round
  M3 counter back on client.register ... KILLED (3)
  M4 POOL_VALUES all zeroed ............ KILLED (2)   <- SURVIVED before this round
  M5 pg gauge -> instrumentationRegistry KILLED (2)
  M6 whole pg block if(false) .......... KILLED (3)
  M7 idle labelled guard ?? 0 -> ?? -1 . KILLED (1)   <- SURVIVED before this round
  PC pin `??=` weakened to `=` ......... KILLED (5)

Verified: typecheck 0 errors; prettier clean on 7 files; 123 files / 2034 tests green;
test:lint-rules 243/243.

Refs clawgate task 299.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 14:58:07 -05:00
briant 6c9904b389 fix(tests): unbreak the unit suite on Windows — 16 failures across 7 files, and two of them could never pass
Measured on a Windows checkout: 7 files / 16 tests fail identically before and after any local
change, so every Windows developer has to hold "these are expected" in their head to read a suite
result. 868kubhjr tracked four of them; the set had grown to seven.

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

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

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

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

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

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

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

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

Closes 868kubhjr

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verification
- root typecheck on the merged head: 0 errors.
- positive control: planted error in scripts/oneoffs/ -> caught (1 error,
  exit 2), so the remaining coverage is real and not a vacuous glob.
- negative control: a NEW deliberately-broken test file dropped into
  scripts/__tests__/ -> 0 root errors. The tripwire is gone.
  The gate still reports that same file, so it is excluded, not invisible.
- ratchet both ways against real tsc: gen_seed 2->3 BLOCKS (exit 1, names the
  delta); untouched tree PASSES (exit 0).
- new invariant test, watched fail: a baseline entry for a root-COVERED file is
  rejected by name, so this baseline cannot be used to park a failure that
  `pnpm typecheck` is actually red on. Plus a negative control proving its
  predicate can reject.
- drift-control mutant re-run against the new glob-shaped quarantine: killed by
  3 tests including the end-to-end one.
- 190 tests pass across both gate suites; prettier and eslint clean.
2026-08-20 11:38:38 -05:00
briant 15c1408d3d fix(db-schema): the enum generator emitted unformatted output, so db:check-generated failed on a clean checkout
`pnpm run db:check-generated` has been failing for anyone who ran it, with no
change of their own. `scripts/prisma-enum-generator.mjs` wrote each type alias on
one line and never formatted the result, while the committed `enums.ts` is
prettier-wrapped at the repo's printWidth of 100. Generator output could
therefore never equal the committed file, and `postinstall` runs `db:generate` —
so every `pnpm install` left the file dirty and every run of the check gate went
red. CI does not run that gate, which is why this survived; the only person who
hits it is someone following "Before Committing" step 5.

The generator now formats with the resolved prettier config before writing. That
is scoped to the one file this generator owns rather than a prettier pass over
`packages/civitai-db-schema/src`, because the neighbouring generated files are
NOT prettier-clean — `models.ts` is emitted with double quotes against a
`singleQuote: true` config and passes the check exactly as it is. Formatting the
directory would reformat it wholesale for no reason.

With the generator fixed, the ~90 lines of churn collapse to one real staleness:
`CollectionItemRejectionReason` was committed as raw generator output in
`1ad8ae3443` while every other alias in the file was wrapped. That line is now
regenerated, and `db:check-generated` passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 10:30:34 -06:00
Zachary Lowden b8f84204d7 fix(auth): fix the two type errors gating apps/auth, and bring it into the CI typecheck gate (#4188)
apps/auth was the last app excluded from `scripts/ci/typecheck-apps.mjs` (#4156),
on two pre-existing errors. Both are fixed at the root, not silenced, and the
exclusion is gone — the gate now covers all 7 apps.

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

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

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

Also neutralises the "six apps" counts in the script header and workflow comment,
which this change would otherwise make wrong, and applies prettier to two
pre-existing unformatted lines in the touched files.
2026-08-20 11:07:40 -05:00
Zachary Lowden 1891ce76aa fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon (#4181)
* fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon

The variable was read where the client decides what to CONNECT to and ignored
where the daemon decides what to LISTEN on. `DEV_DAEMON_PORT=9555 cli.mjs status`
therefore pointed the CLI at :9555, spawned a daemon that bound :9444, and could
not reach it — the daemon has always accepted `--port`, the spawn simply passed
no arguments and the daemon read no environment.

Four files each decided the port for themselves and two of them were wrong:
cli.mjs and scripts/test-unit-run.mjs read the variable, console.mjs hardcoded
9444, and daemon.mjs saw only argv. That is not a bug any one of them contains —
it is a bug in the set, so the number now lives in exactly one module and all
four resolve it there. A daemon a client spawns inherits that client's
environment, so both ends read the same variable through the same function; an
explicit `--port` still wins for a daemon started by hand.

resolveDaemonPort also refuses a value that is not a port rather than handing
back parseInt's NaN, which used to reach a URL as `http://127.0.0.1:NaN` and
fail a long way from its cause.

Verified by reproducing the reported path, not by reading the code. On
pre-change code with DEV_DAEMON_PORT=19461 the daemon logged `Daemon port: 9444`,
emitted no ready line, and nothing ever listened on 19461. After the change it
reports and binds 19461, and the pid it reports is the child that was spawned —
a port that answers proves a listener, not THIS listener.

Six isolated mutants, each killed by its own named assertion:
  daemon ignores the environment  -> both behavioural cases
  daemon ignores --port           -> the --port precedence case
  resolver stops validating       -> the rejects-a-non-port case
  console.mjs re-hardcodes 9444   -> both halves of the ledger
  test-unit-run.mjs re-hardcodes  -> both halves of the ledger
  the default drifts to 9445      -> the default case
console.mjs is a TUI with no end-to-end case here, which is why the ledger
exists: it fails when the set of files deciding the port grows or shrinks.

Closes ClickUp 868kuaa4e.

* fix(dev-server): the audit round — the test deleted the pid file it was written to protect, and the ledger could not see the set grow

🔴 The blocking one. `POST /shutdown` replies 200 and only THEN schedules
`unlinkSync(pidFile); process.exit(0)` on a 100 ms timer, so the helper that
saved and restored the developer's `daemon.pid` restored it ~100 ms before the
daemon deleted it. `scripts/**/*.test.ts` is in the `unit` project, so every
`pnpm test:unit:run` removed `.claude/skills/dev-server/daemon.pid` — and the
file is only written at daemon START, so it did not come back. That breaks the
recipe SKILL.md itself gives for checking the daemon's interpreter, and makes
`cli.mjs shutdown`'s cleanup a no-op. `shutdown()` now waits for the port to
stop answering before returning.

The ledger scanned a FIXED list of four readers, so it could not detect the set
GROWING — which the PR description claimed it did. A fifth hardcoded 9444
already existed while the test was green: `.claude/hooks/check-writable.mjs`
baked it into the dev-port regex, so `DEV_DAEMON_PORT=9555` silently switched
that nudge off for the daemon, the one long-lived server it most exists for. The
ledger now walks the tree, so a file that does not exist yet is covered, and the
hook reads the port from the module. Verified with a negative control: with
DEV_DAEMON_PORT=9555, `curl :9555` nudges and `curl :7777` stays silent.

The ledger's assertions were also spelled rather than structural.
`includes('daemon-port.mjs')` is satisfied by a COMMENT — and this file's own
prose names the module in several — and `includes('resolveDaemonPort(')` is
satisfied by any call whatever is done with the result, so
`resolveDaemonPort() + 1` passed all of it while console.mjs has no behavioural
test. Both clients now take a whole URL from `resolveDaemonUrl()` and do no
arithmetic on a port; the assertions pin an import line and that call.

Three more the audit found:

- `scripts/test-unit-run.mjs` lost its "never leave a caller unable to run
  tests" guarantee. Resolution moved inside `runQueued` but OUTSIDE its try, and
  `runQueued` is un-awaited, so a malformed DEV_DAEMON_PORT became an unhandled
  rejection and no tests ran. Measured against base: base printed "running
  directly", HEAD died. Restored, with the outer `.catch()` as the general net.
- `--port` still open-coded `parseInt`, so the HIGHER-precedence input was the
  unvalidated one — `--port abc` gave ERR_SOCKET_BAD_PORT, which via cli.mjs is
  invisible (detached, stdio ignored) and reads only as "Failed to start
  daemon". Both inputs now go through `parsePort`, which names the input it is
  complaining about.
- Resolving at module load made a bad DEV_DAEMON_PORT throw on merely IMPORTING
  daemon.mjs, which would have broken collection of the port-reservation suite.
  Resolution moved into `parseArgs`.

Nits: SKILL.md said `cli.mjs:69`, the line is 70 — my own earlier check of that
number had gone stale under a later edit. Out-of-range is now a different
message from unparseable ('0' is a number, just not a port). The 9444 scan uses
a word boundary so 19444 is not a false positive.

Seven mutants, each applied alone, tree restored and cmp-verified between:
  shutdown() stops waiting            -> the pid-file case
  a NEW file hardcodes 9444           -> the ledger (the growth case)
  the hook re-hardcodes 9444          -> the ledger
  console.mjs does resolvePort() + 1  -> the client-address case
  the targeted fallback is removed    -> the degrade-to-direct case
  --port back to parseInt             -> the argv validation case
  port resolved at module load        -> the import-safety case

🔴 One of them SURVIVED the first run and the fix is the point: the fallback
case asserted the generic phrase "running directly", which BOTH guards on that
path print, so deleting the targeted guard left the suite green — the mutant
died to the other guard. The assertion now names the specific message.

scripts/__tests__ 324 passed. typecheck 0 errors. Hook selftest green.

* fix(dev-server): round 3 — the last round's hook fix un-guarded the default port, and its catch would have double-run the suite

Both regressions were introduced by the previous commit, not pre-existing, and a
delta re-audit of 7dd65a9d62..85cb922567 found them.

🔴-in-effect: `DEV_DAEMON_PORT` REPLACED the guarded port instead of adding to
it, so setting it un-guarded the shared daemon. Reproduced through the hook's
real stdin contract: with the override set, `curl http://localhost:9444/...`
went from ask to allowed, and the hook's own selftest went from `all green` to
`1 FAILURES`. SKILL.md documents the override as standing a daemon BESIDE the
shared one — both are live, so both need guarding. `daemonPortsGuarded()` now
returns the union, and the selftest is green both plain and under the override.

The other one is subtler and worse in effect. The outer `.catch()` I added
wrapped the whole `runQueued` lifecycle, not just queue acquisition — so a
socket dropped mid-poll abandoned a run the daemon had ALREADY ACCEPTED and
started a second, unqueued full suite beside it. That defeats the serialisation
this script exists for, and the file had already decided the opposite for the
status-code form of the same condition (`Lost contact with the test queue` ->
exit 2). It now tracks whether the run was accepted: before acceptance it
degrades to a direct run, after acceptance it exits 2. `err?.message`, because a
non-Error throw printed `(undefined)` and a null throw raised inside the handler
— back to the unhandled rejection the catch exists to prevent.

Also from the re-audit:

- The port scrape in the hook is a dependency on one line's formatting in
  another file and nothing tested it. A vitest case now drives the real
  `unboundedDevRequest`, with a negative control on a port the skill does not
  use. Breaking the scrape regex now fails two tests; it used to fail none.
- `console.mjs` had no behavioural coverage, and every structural assertion was
  walkable by resolving the URL correctly and then drifting it. It now runs for
  real against a stub daemon on an ephemeral port — via `--tail`, since the
  dashboard refuses to start without a TTY and exits before contacting anything.
- `--base-dev-port` was still `parseInt`, which made "argv gets the same
  validation the environment gets" false about half of argv.
- The port range's upper bound was untested: fixtures were 0 and 70000, so
  65535 could drift to 65536 unnoticed. Now pinned ON the boundary.
- SKILL.md's `console.mjs:88` — the previous commit fixed the cli.mjs citation
  and moved console.mjs's line to 89 in the same change. Now 89.
- The module header claimed the scan covers "the whole skill ... anywhere". It
  covers three roots and four extensions; prose and test files are deliberately
  out of scope. Said so instead.

🔴 The ledger caught ME during this round: a comment I wrote explaining the
un-guarding bug spelled the port, and `spells the port in exactly one source
file` went red. That is the guard working on its author — reworded, not
exempted.

Round-3 battery, each mutant alone, tree cmp-verified between:
  outer .catch() deleted            -> the accepted-run case
  accepted-check removed            -> the accepted-run case
  hook override replaces default    -> the union case
  hook scrape regex broken          -> both hook cases
  upper bound 65535 -> 65536        -> the boundary case
  console.mjs drifts the URL        -> the behavioural case + the ledger
  --base-dev-port back to parseInt  -> the base-dev-port case
All killed. One deliberate survivor: a copy OUTSIDE the three scanned roots,
which is the documented scope and is now stated in the header rather than
overclaimed.

330 passed. typecheck 0 errors. Hook selftest green plain and under override.

* style: prettier the added test file — the CI gate checks ADDED files and I never ran it

`ESLint + Prettier (changed files)` went red on the round-3 push for one reason:
`scripts/__tests__/dev-server-daemon-port.test.ts` was not prettier-formatted.
Four lines, all wrapping.

Reproduced the gate's exact scope locally rather than guessing at it — it runs
`prettier --list-different` over ADDED files only, which is why the four `.mjs`
files this branch MODIFIES were never checked. Those four are unformatted, and
the control says that is not mine: at the merge base `bc74ba06ea`, all four are
already unformatted. Reformatting them would rewrite files this change does not
own, which is the breadth rule in CLAUDE.md, so they are left alone.

Local prettier is 2.8.8, the same version CI installs, so the local format is
the one the gate will read.

21 tests still pass.

* fix(dev-server): round-4 tidy — the test that proves the console bug was itself destroying the pid file on its red path

Follow-ups from the round-3 delta re-audit, which found no 🔴 and confirmed
round 3 did not reintroduce a regression. These are the 🟡/🟢 it did find.

The console test was not wrapped in `withPidFilePreserved`, and the exposure is
specifically on the FAILING path: on green the stub answers `/`, so the console
never starts a daemon. Under the mutant the test exists to catch, it cannot
reach the stub, falls through to its own `startDaemon`, and overwrites
`daemon.pid` with a dead pid — the test that proves the bug also damaged the
thing the rest of the file is careful about. Controlled both ways: with the
drift mutant applied the test now goes red AND the pid file's md5 is unchanged.

The port scrape in the hook is anchored on `export const` again. Dropping the
anchor was justified as surviving a reformat, and that reasoning was wrong — a
reformat does not rewrite `export const NAME =`. What it actually bought was
letting the FIRST match anywhere in the file win, comments included: a
`// historical: DEFAULT_DAEMON_PORT = 9999` line above the declaration made the
hook guard 9999 and stop guarding the real port. Controlled: that same line now
yields 9444.

Three comments that no longer described their code. `test-unit-run.mjs` had the
previous round's two lines left verbatim above the block that replaced them, so
the same sentence appeared twice. The hook still said the port "is overridable
via DEV_DAEMON_PORT" — the exact semantics round 3 removed, since the set is now
additive — and its "resolved lazily" note sat above `daemonPortsGuarded`, which
is neither lazy nor cached; the laziness is in `devPorts`.

Numeric line references (`:123`, `:128`) replaced with named ones. Both had
already rotted by three lines. This is the same class as the SKILL.md citation
fixed last round, and a line number in a comment will rot again — a quoted
message will not.

Named the residual hole rather than implying it away: the daemon enqueues INSIDE
the response write, so the slot is taken before the client can observe it. Lose
the response between those points and `accepted` is still false while the run is
queued. That window needs an idempotency key on the enqueue, not a flag here.

Not changed, deliberately. `.claude/**/*.mjs` fails `prettier --check` at the
merge base as well as here, so it is pre-existing and reformatting it would
rewrite files this change does not own. And `scripts/__tests__/*.ts` is
typechecked by nothing — `tsconfig.json`'s `include` omits it — so this PR's
"typecheck 0 errors" says nothing about the new test file's annotations. Both
are stated rather than quietly folded in.

330 passed. Hook selftest green plain and under the override. Test file
prettier-clean.
2026-08-20 01:23:47 -05:00
Zachary Lowden 23cecb57c0 ci: run per-app typecheck scripts in CI (#4156)
* ci: run per-app typecheck scripts in CI

The root tsconfig.json include is:

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

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

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

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

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

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

Ticket: 868kt8pfu

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

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

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

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

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

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

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

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

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

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

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

* style: prettier the two new files

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

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

---------

Co-authored-by: DevPod Agent <agent@devpod.local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:18:59 -05:00
Zachary Lowden 481582d969 flake: own the dev toolchain, guard the pins, one command to a running app (#4107)
* feat(flake): make the Nix flake own the dev toolchain, and add one command to start

The flake shipped nodejs_22 while package.json declares engines.node
">=24.0.0 <25", .nvmrc pins 24.19.0 and the Dockerfile builds production on
node:24.19.0-alpine3.24. A NixOS developer was running a major the repo does
not support, and nothing said so.

Toolchain:
- node and pnpm are now DERIVED from .nvmrc and package.json's packageManager
  rather than named twice. .nvmrc is treated as the authority because it is what
  every workflow's actions/setup-node reads and what the Dockerfile tracks.
- flake.lock moved 2026-04-23 -> 2026-08-18 (117 days). At that rev nodejs_24 is
  exactly 24.19.0, which is what made agreeing with .nvmrc possible at all.
- pnpm now comes from `pnpm_10`, not the unversioned `pkgs.pnpm`. At the new
  rev the unversioned attribute resolves to 11.21.0 -- a major bump that
  rewrites pnpm-lock.yaml -- so this bump would otherwise have shipped pnpm 11
  to every dev shell silently.
- postgresql_16 -> postgresql_17, matching the primary `db` container. The
  postgres/redis/clickhouse entries are CLIENTS for the compose-hosted servers;
  that is now stated in the file instead of left to be guessed.
- npm_config_manage_package_manager_versions=false. Measured: without it, pnpm
  downloads and re-execs the exact version from the packageManager field, so the
  flake's pnpm pin was being defeated at runtime (`pnpm --version` returns
  10.28.1 with the var unset, 10.34.5 with it set).

Guards (`nix flake check`, 4 checks):
- toolchain-pins: the flake's node must satisfy engines.node and equal .nvmrc,
  and its pnpm must share a major with packageManager. Deliberately does NOT
  re-check the .nvmrc/Dockerfile/engines triangle -- node-version-consistency.test.ts
  already owns that, and a predicate open-coded twice starts disagreeing.
- prisma-pin: re-derives the resolved @prisma/client AND its engine commit from
  pnpm-lock.yaml and compares them to the values flake.nix hardcodes. These were
  correct but unguarded: package.json declares `^6.3.0`, a caret range, so a
  routine lockfile refresh moves the client while the flake's engines stay put,
  and the failure surfaces at runtime in every dev shell.
- pin-guards-selftest: breaks each pin on purpose and requires the guard that
  owns it to fire while the others stay silent.
- dev-scripts: builds the shell entrypoints, which is what runs their shellcheck.
  (`nix flake check` builds checks.* but only EVALUATES packages.*, measured.)

Entrypoints:
- `nix run .#dev` - docker preflight, submodule, .env.development, compose up,
  wait for postgres, pnpm install, then `next dev`. Every step idempotent and
  non-destructive; migrations and seeding stay opt-in.
- `nix run .#dev-server` - runs the dev-server CLI on the flake's node. The
  daemon re-execs itself with process.execPath, so whichever node starts the CLI
  is the node it runs on until it is restarted.
- `nix run .#doctor` - the same pin checks against the working tree.

Compose project is pinned to `civitai` so every worktree shares the one local
stack instead of each spawning a duplicate that fails on the port binds.

* fix(flake): give `nix run` the same env as the dev shell, not just the shell

Found by running the bootstrap on a genuinely clean worktree rather than
reasoning about it. `mkShell`'s `env` applies to `nix develop` only, so both
values it carried were absent from `nix run .#dev`:

- `pnpm install`'s postinstall runs `prisma generate`. Without
  PRISMA_QUERY_ENGINE_LIBRARY et al, prisma tried to fetch an engine for
  platform `linux-nixos` and the bootstrap died on
  `404 ... /linux-nixos/libquery_engine.so.node.sha256`.
- pnpm re-execed itself as 10.28.1 from the packageManager field even though
  PATH pointed at the flake's 10.34.5, so the app reported a pnpm the flake had
  not pinned.

The env is now one attrset (`devEnv`) rendered two ways: `env` for the shell and
an `export` preamble for the apps, so they cannot drift. `nix run .#dev-server`
gets it too -- the daemon runs `pnpm install` / `db:generate` on its own when it
sees the lockfile move, which would have hit the identical 404.

* docs: describe the toolchain the repo actually has, not the one it used to

Every claim below was checked against the code before rewriting, and the
measurements are quoted where they are load-bearing.

README.md
- "Node.js (version 20 or later)" -> 24.19.0, with .nvmrc named as the authority.
- `make init` was DEAD, not merely awkward: it ran `npm i`, and package.json's
  `preinstall` runs `only-allow pnpm`, which exits 1 under an npm user agent
  (measured, with the pnpm-user-agent control exiting 0). Both bootstrap paths
  the README offered went through it.
- MinIO console is on :9001, not :9000 (:9000 is the S3 API). The instructions
  sent people to the wrong port to mint the keys the next step needs.
- `git submodule update --recursive` -> `--init`; without `--init` it is a no-op
  on a fresh clone, which is precisely when it is being run.
- Data Migrations step 1 pointed at `schema.prisma`, which is gitignored and
  regenerated from `schema.full.prisma` on every `db:generate`, so edits to it
  were silently discarded.
- Adds the Nix path (`nix run .#dev`) and a real non-Nix sequence.
- engines.node is ADVISORY, stated plainly: pnpm 10.34.5 under node 26.7.0
  against ">=24.0.0 <25" prints `WARN Unsupported engine` and exits 0. An
  earlier draft of this very README claimed it refuses. It does not, and that is
  the reason the drift survived so long.

Makefile
- `npm i` -> `pnpm install` (see above). `npm-install` kept as an alias.
- `gen-prisma` ran a bare `prisma generate`, which reads the gitignored slim
  schema that does not exist yet on a fresh clone; now `pnpm run db:generate`,
  which generates it first.
- `dev` ran bare `cross-env`/`next`, requiring the caller to put
  node_modules/.bin on PATH by hand; now via `pnpm exec`.
- `docker-compose` (EOL v1) -> `docker compose`.
- COMPOSE_PROJECT_NAME pinned to `civitai`. Reproduced first: `make start` in a
  worktree died with `Bind for :::15434 failed: port is already allocated`
  because compose named the project after the directory.

.envrc.example (new, tracked) + .gitignore
- `.env*` matched `.envrc` too, so nothing tracked in the repo mentioned the
  flake at all -- the only reference was a line in CLAUDE.md filed under
  worktree hygiene. Placeholders only; the real .envrc stays ignored.

.claude/skills/dev-server/SKILL.md
- The skill said nothing about node. The daemon is spawned with
  `process.execPath` (cli.mjs:66, console.mjs:87) and hands its env to every
  `next dev` it supervises, so the first shell to run a CLI verb decides the
  node for everything, indefinitely. Measured on this box: daemon on 26.7.0,
  with no pnpm on PATH at all. Documents `nix run .#dev-server` and how to check.
- `npm run dev:daemon` -> `pnpm run dev:daemon`, in a repo that bans npm.

src/__tests__/node-version-consistency.test.ts
- Comment-only. It said flake.nix "is on a different major" and could not be
  aligned because the pinned nixpkgs had no Node 24 this new. Both halves are
  now false, and a comment a maintainer might act on is worth correcting.

Also: docs/pnpm-migration.md's "Node.js 18.x or later"; the generated-header
line in scripts/generate-slim-schema.js telling readers to run `npm run
db:generate`; CLAUDE.md's local-dev section (no node version, no services) and
its stale "flake's 22.22.2" figure.

NOT changed, because it could not be exercised here: the devcontainer pins
typescript-node:1-22 (Node 22, outside engines.node). Flagged in README with the
tag to use -- there is no `1-24`, the template major moved on, so `3-24`.

* docs(flake): the four postgres containers are not all one version

prisma-pit and db are postgres 17; notification-db and logical-db are 15. The
comment justifying postgresql_17 read as though they were uniform, which would
have made the next person's version decision from the wrong premise.

* docs: keep the non-Nix path the default, demote the flake to optional

The flake is used by one maintainer. Everyone else uses Docker + nvm, and that
has to stay the path a contributor lands on. The previous revision inverted
that: README's Installation section led with "With Nix (recommended...)" and
titled the standard path "Without Nix" — framing the majority workflow as the
fallback. CLAUDE.md opened "From nothing to a running app, one command:" with
`nix run .#dev`, and the dev-server skill led its fix with "Start it through the
flake and this cannot happen".

None of that made Nix *required* — verified: `.github/` is untouched by this
branch, no workflow references Nix (the apparent hits are substrings of
`eslint-unix.json` and `--format unix`), and `nix flake check` is not wired to
any CI gate. It was purely an ordering-and-emphasis problem, which is the kind
that costs a new contributor twenty minutes before they find the section that
applies to them.

Changes, all editorial:

- README: `#### Standard setup` now precedes `#### Optional: Nix flake`, and the
  Nix section opens with a blockquote saying it is not the supported default,
  that nothing requires it, and why it exists at all (NixOS has no published
  `linux-nixos` Prisma engine, so a flake is the practical way to work there).
  The signals/buzz instructions lead with `docker compose up -d` and mention
  `nix run .#dev -- --full` parenthetically.
- CLAUDE.md: the bootstrap block is now the nvm/docker sequence, labelled as the
  default path, with the flake shown after it as NixOS-only and explicitly
  flagged as something not to assume a contributor has. The dev-server step no
  longer instructs going through `nix run .#dev-server`; it states the
  requirement (a shell whose node matches `.nvmrc`) and notes the flake does that
  for you on NixOS.
- dev-server SKILL.md: the fix is now stated setup-agnostically — start the
  daemon from a shell whose node matches `.nvmrc` with pnpm on PATH, which
  `nvm use` gives you — with the flake wrapper presented as the optional NixOS
  convenience, and an explicit note that nothing in the document depends on Nix.

No behaviour, tooling or gate changes: the Makefile, flake, guards and their
tests are untouched by this commit.

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

---------

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:20:51 -05:00
Zachary Lowden 7826ba9bfb fix(dev-server): one exit-code rule for both test-queue waiters, and a log that admits when it was clipped (#4102)
`exitCodeFor` in the queue module exists to stop a signal-killed run reporting a
shell 255 -- its own comment says so. `test wait` used it; the wrapper behind
`pnpm run test:unit:run` kept a second copy of the rule, `state.exitCode || 1`,
which passes the recorded -1 straight to `process.exit`. Measured through a real
daemon: a run cancelled mid-flight exited 255, where `test wait` on the same run
exited 1. The copy is deleted rather than corrected -- the wrapper now imports
`exitCodeFor` and uses it.

The queue also kept the last 2000 lines of a run and dropped the rest in silence,
so a fragment was byte-for-byte indistinguishable from a whole log. Measured: a
child writing 5000 lines produced 1998 through `test wait` with nothing said. The
window is unchanged; the drop is now counted (`logsDropped` on the run view, and
`dropped` on the log response for callers that fetch logs directly) and both
waiters name the number at the moment the verdict lands.

`scripts/test-unit-run.mjs` now honours `DEV_DAEMON_PORT` like the CLI already
did. That is what makes the verdict testable at all: with the port hardcoded
there was no way to stand a stub daemon beside the shared one, which is why a
decision this load-bearing had no test and was free to drift.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: retrigger preview

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:07:05 -05:00
briant 394b4bc806 migrate retool endpoints to moderator endpoints 2026-08-18 11:31:14 -06:00
Zachary Lowden 8dd728eabe feat(build): assert security-relevant branches survive into the compiled output (#3983 detection) (#4068)
* feat(build): assert security-relevant branches survive into the compiled output

Release 5.1.18 shipped `resolveStoreVisibilityScopeUninstrumented` as

    async function S(e){if(await p(e))return"full"}

Two of its three `return`s were absent from the emitted server chunk, so the
function fell off the end and produced `undefined` for every non-privileged
caller. One read path defaulted that to `'full'` and served the whole App-store
catalog to anonymous callers; the other defaulted the same missing value to
`'none'` and showed the intended cohort an empty store (#3983).

The TypeScript is correct — that is the whole problem. A 75-test unit suite, an
integration suite driving the real feature-flag client, and four rounds of
review were all structurally incapable of seeing it, because every one of them
exercises the source. Nothing looked at the artefact.

This adds a gate that looks at the artefact. It reads the emitted `.js.map`
`mappings` and asserts that each watched fail-closed branch still has a
representation in the output, attributing code to source modules by source map
rather than by grepping minified JS — minified names are per-chunk, the module
is inlined into 234 chunks, the literal `"public-external"` appears ~481 times
in the build without ever being a return, and the flag name
`app-listings-public-external` contains that literal as a substring.

Every entry carries a positive control: a branch known to survive that must also
be mapped. If the control is missing the gate exits 2 ("cannot observe") instead
of exit 1 ("violation"), so a build that simply did not emit the module is never
reported as a pile of violations.

Wired into the Dockerfile beside `check-server-graph-singletons.mjs`, and for
the same reason — `.next` exists in that stage, so there is no second build.

🔴 It runs `--warn-only` for now, because the underlying bundler defect is NOT
fixed: `main` and `release` carry byte-identical source for that function and
both emit it truncated, so a hard gate would fail every production build today.
`--warn-only` does not downgrade exit 2. Removing that flag is the definition of
done for #3983.

Verification: 13 cases, each asserting its own failure branch's specific message
and exit code, driven over synthetic `.next` trees with real base64-VLQ maps
encoded by an independent implementation. Six mutations of the gate were each
killed by their own case — one of them (breaking the VLQ sign branch) initially
SURVIVED, because every fixture listed source lines in ascending order and so
never produced a negative delta; the reordered-segments case was added to close
that. The gate was also run against the real 5.1.18 server artefact, where it
correctly reports both missing branches.

* style(build): prettier the compiled-branch gate + watchlist
2026-08-18 10:58:55 -05:00
Justin Maier 98f42aab65 fix(3d-models): correct the cutover constant and drop the superseded backfill (#4054)
* fix(3d-models): correct the cutover constant and drop the superseded backfill

The constant said 2026-08-18, picked by hand before the deploy. Tracking actually
started 2026-08-17 17:21:31 UTC, so the Creator Studio would have marked the
boundary a day late. Now read off production, with a note that the day is MIXED -
backfilled before 17:21:31, live beacons after - so a consumer marks that day and
not the one after it.

Removes scripts/oneoffs/backfill-model3d-views.{ts,helpers.ts} and its test. The
backfill now lives in civitai-scripts (backfill/model3d-views.js), and the copy
here still carried the design that shipped wrong: an --until DATE cannot express
the real boundary, because tracking starts mid-day. Any date either drops that
day's pre-deploy views or double-counts its post-deploy ones - measured on the
real cutover, 1,488 views before the first beacon and 296 after. The replacement
derives the boundary as min(time) of the first tracked day, so the two sources
abut exactly and there is no value left to typo.

Leaving a second, wrong copy in this repo is worse than having none: it is the
one a reader finds by grepping.

The DDL file is kept as the record of what ran, now marked applied.

Backfill result, verified independently: 11,336 rows / 175,964 views across 476
models, 2026-06-19 to the cutover. The boundary day reads 1,650 = 162 beacons +
1,488 backfilled.

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

* refactor(3d-models): keep the cutover constant hardcoded, trim comments

The constant stays and stays hardcoded - the backfill derives its own boundary
from data and never reads it, so its only job is captioning the Studio chart,
and a hand-set date is fine for that.

Corrected to 2026-08-17, the day tracking actually began (17:21:31 UTC). The
comment now says the thing a reader needs and nothing else: the two spans are
different measurements, page loads ran ~1.5x the beacons that replaced them, so
a chart crossing that day steps down for reasons unrelated to the creator.

SQL comments cut 60 lines to 19, keeping only what a future editor would get
wrong without them - that MODIFY COLUMN replaces rather than appends, that
entityType is a sorting key so renumbering means a table rewrite, and why the
MV goes last.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 21:41:09 -06:00
Zachary Lowden 088a9d09b1 fix(release): refuse an app release from a branch behind its released history (#4046)
`release-app.mjs` derives the next version from apps/<app>/package.json on the
CURRENT branch. When that branch is behind the app's released history the tag it
computes does not continue that line, in one of two ways:

  * it already exists  -> `git tag` aborts, but only after the release commit has
    been made, leaving a junk commit on the branch;
  * it is a different line (a minor/major bump off a stale base) -> nothing
    collides, the tag becomes the HIGHEST for that app, and the Flux ImagePolicy
    selects the highest semver rather than the most recent push. That stale build
    is then what production runs.

apps/moderator is in exactly this state, measured 2026-08-17: 0.0.1 on main,
0.0.26 live, all 26 releases cut from `moderator-app-pages` — 211 commits and
+38,630 lines that never merged to main. `pnpm release:moderator` from main
collides on the existing 0.0.2 and aborts; `release:moderator:minor` computes
0.1.0, which does NOT exist, and would deploy main's stale copy to production.
One command, no collision.

So this does NOT bump moderator's version to 0.0.26. That is the obvious fix and
it is the wrong one: it removes the collision that is currently the only brake,
while leaving the app itself 211 commits stale. The real remedy is to land the
branch; the guard is what makes the trap loud until someone does.

Checked after `git pull --rebase` so the tag list is current, and before
`npm version` so a refusal leaves the tree exactly as it found it.

Coverage: 19 tests. The version arithmetic is unit-tested, and — because a guard
nothing calls is not a guard — release-app.mjs is also driven end-to-end as a
real process against a throwaway git repo with a local bare remote (offline; no
tags or commits touch this repository).

Mutation-tested, 4 mutants, each the narrowest expression that can be wrong:
  invert the behind-comparison        -> 5 tests fail
  lexicographic highest-tag compare   -> 6 tests fail
  guard computed but never acted on   -> ONLY the 2 behavioural tests fail,
                                         which is what pins reachability
  drop the unparseable-tag skip       -> exactly 1 test fails
Baseline restored green (19/19) after the battery.

NOTE: scripts/**/*.test.ts runs in the `unit` vitest project, which is
`continue-on-error: true` in lint.yml — so these tests run but cannot currently
fail CI. That is tracked separately (868kp7fdr); it does not make them useless,
it makes them a local and post-fix gate.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:15:50 -05:00
Justin Maier eb848582ef feat(3d-models): track Model3D views, with a pageViews backfill (#3997)
* feat(3d-models): track Model3D views, with a pageViews backfill

3D models had no entity view tracking: no TrackView on any /3d-models route,
no Model3D arm in the ClickHouse enums, nothing in daily_views. The history
exists in pageViews but nothing reads it.

Adds one TrackView on the detail page, reusing the existing
TrackView -> /api/internal/pulse -> Tracker.view() path. No new component, no
new endpoint, no new rollup table, no materialized view: daily_views is already
ORDER BY (entityType, entityId, createdDate), so a per-creator query over at
most 539 ids is a primary-key prefix seek. Ownership resolves from Postgres.

A view is one load of a model's public detail page. /edit and /reviews are
excluded by name rather than by path shape, because /3d-models/481/edit and
/3d-models/481/my-slug are structurally identical - an anchored id regex
matches the edit page too. Verified in ClickHouse's own RE2 against 30 days of
prod pageViews: 82,319 counted, 399 edit+reviews excluded, 21 junk excluded.

The DDL is not applied. It must run after the comics DDL (PR #3993), and it
restates all twelve enum arms because MODIFY COLUMN replaces an Enum8 rather
than appending to it.

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

* fix(3d-models): address adversarial + simplification review

Backfill correctness:
- The shared ClickHouse client sets wait_for_async_insert: 0, so insert()
  resolved before the rows were queryable and the read-back raced the flush -
  a successful run could report "boundary mismatch, found 0" and then be
  blocked from retrying by its own guard. Wait on this one insert.
- Verify the whole written range (count + sum) instead of one boundary day.
  A partial write that lost June and July but landed the last day passed
  before; a boundary day with zero rows verified nothing at all.
- Assert the cutover constant against live data: the first day `views` holds a
  Model3DView IS the cutover. Nothing tied the constant to the deploy, so a
  deploy landing a day early or late left a doubled or permanently empty day
  that nothing detected.
- Derive the id-extraction pattern and the filter from one constant. They had
  to agree or extract() returns '' and toUInt32('') throws mid-query.
- The documented `npx ts-node` command could not resolve the ~/ imports.

DDL:
- Say to apply before deploying the emitting code, and to run steps 1-3
  without a pause. Between step 2 and step 3 a Model3D row can reach an MV
  whose declared header is still nine arms, and every view type on the site
  shares that insert path.
- State what step 0 expects rather than asking the operator to confirm against
  nothing.
- Note that metadata-only holds only because every existing name->index pair
  is preserved; entityType is a sorting key, so renumbering means a rewrite.

Local dev ClickHouse (containers/clickhouse/docker-init/init.sh) declared the
nine-arm enums, so a fresh container silently dropped every Model3DView row.

Tests: delete two DETAIL_PREDICATE assertions that could not fail on a revert -
both matched substrings present in the surviving half of the predicate.

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

* fix(3d-models): harden the cutover guard against incidental tracking rows

Applying the enum DDL removed an accidental protection: before it, a dev
server or preview deploy on this branch writing a Model3DView row was
rejected by the nine-arm enum. Now it succeeds. Local dev points at the same
ClickHouse as prod, so one developer opening a 3D model page writes a real
row - and the cutover guard took a bare min(createdDate) over them, which
would move the detected cutover back to that day and silently drop every day
between it and the real one.

The cutover is now the first day clearing a floor no incidental page load
reaches (50 rows against ~2,700/day of real traffic). Days below it that fall
before the cutover are reported rather than ignored silently.

The mismatch error also prescribed a lossy repair as if it were the fix: the
mismatched day is almost always partial, since tracking starts mid-day. It now
names both options and says not to take the first by default.

Also:
- select_sequential_consistency on the readback. Prod is a two-replica
  SharedMergeTree behind a load balancer, so the insert and the read can land
  on different replicas; wait_for_async_insert guarantees the write committed,
  not that the next query sees it. A stale read reports a false failure and the
  pre-flight guard then blocks the retry.
- Restore the Number() coercion dropped in the last commit. It was what kept
  the script from depending on a client-level output format setting.
- --dry-run works again before the deploy, which the live check had broken.
- Restore an assertion on the edit/reviews exclusion. The previous commit
  claimed both deleted tests were vacuous; only one was. The other failed on a
  real revert, and it was dropped because ID_PATTERN broke its regex, not
  because it caught nothing.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 20:14:06 -06:00
Justin Maier ebe058067f fix(test-perf): stop the mock-allowlist generator reading a directory (#3976)
`globSync('src/**/*.test.{ts,tsx}')` matches directories as well as files, and
vitest browser mode names each snapshot directory after its spec — so
`__screenshots__/AppListingCard.browser.test.tsx` is a DIRECTORY that matches,
and the readFileSync below it dies with EISDIR before the generator produces
anything.

Those directories are gitignored, so the generator works on a fresh checkout and
breaks permanently for anyone who has ever run a browser test locally — which
reads as "this script is broken for me specifically" rather than as a bug.

Filter to regular files. Verified against a tree with three such directories:
EISDIR before, `canonical 218 -> 218 files (0 migrated)` after.


Claude-Session: https://claude.ai/code/session_01KvXBiAVpWyhNS85tsBMuDU

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:35:01 -06:00
Justin Maier 533640662d perf(tests): services a-m onto the canonical shared mocks (127 of 129) (#3973)
* perf(tests): move services a-m onto the canonical shared mocks

70 of 129 files in the services `__tests__` a-m slice: 62 converted by
scripts/test-perf/codemod-shared-mocks.mjs, 8 finished by hand where it
refused one specifier and took another.

The hand cases were a hand-rolled auto-vivifying Proxy standing in for
dbRead/dbWrite, and a `safeError: (e) => e` identity stub the canonical
registration replaces with the real implementation. Neither was asserted
on; both were scaffolding to keep the import graph off real infra, which
is what the canonical mocks do centrally.

Eleven of the converted files carried a hand-written REDIS_KEYS subset
that disagreed with the real table and now get the real constant. Every
dropped literal appeared exactly once, inside its own factory: no test
asserted on one and none used one to select a fixture branch, so the
swap is invisible in both directions rather than merely silent.

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

* fix(tests): repair two files the shared-mock conversion broke

`collection-collaborator.service.test.ts` collected ZERO tests, losing all
51. The codemod lifted a default out of a `vi.hoisted` block body into
module scope, but the value it referenced — `OWNER_ID` — was a local of
that block, so the file threw at import. It reported as one failed suite
with no failing tests, which is the silent class the collected-count diff
exists to catch. The file already sets the same default in `beforeEach`,
so the surviving copy sits with the other module-scope constants.

`creator-program.service.test.ts` asserted on `cp:*` cache keys it had
invented in its own factory; the real ones are `packed:caches:creator-
program:*`. Three tests went red on the swap. They now name
`REDIS_KEYS.CREATOR_PROGRAM.*` so the test cannot re-invent a key.

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

* test(redis): pin the wire values the services a-m tests re-invented

Nine files in that slice carried a hand-written REDIS_KEYS subset whose
values disagreed with the real table — `packed:caches:announcements` for
a real `packed:caches:announcement`, `kill` for
`system:blocks:emergency-kill-list`, `cp:*` for
`packed:caches:creator-program:*`. Every one was used only inside the
factory that defined it, so no assertion could see the divergence and
none of them could go red.

A key's wire value addresses live entries written by deployed code, so a
rename orphans whatever sits under the old name. Pinning them here makes
that a deliberate decision rather than something a test silently follows.

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

* chore(tests): regenerate the direct-mock allowlist after services a-m

345 -> 275 canonical files; the guard passes in both directions.

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

* perf(tests): split the single-client db aliases in services a-m

Eleven files mocked `~/server/db/client` with one local object serving
both `dbRead` and `dbWrite`, so a write could satisfy a read assertion.
Each is bound to the one client the module under test actually spells for
every call it makes, checked against the service source rather than
guessed from the local's name — `mockDbRead` in two of the block-registry
files turned out to be right for the wrong reason, and `mockDb` in
`account-deletion-images` was serving writes.

Two things the split surfaced, both kept rather than papered over:

`get-engaged-models-by-ids` carried a real in-memory fixture on
`resourceReview.findMany`, not a restated default, so binding the client
alone dropped four tests' data and they went red. The fixture is now an
explicit `mockImplementation` on the canonical node, and the four tests
are the evidence it is load-bearing.

`account-deletion-images` asserted on `'pending-restores'`, a key it had
invented; the real one is `system:pending-image-restores`. It now names
`REDIS_SYS_KEYS.SYSTEM.PENDING_IMAGE_RESTORES`.

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

* chore(tests): regenerate the allowlist after the alias split

275 -> 264 canonical files.

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

* perf(tests): migrate 18 more services a-m tests onto the canonical mocks

Seven bound automatically where a balanced behaviour check cleared them,
nine converted by hand where it refused, and two whose single aliased
local was split by routing each call site to the client the service
actually spells for it.

Three cases the run settled rather than the diff:

`minor-hash` wired two separately-declared spies in as client leaves, so
deleting the client literal left both alive, armed by `beforeEach`, and
connected to nothing — the primary-vs-replica re-read then saw the
canonical `null` default. Behaviour-free is not the same as safe to
delete; both names now point at the canonical nodes.

`article-` and `bounty-locked-properties` spread their transaction object
into `dbWrite`, so the canonical `$transaction` default preserves the
identity they relied on. `model-appeal` did NOT — its transaction client
was a separate object, and inheriting the default would let an
in-transaction write satisfy an assertion that means "written outside the
transaction". It keeps its own `tx`.

`model-version.{donation-goals-cache,idempotent}` each carried a second
direct mock of `~/server/redis/client` alongside the db one; both are
taken, so neither file is left half-converted.

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

* chore(tests): regenerate the allowlist after the second services a-m batch

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

* test-perf: add the client-local binder, with the refusal a run taught it

Binds a test's own client locals to the canonical mock nodes for the
shape the codemod refuses as "hoisted entry is not a bare vi.fn()".

It refuses two things rather than guessing. A leaf carrying behaviour a
canonical default does not cover, extracted with balanced parens — a lazy
`vi.fn\([^;]*?\)` stops inside an arrow's own parameter list and hands the
check a truncated call that reads as behaviour-free, which is how five
files passed a check written to stop them.

And a leaf that is a bare identifier declared elsewhere. The client
literal is the only thing wiring such a spy to the module, so deleting it
leaves the spy alive, still armed by `beforeEach`, connected to nothing,
while the code under test reads the canonical default instead. That cost
two tests in minor-hash.service.test.ts and no static check caught it —
behaviour-free is not the same as safe to delete.

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

* docs(test-perf): per-case analysis of the parameterised-client alias split

Six files in the services a-m slice mock one local as both db clients
against services that choose their client at runtime, so the usual
"grep the source for dbRead.model.method" routing finds nothing.

Corrects my earlier report: these are NOT permanent hand-work. No test in
the six passes a `db` option, so every case falls to its entry point's
default, and those are fixed and listed. Four call sites default to
dbWrite and one inverts to dbRead, which is the trap.

Records the 14 negative assertions individually, because routing one to
the client the code never touches makes it pass trivially — the failure a
run cannot show — and names the one path I could not resolve rather than
guessing it.

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

* perf(tests): migrate two more services a-m tests, and harden the binder

`model-version-count` named its spy with ES6 shorthand (`{ groupBy }`),
which the binder read as an empty object and would have orphaned; bound
by hand. `cover-image.service.logging` declares its `logToAxiom`
rejection on the canonical node — the rejection IS the fixture there,
since the tests exist to prove the `.catch()` on the best-effort log
calls, so it is moved rather than dropped.

Three refusals added to the binder, each after catching it wrong on a
real file:

- one local bound to both clients is an alias, and binding it here would
  silently pick whichever came last;
- ES6 shorthand entries, per the orphaning above;
- a factory with a BLOCK body, which opened on the block's brace, read an
  empty object, found no exports to object to, and deleted the whole
  factory with every local it named. It cleared two files that way and
  nothing caught it but reading the diff.

It now auto-converts none of the 23 remaining files in this slice, which
is the honest number: what is left is hand work.

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

* chore(tests): regenerate the allowlist

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

* perf(tests): migrate six more services a-m tests onto the canonical mocks

All six by hand; the binder refuses every remaining file in this slice.

`commentsv2-owner` wired ONE `findUnique` spy into twelve tables, so a
case naming `entityType: 'challenge'` could be satisfied by any of them.
Each entity type now arms its own table, and the two "no lookup happened"
assertions check all twelve rather than the single shared spy — stronger
than what they replaced. Four of the twelve have a case; the other eight
were in the fixture because the service can read them, not because
anything checks that it does.

`leaderboard-rank-showcase` records every statement it issues into an
array that every assertion reads. The canonical `$executeRaw` returns 0
and records nothing, so the recorder is declared rather than inherited —
dropping it would empty the array and throw, which is the loud case.

`challenge-results-notification` and the two `contest-entry-*` gates were
aliases hiding nothing: every path they assert on is spelled `dbRead` in
the service. `bust-caches-for-posts-empty` replaces a permissive Proxy
whose `apply` trap answered every un-stubbed call with `[]`; the two
assertions in it are on `$queryRaw`, whose canonical default is also `[]`.

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

* chore(tests): regenerate the allowlist after batch 4

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

* docs(test-perf): add lag-selected clients as a second unresolvable mechanism

`getDbWithoutLag` picks dbRead or dbWrite from runtime replication state,
so anything reached through it has no fixed client in the source. Five
more files in the slice route reads that way.

Under test it does not even behave as production does: REPLICATION_LAG_DELAY
is a zod `.default(0)` key absent from TEST_ENV_DEFAULTS, so the canonical
env reads it undefined, and `undefined <= 0` is false where `0 <= 0` is
true — the staleness branch production never takes runs in every test that
reaches it without stubbing db-lag-helpers. 73 defaulted keys, 59 absent
from the table, 40 of those numeric or boolean.

Also records why the already-converted files were safe: they happen to mock
db-lag-helpers, which pins the client. The safe and unsafe conversions are
separated by that coincidence, so the population at risk cannot be read off
which files converted cleanly.

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

* docs(test-perf): the lag-selected bucket is two files, not five

Corrects the section I added an hour ago. Three of the five resolve by
the entry point the test imports: deleteVersionById uses dbWrite only,
and neither model-file service mentions getDbWithoutLag at all. Only
earlyAccessPurchase (via getVersionById) and publishModelVersionById
genuinely defer the choice to runtime.

The mistake is the one the section is about — reading BOTH off a
whole-module scan and treating it as a property of the file. A module
containing both spellings says nothing; the entry point the test calls
is what decides.

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

* docs(test-perf): handover for the services a-m mock migration

The state a successor cannot recover from the diff: the four remaining
buckets with the discriminator for each rather than just their names, the
binder's five refusals with the case that taught each one, and what to
check on the seam-blocked files after that change lands — they will look
convertible the moment it does.

Also splits the post-tooling-scare audit into three categories rather than
two: claims re-verified explicitly, claims structurally immune because
they came from node rather than a shell pipe (an accident of format, not a
method), and claims not re-derived at all, named individually.

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

* perf(tests): migrate four more services a-m tests onto the canonical mocks

`engagement-toggle.idempotent` was an alias hiding a real read/write
split: `toggleUserBountyEngagement` reads bountyEngagement on dbRead and
writes it on dbWrite, through one spy, so a case asserting the read could
have been satisfied by a write. Routed per call site from the two entry
points the file imports — `toggleModelEngagement` does every
modelEngagement operation on dbWrite, and the dbRead spelling elsewhere in
user.service belongs to a function this test never calls.

`block-registry.slot-reservation` keeps its distinct read/write locals, so
the parameterised-client question does not arise. Its `redis.scanIterator`
is an empty async generator consumed with `for await`; the canonical node
would vivify it as a spy returning undefined, which throws rather than
iterating, so the generator is declared explicitly.

`file-download-lookup` carried a hand-written `safeError` under a comment
claiming the global setup does not provide one. It does — the canonical
registration spreads the real module.

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

* chore(tests): regenerate the allowlist after batch 5

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

* docs(test-perf): correct the handover after batch 5, and add two mock-system constraints

111 of 129, not the 112 the placeholder claimed. The ordinary bucket grew
from 3 to 7 because three files moved back out of "unresolvable" when I
re-checked them; the seam-blocked three are now unblocked and are the
easiest remaining work, with the check that is not "do they pass".

Adds two constraints that belong to the mock system rather than to this
slice: a canonical mock cannot statically import anything reading mocked
env at module scope, because setup.ts loads it earlier than every hoisted
factory it registers — and the failure is zero tests collected reported as
one failed suite. And ~25 typecheck errors on this base are phantom, with
the tell being that they sit in files you did not touch.

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

* perf(tests): migrate the three withSysReadDeadline seam tests onto the canonical mocks

These three were blocked until `withSysReadDeadline` became a seam node on the
canonical redis mock (17f994221e). Before that, setup.ts spread the real
implementation into the factory, so converting them would have deleted the only
lever they have: each one injects a sysRedis read timeout by replacing that
export, and the deadline is a wall-clock race a mocked client can never lose.

The check afterwards is not that they pass. Each file already asserts the seam
was called once per case, and after conversion that spy is the node setup.ts
hands the service, so "the injected implementation is reached" is asserted four
times per file rather than inferred. What that does not establish is that the
timeout leg can fail at all, because each SLOW case is fail-on-revert by hang -
and a hang is the one failure a runner cannot report. Probed by resolving the
seam instead of rejecting it, which fails on a value in milliseconds:
content-markdown returns instead of throwing, and collection returns the cached
seed rather than the locally-computed hourly one. daily-challenge is not probed;
its fail-open collapses a resolved non-JSON string to null too, so the probe
could not distinguish the branches there.

content-markdown's REDIS_SYS_KEYS copy had drifted - 'content:region-warning'
against the real 'system:content:region-warning'. Its only use is as hGet's
first argument, and nothing in the file asserts or branches on it, so the swap
changes no behaviour. That is the finding rather than a footnote: the file was
never asserting the key. daily-challenge's CUSTOM_CHALLENGE literal looked like
the same drift and is byte-identical, so it is recorded as checked and clean.

collection's fixture answered every REDIS_KEYS / REDIS_SYS_KEYS / REDIS_SUB_KEYS
path through a proxy returning 'k', for the whole of collection.service's import
graph rather than just the module under test - keys that could not disagree with
themselves. It now gets the real tables.

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

* docs(test-perf): record batch 6, and four instrument findings behind it

Bucket 3 is done and the burn-down is 114 of 129. The denominator is
re-derived off the allowlist at the branch base rather than inherited, which
turns up the number the scoreboard is missing: 41 of the 170 files in this
range never carried a canonical mock at all, so measuring progress by absence
reads 155/170 and 91% against a true 114/129. Third instance of that shape and
all three inflate the same way.

The seam files needed a probe establishing their timeout leg can go red at
all, and the obvious probe is wrong: a pass-through leaves the never-settling
read awaited directly, so the test rides to the 60s timeout rather than
failing. Resolving the seam instead fails on a value in 7ms and 9ms, and
surgically - one test per file. daily-challenge is deliberately not probed
because its fail-open collapses a resolved non-JSON string to null too.

A vi.mock of db-lag-helpers is not the pin it looks like: purge-by-hash and
deregister both carry one and neither is pinned, because both override only
preventModelVersionLag through importOriginal. The discriminator is whether
the mock replaces getDbWithoutLag.

And two instruments that mislead. residual-mocks.mjs only matches `~/`
spellings, so the relative-path hole closed for the codemod and guard at
92f1728652 is still open in the script the recipe tells everyone to run.
vitest's numTotalTestSuites counts file-level AND describe-level suites, so
reconciling a per-file count against it fires on a healthy run - which is how
a correct instrument gets discarded.

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

* perf(tests): migrate six services a-m tests onto the canonical mocks

Each was resolved by the entry point the test imports rather than by scanning
the module, and three of the six turned on a distinction that is invisible in
the diff.

commentsv2.appListing splits per CASE, not per path: thread.findUnique is
driven by four entry points that disagree, dbRead for getCommentCount (:433)
and getCommentsInfinite (:741), dbWrite for upsertComment (:267) and
toggleLockCommentsThread (:471); togglePinComment reads commentV2 on dbRead
(:505) and writes it on dbWrite (:508) inside one case.

model-file.service's local was named mockDbRead and served writes. count
(:599), findFirst (:617) and findMany (:36) are dbRead; findUnique and update
belong to markFileReplaced and restoreReplacedFile and are dbWrite. Its
comment claiming these helpers only touch dbRead was true of the two functions
it was written for and false of the file, so it is gone rather than preserved.

$transaction was decided per file, not by pattern. coinbase and deregister
inherit the canonical default because their tx and write client were already
one object - coinbase's tx member IS mockDbWrite.redeemableCode, and the
service touches only tx.redeemableCode.create (:248). Both commentsv2 files
and model-early-access-refund keep a local tx, because their assertions are on
tx spies and mean "written inside the transaction"; inheriting would collapse
that into the direct calls and the assertions would pass for the wrong reason.

Removing model-early-access-refund's wholesale logging mock left
mockLogToAxiom declared, armed and wired to nothing - a spy no assertion
referenced, in a file that would have stayed green. Deleted rather than
re-pointed.

One assertion is left as it is and flagged rather than fixed:
entityAccess.delete carries not.toHaveBeenCalled() and appears on neither
client in either entry point, so it cannot fail whichever way it is routed.
Making it meaningful is a test change, not a migration.

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

* perf(tests): migrate model-file-scan onto the canonical mocks

One local served both clients across 1,439 lines, and a single line decides
it: everything the file drives is dbWrite - modelFile.findUnique/update
(model-file-scan.service:132, :161, :203), modelFileHash.* (:171, :213, :214),
modelVersion.findUnique (:339, :686), modelFile.updateMany (:670) - except
rescanModel's modelFile.findMany at :616, which is dbRead. Nine call sites,
scoped to that describe block by line range rather than replaced globally.

Reverting that one decision - the naive all-dbWrite conversion - reds five
tests in the rescanModel block while collected stays 66, so the routing is
load-bearing and a routing failure is distinguishable from a collection
failure in the output. It is visible only because every assertion downstream
of findMany is positive; the identical mistake under a negative assertion is
silent.

$transaction here is the ARRAY branch (:212), which the canonical default
handles separately from the callback branch the recipe documents. Every test
that reaches it sets mockResolvedValue([]) explicitly, so the default is
overridden and the array's elements are still evaluated as it is built.

logToAxiom and sysRedis.setNxKeepTtlWithEx are re-pointed at the canonical
nodes rather than deleted: both are genuinely asserted, at three sites each.

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

* perf(tests): migrate the six parameterised-client tests onto the canonical mocks

This is the bucket where the service takes its client as a parameter, so which
client a case exercises is a fact about the test's call rather than a spelling
in the source. None of the six passes a `db` option, so each falls to its entry
point's default.

The routing table in services-a-m-parameterised-client-analysis.md is wrong
about one line, and the paired run caught it. Inside resolveBlockInstance the
parameterised local at block-registry.service:1362 serves EVERYTHING on that
path - blockUserSubscription.findUnique (:1409), model.findUnique (:1451) and
modelVersion.findFirst (:1461) - so all three are dbWrite. The dbRead spelling
the analysis cites for model.findUnique is at :2617, in a function these tests
never call. Routing model/modelVersion to dbRead reddened six tests across
pinned-version and resolve-instance, every one "expected null not to be null",
because the code read the canonical null on a client it never uses and returned
early. Fifteen sites corrected.

appCollaborator.findMany is dbRead, and it is spelled nowhere in
appBlockReview.service: getAppInsiderUserIds (:74-77) reaches it through a
deferred `await import()` of app-access.service, where the call is at :1111.
That closes the one assertion the analysis left explicitly unresolved.

appBlockReview.findUnique is NOT on both clients for these entry points either
- the dbRead spelling at :264 belongs to getMyAppBlockReview, which the test
does not import.

spend-cap-config's beforeEach sits above both describes, and the two read
appBlock.findUnique through different clients, so it arms both nodes. Arming
one would leave the other describe on the canonical null and take the NOT_FOUND
branch throughout - green in one block, wrong in the other.

Both resetAll loops are replaced by vi.clearAllMocks(). Object.values() over a
canonical node enumerates nothing: it is a Proxy over a vi.fn with no ownKeys
trap, so the vivified children are never own properties. The loops would have
reset nothing, and only a negative assertion downstream would have shown it.

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

* docs(test-perf): close out the services a-m slice at 127 of 129

Records batches 6-9 with their per-file results, and the two findings that
cost the most to learn.

The red: batch 9 routed model.findUnique to dbRead on the strength of a line in
the parameterised-client analysis, and six tests went red because inside
resolveBlockInstance that call goes through the parameterised local at
block-registry.service:1362, not through the dbRead spelling at :2617 which
belongs to another function. Fourth BOTH-off-a-whole-module-scan instance in
this slice and the first to reach a routing table rather than a bucket
assignment. A citation was present, it looked checked, and the cited line was
not on the path.

The probe: a mis-routed negative is silent, demonstrated with the control that
makes the demonstration mean something. Mis-routing a dangerous negative passes
AND mis-routing a safe one passes identically, so only flipping it to a
positive discriminates. And the canonical nodes carry their dotted path as a
mockName, so a mis-route now names the client it landed on where the old
fixtures read `expected "spy"`.

Kept as a log rather than rewritten to current state.

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

* docs(test-perf): record where the slice landed and what is not established

Branch, SHA and PR, plus the three facts a successor cannot re-derive: the
stale remote branch diverges and is not a fast-forward, two files appear on
both the PR branch and perf/test-mock-system deliberately, and
storage-resolver.deregisterByFile stops where it does on purpose.

States plainly that nothing here has been adversarially reviewed and should
not reach main on the author's own verification.

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

* docs(test-perf): flag the stacked-PR retarget so it cannot be forgotten

#3973's base is #3959's head, which is the shape CLAUDE.md forbids: a
squash-merged parent does not retarget the child, so the child lands on an
orphaned branch and its changes go missing. The plan already avoids it by
rebasing onto main afterwards - this records that the retarget is the
load-bearing step and states the check that catches it having gone wrong.

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

* docs(test-perf): narrow the storage-resolver refusal — not lifted, not permanent

"Do not finish it" overclaimed. The canonical env proxy DOES honour post-hoc
assignment (env.mock.ts:79 writes into overrides, :45 reads overrides first,
with defineProperty and deleteProperty traps landing in the same map), which
was the one link in the argument the author had not verified.

It does not rescue a lift - the proxy honours assignment to `env`, not to a
different object that used to be the mock - so the file still must not be
lifted. But it does mean the file is convertible by rewrite: move the
mutations into setEnv or a direct env.X assignment inside the case that needs
them.

A refusal to do it the cheap way is not a claim that it cannot be done.
Recording it as permanently refused would be a claim nobody has earned.

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

* docs(test-perf): fold in the reviewer's mechanism, and label my unmeasured claim

The shielding hazard cannot re-enter this file at all: logToAxiom is a
canonical hybrid node whose identity is cached in a module-level Map for the
worker's life (hybrid.ts:30, :48-95), so the binding is correct whether or not
the consuming module re-instantiates. That makes converting logging while
keeping env the right split rather than a compromise - the canonical mock
removes the hazard instead of dodging it.

And the reviewer was right to label one of my claims: that a per-file env mock
FORCES re-instantiation under isolate:false is reasoned, not measured. Nothing
here depends on it. Recorded as reasoned so nobody quotes it as measured.

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

* style: Prettier-format the three files this branch adds

CI's lint gate checks ADDED files, and all three had been failing it for as
long as they existed - the same thing that blocked #3959 for its whole life
with six of them. Caught by running the gate rather than by waiting for CI.

No content change: formatting only, on files this branch introduces, so the
diff against main is the whole file either way.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 18:31:13 -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
Justin Maier 7048aadfa1 perf(tests): split sharp-executing tests onto their own pool, pre-bundle five externals (#3960)
* perf(tests): route sharp-executing tests to their own forks project

sharp 0.32.6's addon is not context-aware, so a worker_threads worker that has
run a libvips operation segfaults at thread teardown - after the tests pass and
the summary prints. That takes the whole run down with an exit code and no
failing test.

It is a race, not a threshold. On the six affected files, three repeats per
width: vmThreads crashed 3/3 at 1 worker, 2/3 at 2, 1/3 at 3, 0/3 at 4; threads
crashed at every width. A green run is not evidence of safety.

Importing sharp is harmless; only executing an operation arms it. 100 test files
carry sharp in their static closure and exactly six call it. That set was
measured by aliasing sharp to a recording proxy and running all 100 under forks,
not by grepping and not by a crash-scan - with a race, "ran alone and didn't
crash" builds the list out of the files that got lucky.

- unit        -> the suite minus those six, pool unchanged (forks)
- unit-native -> pool: forks pinned, including only those six

unit deliberately does NOT move to threads. threads measured 1.04x at 4 workers
and 0.94x at 16 - no win - and it segfaults mid-run on the full suite at roughly
1 in 4, after completing hundreds of files cleanly and with no unit-native file
having run, so a second crasher exists that is not sharp and is not diagnosed.
What the split buys is that the sharp crash is deterministic and gone, so anyone
experimenting with --pool=threads no longer has to fight it too.

Excluded from unit rather than merely claimed by unit-native, so that a run
naming a sharp file under --project unit reports "No test files found" rather
than running it on a thread pool if one is ever selected. Shared settings are
hoisted into one object both projects spread, so they cannot drift apart.

Every selector moves to a unit* project pattern. Verified by file count rather
than by a green summary: vitest list over unit* is 1065 files and unit-native is
6, summing to the pre-split baseline.

no-sharp-outside-native-project.test.ts is the positive control, since nothing
else in the suite can notice this breaking: the realistic failure is a rename,
which stops matching unit-native's include AND unit's exclude. Mutation-tested,
not assumed green.

* perf(tests): pre-bundle five externals nothing mocks

Every test file gets a fresh module registry - under forks with isolate: true it
is literally a fresh child process per file (N files at maxWorkers=1 give N
distinct pids), so Node's module cache dies with it and each externalised
package is imported cold once per file that reaches it. Pre-bundling collapses a
package's many-hundred-file native load into one chunk, paid once per run.

Full suite, control then treatment in one window:

  control     wall 217.2s  collect 4730s  1066 files  16787 tests  17 failed
  treatment   wall 192.9s  collect 4082s  1066 files  16787 tests  17 failed

The failure SET is unchanged, diffed both directions - nothing appeared, nothing
cleared. This alters timing, not behaviour.

The effect tracks exposure, which is what separates it from ambient drift:

  reaches 0 of the 5   353 files  collect  113s ->  105s   -6.9%
  reaches 1            115 files  collect  150s ->  103s  -31.2%
  reaches 2            209 files  collect  529s ->  373s  -29.6%
  reaches 3             18 files  collect   69s ->   52s  -24.3%
  reaches 5            371 files  collect 3869s -> 3449s  -10.9%

Exposure 5 shows the smallest percentage and the largest absolute saving (420s
of 648s) because those are the heavyweight files: five packages are a small
share of a 1,300-module closure and a large share of a small one. Percentage
tracks share-of-closure, absolute tracks file weight.

A control-vs-control run to size run-to-run drift directly was attempted and
died to an unrelated crash, so the residual drift term is unmeasured and the
figure above should be read as an upper bound.

The list is confined to packages nothing mocks, and that is load-bearing.
Pre-bundling wraps a package as a CJS-interop chunk, so a vi.mock factory
returning only named exports stops satisfying its consumers - adding redis and
the mocked aws-sdk clients takes four mock-holding files from 92 tests passing
to 7 collected. The importOriginal form does not protect against this. Those
three are worth ~275s more and need a default export added to six mock
factories first; that is a separate change.

The treatment paid its cold optimize pass inside the measured run - the shared
.vite cache was not cleared - so the number is not flattered by a warm cache,
and a fresh CI runner pays the same thing.

* test(perf): pin the native project's pool independently of unit's

The guard asserted unit ran on threads, which was the state the split shipped in
for about ten minutes. It caught its own config change, which is the behaviour
wanted, but the assertion was aimed at the wrong invariant: what must hold is
that unit-native stays on a process-based pool whatever unit is pointed at, not
that unit is on any particular one.

* test(perf): acceptance harness for the six external-mock factories

The change that brings redis and the aws-sdk clients into the pre-bundling
safelist cannot be verified on a tree that does not enable the optimizer:
without pre-bundling the package is not wrapped as a CJS-interop chunk, the
missing default export never bites, and a green run proves only that the old
config still works. This runs the six affected files under a config with all
three candidates pre-bundled.

Compares per-file collected counts rather than the total. s3-utils is 66 of the
106, so a sum of 40 could be one file collecting zero and still read as a
partial pass.

Negative control on the unchanged tree: 5 of 6 files collect 0, and the harness
exits 1 naming each one.

* docs(test-perf): record the measurement envelope this box imposes

Two identical full runs, back to back, nothing changed between them, came out
+20.5% apart on collect. That pair was contaminated, so it is not a drift figure
- but it demonstrates the box can move further than most of the effects measured
today, which means any comparison assembled from two windows is unreadable.

Collects the methodology that follows: quote in-pair controls rather than
cross-window deltas; a control group must be comparable in cost and not merely
in count; a dose-response on an axis confounded with file cost is suggestive
rather than conclusive; a crashed run's wall clock is not a fast run; and check
for the workload rather than for the runtime when deciding the box is quiet.

A clean drift pair still has not been taken and is the denominator for
everything else here.

* docs(test-perf): scout Bun and node:test as vitest replacements

Recommendation is to stay on vitest, but the measurement overturns the cost
model we spent the day optimising against.

Same 84-module first-party graph: vitest collect 5298ms, bun 3.3ms, node+tsx
8.5ms. Whole-suite arithmetic gives vitest 10.2ms per static module-instance
against 0.04ms for bun. The cost is the module runner, not the modules - which
matches this morning's tracer result (569 module bodies in ~0.4s against a 25.4s
import phase) and locates the time in vite-node's per-module fetch/instantiate
rather than in compile-and-evaluate.

Unrealisable, though. Bun cannot load any graph reaching the React/Next side -
it dies resolving use-sidecar's package exports - so the numbers are measured on
the light stratum only, which is the flattering-slice trap: 383 of 1065 files
have no infra dependency and the largest of those is an 86-module closure.
Module-scope env aborts the import under both runtimes, and cache-helpers hung
past 300s under bun after the env gate was satisfied.

The mock surface is the wall: 1053 of 1065 files import from vitest, 3883
vi.mock sites across 651 files, plus 8053 vi.fn and the fake-timer, spy and
importActual surface. The canonical mock system, its guard, the allowlist
ratchet, reporter.mjs, the dashboard and the queue integration are all
vitest-shaped as well.

Retarget rather than switch: if per-module cost is vite-node overhead, shrinking
the graph attacks a term worth ~0.04ms of real work per module, and the leverage
is in how many times a module is INSTANTIATED - which is what isolate:false
removes.

* docs(test-perf): retract the per-module ratio in the runner scouting

It divided collect by inventory.json's static module counts, and that artifact
was wrong by up to 75x and selectively so - it followed lazy dynamic import
edges that never execute and ignored vi.mock factories. Honest suite union is
1321, not 3230.

The per-file wall clock the recommendation rests on needs no denominator and is
unaffected: same 84-module closure, vitest collect 5298ms against bun 3.3ms and
node+tsx 8.5ms. So is the tracer result behind it - 569 module bodies executing
in ~0.4s against a 25.4s import phase, measured with no static count at all.

No counterpart figure is quoted for bun, because its denominator came from the
same artifact.

* docs(test-perf): scrub the remaining per-module claims from the runner scout

Two survivors of the retraction: an 'orders of magnitude per module' headline
and a stratum characterisation quoting closure sizes, both resting on the same
broken counts. Restated against the per-file wall clock, which needs no
denominator, and the observed hard failure, which is not a count.

* docs(test-perf): correct the runner comparison to like-for-like

The headline compared vitest's collect for a TEST FILE against a probe importing
only the SOURCE module underneath it - a different and much smaller graph. That
is where '~1600x' came from.

Like-for-like, on the same 82-module test-file closure: vitest collect 5298ms,
bun 259ms (median of 5, 250-262). ~20x, not three orders of magnitude. node+tsx
cannot import a test file at all - 'Vitest cannot be imported in a CommonJS
module using require()'.

Per-module refit against aidan's honest closures.json (mode: 'real') joined to
the pre-ctl full run: 1065 files, 104797 real module-instances, collect 4729s ->
vitest 45.1 ms/module, independently agreeing with aidan's 43.6. bun 3.2
ms/module on the file both can load.

The recommendation is unchanged and the mechanism finding is unchanged; the size
of the gap was overstated.

* docs(vitest): say why the unit projects set no per-project maxWorkers

Per-project maxWorkers does apply at runtime, but two projects with different
counts need different sequence.groupOrder values, and different groups run
serially. For a 1059/6 split that trades the concurrency between them for a
knob nobody needs - the six-file project would gate the other 1059 instead of
filling spare capacity beside it.

Currently reads as an omission, so a future reader adds one and loses
concurrency without knowing they traded for it.

* docs(test-perf): final form of the runner scouting result

Leads with both corrections stated in place rather than silently edited out, and
records that neither changed the recommendation.

Promotes the cross-validation to a finding of its own: 45.1 ms per
module-instance here against aidan's independent 43.6, from a different artifact
by a different route. Two wrong denominators would not have agreed, so the pair
is what licenses everything downstream that divides by a module count.

Names what both errors had in common - each a denominator error producing a
number right about the thing it measured and wrong about what that thing was.
Checking two runtimes are comparable is not checking the two quantities are.

* fix(test): pin unit-native's pool against a CLI --pool, and correct the project selector

`unit-native`'s static `pool: 'forks'` loses to a CLI `--pool=threads`: resolveProjects
builds cliOverrides from a list that includes `pool` and spreads it after options.test,
so the flag wins. The six sharp-executing files would then follow `unit` onto a thread
pool and segfault AFTER printing a green summary.

configureVitest hooks run after resolveProjects(cliOptions), and getFilePoolName --
`browser.enabled ? 'browser' : project.config.pool` -- is what stamps each spec's pool,
so re-asserting there outranks the flag. The other two readers of project.config.pool
populate task metadata from the same field and cannot disagree with it.

The comment already claimed this guarantee; without the plugin it was false, and false
in the reassuring direction.

Also corrects CLAUDE.md: the unit suite is two projects now, so `--project unit` silently
runs 1059 of 1065 files and exits 0. Select it as `--project 'unit*'`, which is what
package.json's own scripts already do.

Bound: the pin covers `pool` and nothing else. isolate, fileParallelism, sequence,
testTimeout and retry are on the same cliOverrides list and remain overridable.

Not verified by a run -- the mechanism was read from vitest 4.0.18's cli-api chunk twice,
independently, by two readers.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 17:07:44 -06:00
Justin Maier f4dce6a3c9 perf(tests): cut test-body time in the five slowest unit-suite files (#3962)
* perf(tests): move the challenge job-lock wiring assertion off the ladder suite

challenge-ladder.test.ts was the slowest file in the unit suite at 37.9s of
test-body time, and 35.3s of that was a single test dynamically importing
~/server/jobs/daily-challenge-processing to read one job option. The file is
otherwise pure arithmetic (collect 476ms).

challenge-jobs-scale.test.ts already loads that module behind a mock preamble,
so the wiring half of the assertion lands there at no marginal cost. The
arithmetic half (lock < interval) stays beside the constants.

challenge-ladder.test.ts 37875ms -> 427ms test-body; challenge-jobs-scale
29ms -> 17ms with its collect unchanged.

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

* perf(tests): stub model.service’s graph in the orphan-relations regression suite

prisma-inconsistent-orphan-relations.test.ts loaded the real model.service from
a beforeAll with a 120s timeout, which billed the whole graph to test-body time:
31963ms duration against 237ms collect in the full-suite baseline.

Replaced the wholesale @prisma/client Proxy stub with the mock scaffold seven
sibling model.service suites already use, and hoisted every dynamic import to
module scope. The scaffold cuts the graph rather than relocating it: measured in
one window, 19.6s wall / 19148ms test-body -> 7.2s wall / 9ms test-body.

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

* perf(tests): stub the lazy DB read in the challenge-category fallback tests

The "preset fallback (no DB available)" tests left ~/server/db/client unmocked
and relied on a real connection attempt failing. That cost ~4s per call — four
calls, 18400ms of test-body time — and made the branch under test depend on what
the box could reach rather than on the code.

Stubbing findMany to reject drives the same catch, in 39ms.

The old suite could not tell the failure path from a successful read of zero
rows: merging presets with an empty row set gives the same answer, so flipping
the stub from reject to resolve([]) left all 16 tests green. Added the two
assertions that separate them — the failure path deliberately does not cache,
the success path does — so the stub cannot quietly become the thing under test.

18400ms -> 39ms test-body; 16 tests -> 18.

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

* perf(tests): drive the s3 upload retries on a fake clock, and cover the backoff

Six tests in s3-upload.store.test.ts waited out real retry sleeps: withRetries
holds a flat second between the three /api/upload/complete attempts, so each of
them cost ~3s of wall clock. 16358ms of test-body time, 17.6s wall.

They now run through a runUpload() helper that advances a fake clock until the
upload settles. The loop is bounded and throws rather than draining while timers
happen to exist: at the moment the upload is handed back no timer exists yet (the
first is scheduled after fetch('/api/upload') resolves), so a getTimerCount()
guard exits before the run begins and the test hangs. Bounding it also means a
retry loop that stops terminating fails with a message in 113ms instead of
wedging the runner, which is the failure mode the fake would otherwise create.

The change exposed a gap it then closes: nothing exercised the TRANSIENT part
retry path. Making 429 and 5xx non-retryable left all 17 tests green, because
covering it meant sitting through 1s + 2s + 4s + 8s of backoff. On the fake clock
it is free, so the path and its MAX_PART_ATTEMPTS bound are now pinned, and
getPartRetryDelay - whose policy was observable only as elapsed wall clock - gets
real unit tests beside isTerminalCompleteStatus.

16358ms -> 36ms test-body. 17 tests -> 19, and upload-retry 10 -> 17.

Also records why audit-matching-equivalence.test.ts must stay slow: its
brute-force oracle is valuable because it is a copy, and an optimised copy is a
second implementation you believe is equivalent.

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

* perf(tests): hoist the listing-asset service imports out of the test bodies

listing-asset-upload-integrity.test.ts reached app-listing-assets.service,
offsite-listing.service and stored-object-integrity through `await import()`
inside its helpers, so the whole graph was billed to whichever test ran first:
6664ms of the file's 6992ms sat on one test, behind a `collect` of 561ms.

This is a RELOCATION, not a cut. Test-body time drops 6992ms -> 773ms and collect
rises 561ms -> 6140ms; wall clock is unchanged within this box's noise. It is
worth doing anyway because a graph billed to `duration` is invisible to the lane
that attacks import cost - the file reads as cheap to import and expensive to
run, when the opposite is true.

No mock scaffold added. The file is a seam test that deliberately runs the real
persist and attach procs with only the backend accessor replaced, so cutting the
graph would cost it the thing it exists to check.

Verified the suite is still live after the move - mocks apply because vi.mock is
hoisted above imports - by making classifyStoredObjectIntegrity always report a
match: 11 tests fail across all three asset kinds, including "REFUSES the attach
once the stored object is no longer the measured one".

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

* test(gate): pin that a wrapper which never launches cannot pass

The typecheck-tests gate was checked for the spawn-laundering class found in the
packages suite, where execFile on an extensionless node_modules/.bin script never
started on Windows and the ENOENT landed in the same `code` field as a real exit
status - so a process that never ran reported as one that exited 2.

It is not present, and structurally cannot be: the gate spawns process.execPath,
which always exists, and passes the wrapper as an argument. A missing wrapper is
therefore node's own exit 1 with no diagnostics, which the FAILURE-TO-RUN rule
already owns. There is no resolved-binary lookup on this path to fail silently.
Verified against the real gate: res.error is undefined, status 3, CANNOT MEASURE.

Nothing pinned that, so this adds the case. It asserts the run is refused rather
than which guard refuses it, which is deliberate: with the FAILURE-TO-RUN rule
disabled the positive-control floor catches the same run at 0 test files against
a floor of 844, and pinning one mechanism would make the test fail on a change
that left the property intact.

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

* perf(tests): stop paying the full port-claim deadline twice

Two cases in dev-server-port-reservation.test.ts pass claimPortForReuse a 5000ms
timeout and a port that never frees, so each polls for the whole deadline before
moving: 10.0s of the file's 10.6s, in two tests.

The file's own header claimed the opposite - that a 1ms poll with a generous
deadline keeps every case count-bound rather than clock-bound. That holds for the
cases where the port FREES, where a probe mock ends the loop. It cannot hold where
the port never frees, because expiry is the mechanism under test.

Those two now take a 60ms deadline. Shortening it cannot make them flaky: the
assertion is that the session moved, and the move is what happens once the deadline
passes, however few probes fit inside it. The comment now says which cases are
which.

10600ms -> 734ms test-body, 37 tests unchanged.

Mutation-tested, both directions, at the new deadline:
  drop `session.port = moved`      -> expected 3101 to be 3102   (68ms)
  make findAvailablePort ignore other sessions' reservations
                                   -> expected 3100 to be 3102, and
                                      expected 3100 to be 3103   (67ms, 71ms)

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

* perf(tests): drive the reward batch-retry backoff on a fake clock

`(e) rethrows on update failure in the batch process path` waited out
updateBuzzEvents' real retry budget - 5 retries at a 500ms backoff - costing
2556ms of the file's 3837ms.

The budget is the behaviour under test, so shrinking it would change what the test
covers. The clock is driven instead, with a bounded loop and the assertion awaited
afterwards, so a retry chain that stopped terminating fails on the rejection rather
than spinning.

3837ms -> 1270ms test-body, 10 tests unchanged.

Adds an attempt-count assertion, which pins the retry budget and doubles as the
control on the fake clock: without it driving the backoff those attempts would not
have happened.

That assertion also corrected a wrong reading of my own. It pins the literal `5`
that updateBuzzEvents passes to withRetries, NOT BATCH_RETRY_COUNT, which is
addBuzzEvent's default and which this path never reads - dropping BATCH_RETRY_COUNT
to 1 leaves the file green, dropping the literal gives "expected 6 times, but got
2 times". The comment records both so the next reader does not credit the constant
with coverage it does not have.

Mutation-tested:
  batch path swallows instead of rethrowing -> promise resolved "undefined" instead of rejecting
  updateBuzzEvents retry literal 5 -> 1     -> expected "vi.fn()" to be called 6 times, but got 2

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 16:59:44 -06:00
Justin Maier 21ab65ba14 fix(tests): make the packages and unit suites readable on Windows (48 failures -> 0) (#3961)
* fix(tests): run the drift CLIs through node, not the POSIX bin shim

`node_modules/.bin/tsx` is a shell script, so `execFile` on Windows failed
ENOENT before any CLI started — 32 tests across the three schema-drift files,
i.e. every case in `test:packages:run` that spawns a CLI, red on any Windows
checkout. `execFile` reports that in the same `code` field as a real exit
status, so they read `expected 'ENOENT' to be 2`: an assertion about the CLI's
behaviour rather than about a CLI that never ran.

Resolve tsx's own entry and spawn it under `process.execPath` instead, and
throw when a spawn produced no numeric exit code so the two can never be
confused again.

Mutation-tested: dropping `process.exitCode` in gate-cli gives
`expected +0 to be 1`, and dropping the `capturedAt` stamp in cli.ts gives
`expected undefined to be truthy`.

* fix(tests): give the source-scanning guards posix path identifiers

Four drift guards build a repo-relative path with `path.relative()` and then
use it as an IDENTIFIER — matched against `/`-separated literals, or split on
`/`. On Windows `path.relative()` returns backslashes, so the match can never
hit and 14 tests were red on any Windows checkout. Normalise separators at the
point the path stops being a filesystem path; nothing asserted changes, and the
paths handed to the filesystem are untouched.

One of these did more than go red. In app-spend-tier-privilege the mismatched
key is `alwaysDecode`, whose stated job is to read the publisher-facing modules
unconditionally so that a renamed or deleted path is loud rather than a quietly
vacuous pass. With backslash keys it never hit, so it force-read nothing and
could not tell a stale path from a present one — the mechanism against a vacuous
pass was itself vacuous. Any Windows checkout has been in that state since the
guard was written.

Mutation-tested, each fix separately:
- rename a PUBLISHER_REACHABLE entry -> `blocks.router.RENAMED.ts was not read
  — is the path stale?: expected undefined to be defined`
- drop 'generation-resources' from KNOWN_STATIC_ENDPOINT_SEGMENTS ->
  `expected '/api/v1/blocks/:seg' to be '/api/v1/blocks/generation-resources'`
- drop the 'user-settings:write' label -> `expected [ 'user-settings:write' ]
  to deeply equal []`
- change orchestrator-chat's wait to 60000 -> `expected 60000 to be less than
  or equal to 150`, and the ledger diff names
  `server/services/comics/orchestrator-chat.ts:60000`

* fix(scripts): name emitted chunks with posix separators, and cover exit 137

The server-graph gate keyed its chunk map on `relative()` output, so on Windows
every violation named `chunks\ssr\b.js`. That key is what the report prints, so
it is a name, not a path: normalise it, and keep the absolute path beside it so
reading a chunk never goes back through the key.

`typecheck.test.ts` simulated an outside kill with SIGKILL, which Windows cannot
deliver — the child exits 1 with `signal === null` and the wrapper correctly
reports a generic crash instead. Skip that case there for the stated reason and
add the other half of the same branch, `exit 137`, which is what a container
actually reports and which runs everywhere.

Mutation-tested: dropping `|| code === 137` from the wrapper's classifier fails
the new case with `expected '...TYPECHECK CRASHED...' to contain 'killed from
outside'`. The gate's own synthetic negative control covers the chunk name.

* fix(tests): resolve tsx per call, read the walked path, name a signal kill

Three review findings on this branch, all one-liners, all the same shape as the
bug the branch fixes: a failure reported as something other than what it is.

`tsx/cli` was resolved at MODULE scope. All three drift test files import that
module, so a resolve that throws — a tsx release dropping the `./cli` export
subpath, a partial install — would take all three down during module evaluation
and each would collect ZERO tests while the failure count stayed 0. Resolving
inside the call surfaces it as a test failure naming the module instead. Latent,
not live: tsx 4.20.3 declares `./cli`.

`app-spend-tier-privilege` now reads the absolute path the walk produced rather
than `join(ROOT, <normalised key>)`. The key is an identifier; handing a
posix-separated string back to the filesystem works today and would not under a
`\?\` prefixed path. This is also what the PR body already claimed it did.

`runTsxCli` announced a signal-killed process as "did not run", which sends the
reader looking for a spawn failure. A signal kill did run.
2026-08-15 14:16:26 -06:00
Justin Maier 93ee73cd84 test(perf): instrument the unit suite and track the isolation migration (#3957)
* test(perf): instrument the unit suite and track the isolation migration

The unit suite spends 81% of its worker time importing, and nothing in the repo
could say which modules or which files. This adds the measurement that answers it,
plus a dashboard so the isolation migration has a burn-down nobody has to maintain.

The finding that motivated the shape of it: traced at 1 worker, the module BODIES
of two of the heaviest test files total ~0.4s against a 25.4s import phase. The
cost is vite-node's per-module fetch, which under the `forks` pool is a
child-process IPC round trip per module per file. So cost is linear in module
COUNT, not module weight, and the static import graph is the right thing to rank by.

- graph.mjs      static first-party import graph + vi.mock inventory
- reporter.mjs   per-file collect/setup/test timings from any run
- bench.mjs      fixed 90-file stratified yardstick, so two measurements taken
                 hours apart by different people are comparable
- sweep.mjs      pool x isolation x worker-count matrix, run back to back
- trace-*        module-execution tracer; counts what actually ran, which the
                 static graph cannot know (a vi.mock factory stops the real
                 module and its subtree from executing)
- why.mjs        shortest import path between two modules
- dashboard.mjs  builds .test-perf/dashboard.html from whatever is on disk

Output goes to .test-perf/, gitignored. Nothing here runs in CI or changes how
any suite executes.

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

* test(perf): add the per-worker union report, and record three dead ends

Under isolate:false a worker keeps one module registry, so its cost is the UNION
of what its files import rather than the sum. order.mjs reports that union, which
is the number that bounds what removing isolation can deliver: measured at ~36
seconds per worker to build, near-constant at 8 and at 24 workers, and a wall-clock
floor more workers cannot shrink.

Three things measured today that do NOT help, written down so the next person does
not pay for them again:

- Affinity file ordering. A greedy graph-similarity sequencer gave a mean per-worker
  union of 1139 modules at 31 workers against alphabetical's 1084 - slightly worse.
  Alphabetical already groups by directory and directory already correlates with the
  graph. The sequencer is deleted; the measurement is kept.
- NODE_COMPILE_CACHE. Cold 26.8s, warm 51.4s, warm again 33.9s on the yardstick. The
  cache filled (5.3MB) so it was active; vite-node does not evaluate through the
  loader it covers.
- vmThreads. Two clean 90-file runs, then the five sharp-executing files crashed or
  passed on identical input at 2 and 3 workers. It is a race, and CI's 4 vCPU
  resolves to the width measured at 1-in-3 SIGSEGV.

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

* docs(test-perf): three measurement rules this project had to learn twice

- The 90-file yardstick understates isolate:false and cannot judge it. That flag
  amortises the registry build across the files a worker runs, so its win scales
  with files-per-worker: 90 files at 16 workers is ~6 each and measures 1.65x,
  while 1065 files at 8 workers is ~133 each and measures 16x on the same phase.
- No --no-isolate number is quotable without a per-file collected count. Its damage
  is not only failing assertions: files silently collect ZERO tests, and how many is
  width-dependent (9 of 90 at forks/4, 14 of 90 at threads/4, 0 at threads/16, same
  input). A summary line cannot show this.
- The noise floor on a shared box is +/-30%: one configuration measured 53.3s and
  76.6s in a single session. Below ~20%, quote phase numbers or nothing.

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

* test(perf): show the guard allowlist as the migration burn-down

The dashboard was deriving migration status from the static vi.mock inventory,
which is an estimate. The authoritative number is the length of the guard's
allowlist: it ratchets in both directions, so a new direct mock fails and a
migrated file left on the list fails too, and it therefore cannot drift from what
the suite will actually accept.

Reads it from the working tree when present, otherwise from the branch carrying
it, so a dashboard built on main still shows the real number rather than nothing.

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

* docs(test-perf): name the baseline, retract the IPC mechanism, fix the yardstick's promise

Three defects found by an adversarial review of this PR, all in the class of
asserting something nobody re-checked.

- The README quoted one baseline while shipping an artifact with different
  numbers for the same tree - 4565.5s against 5476.3s of import, 36% apart. The
  tree did not change; the box did. All three measurements of `3863adcbb0` are
  now tabulated with the rule that a main-relative figure must name the run it
  was measured against, because that spread is the same size as several of the
  effects being measured against it. The derived import share is 81% or 84.3%
  depending which row you pick, and both are now stated.
- The per-module IPC mechanism was retracted in mail hours ago and left standing
  here. The pool sweep refutes it: `threads` beat `vmThreads` while paying a cold
  fetch, which shipping module source cannot explain. Now labelled inferred, with
  the competing reading and the note that V8 compile time was never instrumented.
- The yardstick claimed it made measurements "hours apart" comparable, three
  bullets above a +/-30% noise floor measured inside one session. It fixes what is
  measured, not when. Also records that a null from a 90-of-1065 sample is not
  evidence of no effect - two changes measured flat there were later shown real.

And the gitignore claim is now scoped to "by this change", since it is false on
main until this merges.

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

* fix(test-perf): count the modules a worker loads, not the ones a bundler compiles

The static graph over-counted a page-gate test by ~75x, which put four of the
cheapest files in the suite at the top of the closure ranking. Their measured
worker time ranks 202-572 of 1065.

Four independent causes, each removing modules the naive walk counted:

- lazy `import()` is not followed, EXCEPT from a test file itself. A
  `dynamic(() => import())` in a page never runs; an `await import()` in a
  test body is what the test exists to do. Collapsing the two makes those
  files either the top of the ranking or 1 module each.
- a `vi.mock` factory without `importOriginal` truncates the subtree behind
  it. The mocked module itself is still counted -- registering the mock is
  what causes the transform -- and is counted even when nothing imports it.
- `import { type X } from` erases the statement. Only `import type {` was
  handled, and the inline form is the common shape here.
- a line filter cannot strip a multi-line `import type`: it leaves
  `} from '...'` behind, and IMPORT_RE's lazy `[\s\S]*?` glues that orphan
  onto the previous import, inventing an edge. Stripped as statements now.

Plus `event-engine-common` in SRC_DIRS -- a submodule imported by relative
path from src/server/services, so its modules and the civitai-db-queries
files reachable only through it were invisible.

Validated by diffing the model against a transform-hook trace as SETS, not
counts: 3 of 5 files exact with empty diffs both ways, 16 modules of
symmetric error across 691 traced (2.32%). Counts alone agreed often enough
to hide two of the four causes -- a count agreeing is not the rule agreeing.

`graphModules` is now the honest count and stays the default field, so
dashboard.mjs is corrected without a change of its own; `graphModulesRaw`
keeps the bundler view. closures.json gains `mode: 'real'` and a note
describing the truncation rules, so a consumer can refuse a naive one.

Also flags the two order.mjs union figures as pre-honest levels needing a
re-run; the ordering conclusion is a ratio and survives.

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

* fix(test-perf): stop the type-statement stripper eating real code, and record maxWorkers honestly

Three review findings, all mine.

1. graph.mjs -- the type-statement stripper swallowed spans of real code.
   `TYPE_STATEMENT_RE` also started matching on a bodyless `export type X = ...`
   (no `from`), and the lazy `[\s\S]*?from '...'` then scanned FORWARD across the
   file to the next `from '...'`-shaped text -- inside comments and strings
   included -- deleting every statement in between before IMPORT_RE saw them.
   src/server/common/enums.ts lost a real `@civitai/notifications/constants`
   edge across a 7,881-character span.

   The gap is now tempered: between `type` and `from` a real type-import
   statement holds only a binding clause, so it may not cross `;`, `=`, or
   another `import`/`export` keyword. Measured across 5,294 files, spans of
   more than 12 lines fall from 73 to 28, and every one of the 28 remaining is
   a genuinely long multi-line type-import list. Re-validated against the
   transform-hook traces: symmetric error 2.32% -> 2.03% over 691 traced
   modules, so the fix is strictly in the right direction.

   Direction of the bug was UNDER-count, and `graphModules` is the ranking key
   for the dashboard, order.mjs and bench.mjs --make-subset. The earlier
   validation did not cover it: 5 files of 1,065, none of the affected ones.

2. reporter.mjs -- the one unguarded filesystem call. It sits inside an awaited
   vitest lifecycle hook, so a throw (read-only cwd, `.test-perf` existing as a
   file, ENOSPC, an AV EPERM) would red a run whose tests all passed, and the
   failure would be attributed to the code under test. A measurement tool must
   never be able to fail a suite; losing the recording is the correct trade.

3. reporter.mjs -- `maxWorkers` is not on vitest 4's `ctx.config`. Verified by
   execution: the emitted config was `{isolate, pool, argv}`. Every run ever
   recorded therefore stored null, and dashboard.mjs rendered all of them as
   "(default) workers" -- claiming a fact nobody measured. Recovered from argv
   (`--max-workers=8`, `--max-workers 8`, `--maxWorkers=8`), falling back to
   VITEST_MAX_WORKERS, and reported as 'unknown' rather than null when neither
   is present. The env var and the flag are recorded separately because they do
   not behave the same -- the flag reaches a queued run and the env var does
   not. The dashboard now shows historical nulls as 'unrecorded', not
   'default'.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 14:15:17 -06:00
Justin Maier 6f54a21085 feat(dev-server): per-service dev/prod env modes on start (#3954)
* feat(dev-server): per-service dev/prod env modes on start

A session picks one .env and now applies a per-service overlay on top of it:
`start --prod buzz` moves Buzz to production and leaves everything else on dev.
Groups come from a gitignored env-modes.local (db, buzz, search, signals,
redis today), so adding a service is an edit to that file rather than to code.

Every group defaults to dev. DEVSERVER_PROD_GROUPS in the skill .env moves a
default when a dev service is unreliable, and a flag beats both. The flag
applies to that start only, including when it takes over a dead session, so a
prod choice never leaks into the next bare start. Asking for different modes
while a session is already running that worktree is refused rather than
answered with the running session.

The orchestrator, payments, S3, ClickHouse, the notifications DB, the feeds
proxy and OpenSearch have no dev counterpart at all, so the summary prints them
after every mode line: dev mode is not a claim that nothing here is production.

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

* fix(dev-server): close the ways an env mode could lie about itself

Review found four paths where a mistake resolved quietly to the wrong
environment, three of them ending on production while the session reported dev.

A malformed section header (`[db prod]`, a missing bracket) matched neither the
section nor the KEY=VALUE pattern, and the parser left the previous section open
— so every following key landed under it, filing the production DATABASE_URL as
`[db.dev]`. Any parse error now closes the open section, and the daemon refuses
to start at all when the definitions file did not parse cleanly, rather than
logging a warning nobody reads and carrying on.

`all` was expanded before group names were validated, so `--prod all,typo`
discarded the typo and `--prod all` against a machine with no env-modes.local
reported success having moved nothing. Names are checked first, `all` errors
when it matches no groups, and it now selects only groups that have that mode
instead of failing the whole start over one dev-only group. `--prod=` with an
empty value errors like the spaced form already did. A DEVSERVER_PROD_GROUPS
entry that matches no group produces a note instead of silently leaving the
service on dev — the failure an operator setting it is specifically trying to
avoid.

The busy-session refusal compared flag text, so it refused `--prod all` against
a session started with every group named, and accepted a bare start against a
session whose modes a bare start would no longer produce. It compares resolved
modes now. That refusal also broke the dashboard: console.mjs sends no modes, so
a 409 sent it down a fallback that picked whichever session was first in the map
— another worktree, another branch, its logs, silently. It watches the refused
session when one is named and otherwise looks only at this worktree.

The example understated each group's key set: db without DATABASE_IS_PROD (which
gates the S3 delete paths) or DATABASE_REPLICA_LONG_URL left a session writing
to production and reading long queries from dev, and redis without REDIS_CLUSTER
would point a single-node client at a cluster.

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

* fix(dev-server): stop the mode guard reporting a stale or partial answer

Second review round, on the hardening itself.

The 409 mismatch check read `session.modes`, which only start() writes. On the
takeover path the request's modes were stored and restart() awaited, so for the
seconds spent stopping and reclaiming the port the session still described the
run being torn down — long enough for another agent's bare start to match it and
be handed 200 for a session coming up on production. The resolved modes are
stamped before the await now, and cleared at the top of start() so a session
that errors out stops reporting an env it never applied. The endpoint also
resolved against a cached skill config while start() reloaded it, so an edited
DEVSERVER_PROD_GROUPS made the two disagree; it reloads first.

`--prod all --dev search` — everything on prod except one — threw a conflict,
because the conflict check ran after `all` expanded. Expansion now skips groups
named on the other flag and conflicts are judged on explicit names only, so the
exception form works and `--prod all --dev all` is rejected on its own terms. A
group named beside `all` that has no section for that mode is an error rather
than a silent fallback to dev.

Parser errors no longer echo the offending line: they reach an HTTP 400 body and
a log buffer any agent can read, and a stray connection-string line splits on
its first `=` with the password on the left.

The example understated three more groups: search without METRICS_SEARCH_* is a
second Meilisearch the app writes to, redis without REDIS_SYS_SENTINELS leaves
the system client on production (with sentinels set, the URL supplies only the
password), and db without DATAPACKET_DATABASE_RO_URL keeps a read pool on
production Postgres. The dashboard counts a `base` group as production, since
base means the .env value stood and this .env is production.

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

* test(dev-server): pin the env-mode rules, and stop the guard overreaching

Third review round. The resolver was the most intricate code on the branch and
had no test file — the checks proving it lived in a scratchpad, which protects
nothing after this session ends. `scripts/__tests__/dev-server-env-modes.test.ts`
now covers the resolution rules, the overlay, the parser and the session
comparison, beside the queue's tests for the same reason (the daemon is not in
the app's module graph). Both mutations fail legibly: reverting the parser fix
prints `expected 'PROD-host' to be 'dev-host'`, and reverting the all/dev
exception prints the wrong mode map.

Fixes with it:

The session comparison compared whole maps, so ADDING a group to env-modes.local
made every already-running session disagree with every new bare start — `start`,
which is meant to be idempotent, would 409 everywhere until each session was
restarted. It compares the groups both sides resolved. A group only one side
knows about is a definitions edit, not two agents wanting different environments.

`DEVSERVER_PROD_GROUPS` is pinned on the session when it is created rather than
re-read on every start, because start() also runs unattended — a branch switch,
a crash restart — and an edit to that file would otherwise move a LIVE session
onto production with nobody having asked.

A start refused over its env modes answered 201 and the CLI exited 0, so
`start && curl` proceeded as though a server had come up. It answers 5xx with the
session's own error line, on both the new-session and the reuse path.

The takeover stamp is `pendingModes`, separate from `modes`: a crashed session's
process can still be serving on the old env for the length of the port wait, so
`modes` stays true to what is running and the mismatch check reads what is coming.

`PROD_ONLY_GROUPS` is filtered by what the definitions file defines, so defining
`[s3.*]` no longer produces a summary that says `s3=dev` and `always prod: s3` on
the same line.

Documented rather than fixed: the auth hub is one shared process reading its own
apps/auth/.env, so it cannot follow a per-session db mode — a `--prod db` login
mints a token for a user id from the other database. And the build dir is keyed on
branch, not mode, so changing search/signals mode on a warm `.next` can leave the
previous NEXT_PUBLIC_* host inlined in client chunks; keying it on mode would
multiply an 8GB cache per combination.

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

* fix(dev-server): close the fail-open half of the mode comparison

Fourth review round, and the worst finding was in the previous round's fix.

Comparing only the groups both sides resolved made an EMPTY request match
anything: with env-modes.local momentarily absent — an atomic-rename save, a
move, a git clean — a bare start resolved to no groups, the mismatch guard did
not fire, and the daemon handed back the running session as though it were the
dev one asked for, while it ran on production. The comparison is asymmetric now.
A group the request has and the session does not is an added section and still
matches, which is what keeps `start` idempotent; a group the SESSION has and the
request lost does not.

`auth-hub` stays in the always-prod list whatever env-modes.local says. Defining
[auth-hub.dev] cannot move the hub — it is a separate shared process reading its
own apps/auth/.env — so letting a config edit delete that warning would leave the
summary asserting something the mechanism cannot deliver.

`--prod all` now leaves a note naming each group it could not move for want of a
section, instead of being the one unhonourable request in this resolver that says
nothing. The takeover stamp is cleared in a `finally`, so a restart that throws
cannot leave a session advertising modes that will never be applied. And the
dashboard reports a refused start even when it finds a session to attach to
anyway — a malformed definitions file returns no session in the body, and
repainting a healthy dashboard over the pre-edit session read as the edit having
worked.

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

* fix(dev-server): close the remaining fail-open directions in the mode guard

Fifth review round, and again the findings were the mirror image of the previous
round's fixes. Each is the direction where a mistake resolved to production
while the summary said otherwise.

A session that resolved NO groups came up before env-modes.local existed and is
running the base .env. `every` over no keys is vacuously true, so once the file
appeared, the first bare start was told its dev modes held while the process was
still on the .env. An empty running side now matches only an empty request.

`--dev all` left any group with no [x.dev] section on the .env — production —
and succeeded with a note, while `--dev <group>` for the same group threw. The
safety direction should not be the one that fails open, so it throws too.
Skipping stays a note in the prod direction, where the group stays on dev.

`formatModeSummary` dropped a prod-only service from the always-prod tail
whenever it resolved to `base`. `base` means no section applied and the .env
value stands, which for those services is production — so a half-written
definition removed the warning exactly when it was most needed. Only a group
that actually moved leaves the list now.

`auth-hub` was described as unmovable but nothing enforced it: defining
[auth-hub.dev] resolved normally and wrote its keys into the MAIN app's env,
repointing the app at a hub that is not listening while deleting the warning.
Defining it is refused at load.

And a reuse that threw left the failed request's --prod set pinned to the
session, so the next unattended restart would have brought it up on production
off the back of a start that errored. The previous overrides are restored on
that path.

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

* fix(dev-server): a named group the session never had is a mismatch

Sixth review round, two more directions into the same guard.

Leniency about a group the running session does not know is only defensible
when nobody asked for it. `--dev search` against a session that started before
[search.*] was defined came back 200 "existing", CLI exit 0, while search was
still on the base .env — production Meilisearch. Groups named on a flag are a
hard requirement now; a bare start against a session missing a newly-defined
group is still idempotent, which was the point of the leniency.

The override restore lived only in `catch`, but start() reports a mode failure
by setting status and RETURNING rather than throwing — the exact case the 500
branch handles. So a failed `--prod db` left the session pinned to it, and the
dashboard's restart key would have brought it up on production off a start the
CLI reported as failed.

Also: the dashboard's mismatch notice was a 3-second flash, after which a
dashboard attached to a session that is not what it asked for looked identical
to a healthy one — it carries a sticky `!env` marker in the header now. And the
redis example restates REDIS_CLUSTER_NODES and REDIS_SYS_SENTINEL_PASSWORD in
both blocks, since the overlay applies onto the .env rather than onto the other
mode, and the file's own rule says every key of a service moves together.

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

---------

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 08:32:56 -06:00
Justin Maier e056028dbe fix(dev-server): stop a queued test run from enqueueing itself
The daemon runs `pnpm run test:unit:run`, which is the script that routes to
the queue, and passed its own environment through. The child saw
CIVITAI_TEST_QUEUE still set, enqueued a second run and waited for it while
the first held the slot that run needed: a deadlock on every full-suite run.

One wedged run held the only slot for 20 minutes with another agent's run
queued behind it. Raising concurrency does not fix it — each logical run would
then occupy two slots, so agents starting together refill them with waiters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 21:36:24 -06:00