Skip to content

Make the session-trace tests hermetic, and cover the Bun path in CI#157

Open
justinstimatze wants to merge 9 commits into
fiorastudio:masterfrom
justinstimatze:test/hermetic-suite
Open

Make the session-trace tests hermetic, and cover the Bun path in CI#157
justinstimatze wants to merge 9 commits into
fiorastudio:masterfrom
justinstimatze:test/hermetic-suite

Conversation

@justinstimatze

Copy link
Copy Markdown
Contributor

Stacked on #156, which is stacked on #151 — so this shows their commits too until they merge. The five commits that are mine are the top five.

Follow-up to the two bugs in #156. Both were invisible to the checks the project had, in opposite directions, and this closes those gaps rather than just fixing the symptoms.

The session-trace tests now use a fixture home

resolveSessionTrace takes an optional home (defaulting to homedir()), and the tests build a temp fixture tree instead of reading the developer's real ~/.claude. Both production callers are single-arg, so nothing else changes.

Reading the real home made the outcome a property of the machine: on a runner with no transcript history the tests passed in milliseconds and asserted almost nothing, and on a working machine they read gigabytes. Bounding the reads in #156 made that survivable; this makes it absent. The fixture also lets the tests assert things they previously couldn't, since nothing about the developer's home was knowable — resolution to cwd and label, a non-matching transcript being ignored, Codex fallback with its session id, malformed ids. 2 tests → 6, and >90s → 12ms.

A Bun leg in the CI matrix

ccHashString shells out to bun and falls back to FNV-1a when it's absent. A bare runner only ever takes the fallback, so the Bun branch would have been covered in name only. Measured against master, on the identical-bones bug:

master, Bun on PATH   →  1 failed   (bug visible)
master, Bun absent    →  50 passed  (green)

So the CI in #156 would have reported success on that code. One leg with Bun installed executes the branch for real. Excluded from the Node 20 leg since it doesn't vary by Node version.

The general point is worth keeping in mind past this one path: a branch gated on a tool the runner lacks isn't covered by CI, it's hidden from it, and the green check actively misleads.

Two smaller things

permissions: contents: read on the workflow — it only reads the tree, and update-stats.yml already sets its scope explicitly, so this matches. Plus workflow_dispatch for parity.

And I trimmed my own spread test from 40 sampled userIds to 12. Each roll spawns a bun subprocess, and that file is already timing-fragile for it — on master returns valid bones took 36s against a 15s budget in one run. Twelve keeps all the discrimination that matters, since the bug collapsed every id to one vector.

Worth a separate look sometime: a unit test probably shouldn't shell out per call at all.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WFi4A9eG3CWPjDDMvdmUfy

InfinitePortaldev and others added 9 commits July 20, 2026 22:02
The tuning in fiorastudio#129 left the older detector spec tests behind — they
still assume the stricter pre-tuning thresholds, so npm test fails on a
fresh clone. This updates the fixtures and expectations to the current
values. Tests only, no behavior changes.
…ion (fiorastudio#150)

Some detectors are specializations of others. `echo_chamber`'s predicate is
`load_bearing_vibes`' predicate plus three conjuncts (user speaker,
assistant-only supports, no pushback), so every echo case is also a
load-bearing case — one situation at two resolutions, not two competing
findings.

Selection takes candidates[0] within a category, so the general finding won
purely on the order detectors run in. The per-anchor cooldown made it
self-reinforcing rather than self-correcting: it keys on anchor_claim_id
without regard to type, so emitting the general finding also blocked the
specific one on that anchor. `echo_chamber` could not surface at all.

`SUBSUMES` names the containment; `runAllDetectors` drops a general finding
when a more specific one fired on the same anchor. Thresholds are untouched —

Also fixes two defects fiorastudio#129 exposed in the same area:

- `unchallenged_chain` fired on the unchallenged *suffix* of a challenged
  chain. The walk started from every node, so once the minimum length dropped
  4→3 a suffix below the challenged pair became a candidate in its own right.
  It now starts only from maximal chains, matching its documented "anchors on
  the head" intent.
- The "below threshold" / "below minimum length" fixtures hardcoded edge
  counts that fiorastudio#129's retuning turned into *at*-threshold cases, so they went
  green while testing nothing. They derive from REASONING_CONFIG now.

Three fixtures were mislabeled: they seeded a user vibes claim backed by
assistants with no pushback — a textbook echo chamber — and read as
load-bearing only because of the ordering bug. They now use an
assistant-authored anchor so they test what they claim to.

Builds on fiorastudio#151, which fixes the same five failures by pinning the current
behaviour and explicitly defers the real fix here. The two fixture sets
conflict and should not both be merged as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFi4A9eG3CWPjDDMvdmUfy
…ntical bones

The CC-compat rescue path reproduces Claude Code's `Bun.hash()` by shelling
out to `bun -e`, passing the userId as a trailing argument and reading it back
as `process.argv[1]`. Bun does not forward trailing arguments to `-e` scripts:
inside the eval, `process.argv` is `['bun', '<cwd>/[eval]']` and nothing more.
`process.argv[1]` was therefore a constant path string, identical on every
call, and the userId never reached the hash.

Measured on master with Bun installed: 300 distinct userIds produced exactly
one distinct stat vector. Rarity, species, eye and stats were the same for
every rescued companion. Silent because the roll stayed perfectly
deterministic — which is what the determinism test checks — and because the
FNV-1a fallback path, used on machines without Bun, was always correct.

The input now travels through the environment instead of argv, preserving the
no-string-interpolation property that argv was chosen for. After: 200/200
distinct, still deterministic per userId.

Already-rescued companions keep their stored bones, so no migration is needed,
but every existing rescue was a clone of the same roll.

The existing test compared two userIds, which reads as an unlucky fixture
precisely when the bug is total. Replaced with a 40-id spread assertion.

Not covered by any open PR — fiorastudio#152 and fiorastudio#153 fix the other fresh-clone failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFi4A9eG3CWPjDDMvdmUfy
…test suite

resolveSessionTrace walks ~/.claude/projects, ~/.claude/sessions and
~/.codex/sessions to map a session id back to its cwd. It needs the first
JSONL record (Claude) or the first dozen (Codex), but read every candidate
file in full to get them, so cost scaled with total transcript volume instead
of with the header actually parsed.

On a machine with real history — 646 files, 8.5 GB — that meant reading 8.5 GB
to extract a few hundred kilobytes of headers. reasoning/session-trace.test.ts
never finished, and because vitest runs files serially it took the rest of the
suite down with it; `npm test` appeared to hang with no failing test to point
at. Fresh clones and CI runners have no transcript history, which is why this
went unnoticed.

Reads are now bounded to a 32 KiB head per file, enough for a first JSONL
record by a wide margin. Whole-file .json parsing keeps its own 256 KiB
ceiling since it is parsed as a single object. A truncated read discards its
trailing partial line rather than trying to parse half a record.

Resolution is unchanged — the real workspace still resolves to the same cwd
and source, in 4ms. The test goes from a >90s hang to 2s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFi4A9eG3CWPjDDMvdmUfy
The repo has no CI — .github/workflows contained only the stats updater — so
nothing has been running the test suite on PRs or merges. fiorastudio#129 shipped a red
suite in May and it stayed red until a contributor cloned the repo in July and
ran npm test by hand (fiorastudio#150, fiorastudio#151, fiorastudio#152, fiorastudio#153).

Node 20 (the engines floor) and 24, npm ci / build / test. `npm ci` compiles
better-sqlite3 from source when no prebuild matches the runner ABI, which is
the same path users hit on install, so a broken native build fails here too.

MERGE ORDER: this gates every PR the moment it lands, and the suite is not
green yet. fiorastudio#152 fixes the two doctor check-count failures and fiorastudio#153 fixes the
two Penguin frame-width/pose failures; those are the only four remaining, and
both PRs are open. Merge them first, or this red-gates unrelated work.

Note that CI could not have caught the bug fixed in the preceding commit: a
fresh runner has no ~/.claude transcript history, so resolveSessionTrace's
unbounded scan costs nothing there. Green CI is not evidence the suite is
sound on a real machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFi4A9eG3CWPjDDMvdmUfy
…veloper's

resolveSessionTrace takes an optional `home`, defaulting to homedir(), and the
tests point it at a temporary fixture tree they build and tear down.

Before, these tests searched the real ~/.claude. That made the outcome a
property of the machine rather than the code: on a CI runner with no
transcript history they passed in milliseconds and asserted almost nothing; on
a working machine they read every transcript in full — 8.5 GB across 646 files
here. Because the scan is synchronous, it blocked the vitest worker so the
5s test timeout could not fire, and the suite presented as a hang with no
failing test to point at. Bounding the reads made that survivable; this makes
it absent.

The fixture also lets the tests assert behaviour they previously could not,
since nothing about the developer's home was known: a Claude transcript
resolves to its cwd and label, a non-matching transcript is ignored, Codex
metadata is used as fallback with its session id, a malformed session id
yields empty fields, and a match on the first record survives an unparseable
tail.

Deliberately NOT asserted: that reads are bounded. A file small enough for a
unit test is cheap to read whole, so no timing assertion could distinguish
the bounded implementation from the unbounded one. That is a performance
property and pretending to test it would be worse than leaving it untested.

Test time for this file: >90s (blocking) to 12ms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFi4A9eG3CWPjDDMvdmUfy
ccHashString shells out to `bun` to reproduce Claude Code's Bun.hash(), and
falls back to FNV-1a when Bun is not installed. A bare GitHub runner has no
Bun, so it only ever exercised the fallback — the Bun branch would have been
covered by CI in name only.

That is not hypothetical. Measured against master, on the identical-bones bug
fixed earlier in this branch:

  master, Bun on PATH      →  1 failed   (bug visible)
  master, Bun absent       →  50 passed  (completely green)

So the CI added in the preceding commit would have reported success on code
where every rescued companion rolled the same rarity, species, eye and stats.
One matrix leg with Bun installed executes the branch for real. Verified that
the fixed code passes both ways, so the leg tests the path rather than just
pinning an environment.

Excluded from the Node 20 leg: the Bun branch does not vary with Node version,
so a second copy would buy nothing.

The general lesson is worth stating, because it generalises past this one
path: a conditional gated on a tool the runner lacks is not covered by CI, it
is hidden from it, and the green check actively misleads. Any future branch
taken only when some external binary is present needs a leg that provides it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFi4A9eG3CWPjDDMvdmUfy
The workflow only reads the tree, but declared no `permissions` block, so it
inherited the repository default. On a public repo with 21 forks that means a
PR-triggered run could hold a wider token than it needs, and the scope would
widen silently if the repository default ever changed. `contents: read` is
declared explicitly instead.

This also matches the convention already set by update-stats.yml, which
declares `contents: write` for the one job that genuinely needs it. Adding
workflow_dispatch for parity with that workflow's manual trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFi4A9eG3CWPjDDMvdmUfy
Each rollWithCCCompat spawns a `bun` subprocess. That is ~9ms warm but swings
to seconds under load, and these tests were already timing-fragile for it —
on master, before any change here, `returns valid bones` took 36s against a
15s budget in one run. A 40-id sample added 40 more spawns to that file and
made neighbouring tests likelier to blow their timeouts.

Twelve ids keep all the discrimination that matters. The bug collapsed every
id to a single stat vector, so the assertion fails at 1-of-12 with enormous
margin; nothing is gained by sampling forty.

The underlying fragility is untouched and worth a separate look: a unit test
should probably not shell out per call at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFi4A9eG3CWPjDDMvdmUfy
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants