feat(epic-1): contract, fixtures and the canvas-2d perf verdict - #9
Merged
Conversation
* chore(cli): scaffold pnpm workspace with six packages Refs: 1.1-workspace-scaffold Agent: alice (terminal-agents, claude-fable-5) * fix(repo): raise Node floor to 20.19 to match Vite 8 engines Refs: 1.1-workspace-scaffold Agent: alice (terminal-agents, claude-fable-5) * chore(repo): disable CI triggers per maintainer decision Refs: 1.1-workspace-scaffold Agent: alice (terminal-agents, claude-fable-5)
* feat(contract): analysis.json schema, generated types and validator
The frozen draft 2020-12 JSON Schema for analysis.json, the TypeScript types
generated from it, a compiled-once ajv validator and the intermediate pipeline
types every analyzer hands to cli. This is the single interface between
modules (AD-1); everything else in the workspace builds against it.
The schema is the source and the types are generated (AD-9):
scripts/generate-types.mjs writes src/generated/analysis.ts, it is committed
with a do-not-edit banner, and CI regenerates it and fails on any diff, so
hand-edited drift cannot reach master. The hand-written intermediate types
derive from the generated ones via Pick/Omit, making a schema change a compile
error in the analyzers rather than silent divergence.
validateAnalysis compiles once at module load, since both the cli and the
Viewer validate on a hot path, and narrows to AnalysisDocument on success.
Missing-property errors are rewritten to point at the absent field instead of
its parent object, so a cli message can print the JSON Pointer verbatim.
The package stays environment-neutral (AC-5): no node: import anywhere under
src/, enforced by a tsconfig with no DOM lib and no node types, and verified
by the viz browser bundle building with the contract imported.
Refs: 1.2-contract-schema
Agent: bob (terminal-agents, claude-opus-5)
* fix(contract): validate date-time semantics, not just its shape
The `date-time` format was a digit-placement regex, so it accepted
`2026-99-99T25:61:61Z` for `repo.analyzedAt` and `node.lastChangedAt`. The
schema declares `format: "date-time"`, which promises RFC 3339 semantics — and
the Viewer feeds those strings to `new Date(...)`, so an out-of-range instant
would have surfaced as NaN in the panel rather than as a validation failure at
the point the document was produced.
The format now checks component ranges and the calendar: month 1-12, a day
that exists in that month of that year (so 2026-02-29 is rejected while
2024-02-29 passes), hour <= 23, minute <= 59 and offset bounds. Second 60 stays
valid, deliberately: RFC 3339 §5.6 permits a leap second and `git log`
reproduces whatever a commit recorded.
Adds 24 cases covering valid instants (toISOString, no fraction, lowercase
t/z, half-hour offsets, leap day, leap second) and the out-of-range and
malformed ones. Found by `codex exec review`.
Refs: 1.2-contract-schema
Agent: bob (terminal-agents, claude-opus-5)
* fix(contract): place a leap second at the only instant one occurs
Second 60 was accepted at any time of day, so `2026-08-11T10:00:60Z` passed.
RFC 3339 §5.6 permits a leap second, but one exists only at 23:59:60 UTC — a
midday `:60` is not a timestamp any clock or `git log` can produce, and
accepting it made the semantic promise of the format a lie in the same way the
shape-only regex did.
Placement is now checked after normalizing the local time by its offset, so
`2027-01-01T00:59:60+01:00` is accepted (it is that instant seen from +01:00)
while `2026-12-31T23:59:60+01:00` is not (22:59:60 UTC).
Found by the second `codex exec review` pass over this branch.
Refs: 1.2-contract-schema
Agent: bob (terminal-agents, claude-opus-5)
* fix(contract): accept only instants the Viewer can parse
Reject second 60 outright, rather than permitting it at 23:59:60 UTC. RFC 3339
§5.6 allows a leap second, but nothing on either side of this contract can
represent one: git stores POSIX epoch seconds, which have no leap second, and
`new Date("2026-12-31T23:59:60Z")` returns Invalid Date. Accepting the form
therefore preserved the exact NaN-in-the-panel failure that validating
date-time ranges was added to prevent — the format promised semantics it did
not deliver.
The rule is now stated as the invariant it always was: every instant the
contract accepts must parse with `new Date(...)`. A test asserts that over the
whole table of valid instants, so a future widening that admits an unparseable
one fails even if nobody adds it to the negative cases.
This makes the contract deliberately narrower than RFC 3339 on one point, which
the PR body flags. It also deletes the offset-normalizing arithmetic the
leap-second placement check had needed.
Found by the third `codex exec review` pass; the Invalid Date behaviour was
confirmed in node before acting on it.
Refs: 1.2-contract-schema
Agent: bob (terminal-agents, claude-opus-5)
* docs(contract): record the DOCS_TASK_DIR harness defect
The pre-PR docs gate demanded docs/backend/<epic>/... though this project
declares DOCS_TASK_DIR='docs/dev'. The project value is not propagated into
.env.agent at spawn, so every agent here inherits the default and the gate asks
for a path the project rules forbid. Recorded in the Dev Agent Record with the
commands that reproduce it, the workaround, and what the workaround cost.
Not fixed here: it is one line in spawn-agent.sh, outside this story.
Refs: 1.2-contract-schema
Agent: bob (terminal-agents, claude-opus-5)
* fix(contract): reject year zero in a date-time
Year 0000 parses in JavaScript, so the Date-parseability invariant does not
exclude it, but no repository has commits from it. A zero year arriving here is
an upstream parsing bug, and the contract should say so at validation time
rather than pass it through to the Viewer.
Found by a further `codex exec review` pass. That loop is now stopped
deliberately: the concurrent auto-review of the same tree reported no
actionable regressions, and the remaining candidates are theoretical rather
than reachable by any producer in this pipeline. Recorded in the Dev Agent
Record.
Refs: 1.2-contract-schema
Agent: bob (terminal-agents, claude-opus-5)
* test(contract): edge-case fixtures and deterministic fixture repos Ship the six edge-case analysis.json fixtures viz builds against until M2, and the fixture-repo mechanism githist tests will run on. Fixtures cover an empty graph, a single module, a module-level import cycle (a->b->c->a), a module with zero files, a zero-history repo (all churn 0) and a seeded synthetic 100-module / 2,000-file document that is the perf yardstick (ADR-0006 / FR-14). The validation test loops over the fixtures directory, so a fixture added later is covered without touching test code; all six validate against the frozen 1.2 schema. The repo builder replays crafted commits with pinned dates and identities into gitignored test-fixtures/.generated/, and the history deliberately carries every trait AC-4 names: a rename, a multi-file commit, a file with three authors, a file whose commits predate the pinned analysis window (AD-13) and a Python + TS mix. Building twice yields identical hashes, which a test asserts. The root pretest runs the builder, so a fresh clone passes pnpm test with no manual step. Contract tsconfig now excludes test files, with a sibling tsconfig.test.json granting them node types; the shipped runtime path keeps types: [] so an accidental node: import still fails to compile (1.2 AC-5 / AD-11). Refs: 1.3-contract-fixtures Agent: pamela (terminal-agents, claude-opus-5) * fix(contract): derive synthetic module edge weight from emitted file pairs ADR-0005 defines a module edge's weight as the number of underlying file-level import pairs, and the generator claimed the sample count instead. Sampling can draw the same file pair twice; repeats were dropped, so four module edges in the committed fixture promised more pairs than the document contained (mod-023/ -> mod-011/ claimed 4 with 3 beneath it, and three others). A viz story testing module aggregation against this fixture would have been debugging the fixture. The weight is now derived from the pairs actually emitted rather than from the draw count, which also avoids a resampling loop that could spin when a module pair has fewer distinct file combinations than the draw asks for. Regenerated output changes exactly those four weights. Guarded by a new cross-record test over every fixture in the directory: schema validation cannot catch this, since each record is individually valid. Verified red first — it reported the mod-023/ mismatch by name against the old fixture. Found by codex review. Refs: 1.3-contract-fixtures Agent: pamela (terminal-agents, claude-opus-5) * style(contract): escape the NUL map-key separator so the sources stay text Both files carried a literal NUL byte inside a template literal, used as a separator that cannot occur in a node id. Git classifies any blob containing a NUL as binary, so these two files showed up as "Bin 6411 -> 6864 bytes" in the previous commit instead of a reviewable diff — and would have done the same in the PR, in git blame and in every patch tool. Writing the same code point as a unicode escape produces an identical string at runtime and leaves the source pure ASCII. The synthetic fixture regenerates byte-identically, which the existing test asserts. Refs: 1.3-contract-fixtures Agent: pamela (terminal-agents, claude-opus-5) * docs(contract): record the codex round in the story spec Refs: 1.3-contract-fixtures Agent: pamela (terminal-agents, claude-opus-5)
* feat(viz): performance spike — 2000-node canvas feasibility Measures d3-force + Barnes-Hut + viewport-scoped unfold at 100 modules / 2,000 files across three scripted phases. Verdict: canvas-2d viable, 59 fps sustained in every phase, phase (c) worst-frame work 4.4 ms of a 16.7 ms budget. Local wake confirmed at 0.0 px non-member displacement. Refs: 1.4-perf-spike Agent: arnold (terminal-agents, claude-opus-5) * fix(viz): render the full unfolded graph in the spike's phase c Rendered links were derived from live wakes, so a module's import edges stopped being drawn once its wake settled and phase (c) under-measured render cost. Rebuilt from the unfolded set, which also picks up imports crossing between two unfolded modules. Re-measured over three runs and committed all three as evidence. Refs: 1.4-perf-spike Agent: arnold (terminal-agents, claude-opus-5) * fix(viz): collapse modules on viewport exit in phase c Phase (c) only ever unfolded. A module that left the viewport kept its file nodes in the shown set and its id in `unfolded`, so the scripted pan accumulated 1,820 of 2,000 files — the opposite of ADR-0006's "off-screen modules stay collapsed", and therefore not the mechanism the spike claims to measure. It never showed up in the numbers, which held 59 fps either way, and MANUAL_TESTING.md asserted the collapse behaviour in an eyeball step that no measurement backed. `unfoldTransition` now diffs the wanted unfold set against the current one each frame, and wakes are per-module rather than per-batch so a collapse is a deletion instead of a re-seed of the survivors. Below UNFOLD_ZOOM the wanted set is empty and the same diff collapses everything, which is the ADR's other half. Peak live file nodes fall from 1,820 to 260-300 and phase (c) mean frame work from ~1.3 ms to ~0.5 ms: the earlier evidence was conservative rather than optimistic, so the verdict is unchanged. Two more ways a run could look valid and mean nothing are now detected instead of assumed away. `SettleGate` separates "settled" from "hit the 3,000-frame cap", which previously continued as if settled while (b) and (c) measured a layout frozen mid-motion. `VisibilityWatch` catches a backgrounded tab: Chrome throttles rAF there, which stretched the first re-run's phase (a) from 4 s to over 90 s while producing perfectly well-formed output. Both, plus the fixture source, fold into a single `runValid` field the report tells the reader to check first. Story 1.3 having merged, the committed evidence is now three runs against its `synthetic-100x2000.json` (1,095 edges) rather than the spike's seeded generator (4,132 edges). The fixture lives outside the spike's Vite root, so a dev-server plugin serves it at /fixture.json; without it the fetch 404s and the loader falls back silently, which is what the previous version did. The fallback survives only as a last resort and marks its run invalid. Verdict is unchanged and the margins are wider: VERDICT: canvas-2d viable (>= 55 fps sustained in phases b and c) Refs: 1.4-perf-spike Agent: arnold (terminal-agents, claude-opus-5) * fix(viz): keep the spike's camera maths in CSS pixels The canvas backing store is sized `cssSize * devicePixelRatio` so the render is native-resolution, but drawing and the viewport calculation then used those device pixels directly. On a Retina display that divides the effective zoom by the DPR: the same camera script covers DPR-times more world, unfolds more modules and draws smaller nodes, so a 2x run is not comparable with a 1x run. `cssViewport` converts the backing store back to CSS pixels for everything camera-related, and `draw` sets a DPR transform on the context. The regression test asserts the visible world rect and the unfolded module set are identical at 1x and 2x, and that feeding the raw device size changes them — otherwise the test would pass on a harness that had the bug and simply never looked. This machine reports DPR 1, where the transform is the identity, so the numbers do not move. It matters because the report's own caveat asks for a confirming run at DPR 2, and before this that run would have measured a different camera. All three committed results files were re-measured on the fixed code and still carry runValid: true. Phase (c) mean frame work 0.57-0.62 ms, worst observed frame 4.1 ms against a 16.7 ms budget; peak 13-15 modules unfolded, 260-300 of 2,000 file nodes live. VERDICT: canvas-2d viable (>= 55 fps sustained in phases b and c) Refs: 1.4-perf-spike Agent: arnold (terminal-agents, claude-opus-5) * fix(viz): re-partition non-members when a wake settles The AC-5 instrumentation splits on-screen files into "a live wake is simulating this" and "this is frozen", and measures displacement over the frozen set. That split was recomputed only when a module entered or left the viewport — but a wake also disappears when it settles, and its files become frozen at that moment. They stayed outside the measured set until the next viewport transition, which is exactly the interval where a freshly frozen file would still be drifting if the local-wake mechanism were leaky. The zero was measured over a set chosen to exclude the most suspect nodes. `refreshNonMembers` now runs on every change to the wake set, from either cause. Re-measured over the strictly larger set, three runs: still 0.0 px per-frame, 0.0 px cumulative, 0.0 px for pinned modules. The claim is unchanged and now rests on evidence that could have falsified it. All three committed results files come from this code. Phase (c) mean frame work 0.53-0.58 ms, worst observed frame 4.0 ms against a 16.7 ms budget. VERDICT: canvas-2d viable (>= 55 fps sustained in phases b and c) Refs: 1.4-perf-spike Agent: arnold (terminal-agents, claude-opus-5) * fix(viz): watch visibilitychange, not just throttled frames The visibility guard counted frames that observed `document.hidden`, which catches a throttled background tab but not a suspended one. When Chrome stops requestAnimationFrame outright, no frame callback runs while hidden at all: the counter stays at zero and the run is marked valid in exactly the case the guard exists to reject. `VisibilityWatch` now also takes a `visibilitychange` event, and seeds `everHidden` from the constructor so a run started on a hidden tab is invalid from frame zero. `documentEverHidden` joins `hiddenFrames` in the results — both are kept, because they say different things: frames observed means throttled, event-without-frames means suspended. Re-measured; all three committed runs report documentEverHidden: false, runValid: true. Phase (c) mean frame work 0.55-0.58 ms, worst observed frame 4.2 ms against a 16.7 ms budget, peak 13-14 modules unfolded. VERDICT: canvas-2d viable (>= 55 fps sustained in phases b and c) Refs: 1.4-perf-spike Agent: arnold (terminal-agents, claude-opus-5)
* docs(repo): epic 1 retrospective and story rows to done Supervisor deliverables for epic/1-workspace-scaffold, written at closure after all four story PRs (#1-#4) merged. - Epic 1 retrospective: delivery metrics, six post-run observations with cause and fix, technical debt carried forward, next-epic readiness and eight action items. First retrospective in this repository, so it also establishes the location for the next one. - sprint-status.yaml: the four Epic 1 rows flipped review -> done. Written once, by the supervisor, at closure rather than per story. Refs: epic-1-contract-foundations Agent: superman (terminal-agents, claude-opus-5) * docs(repo): keep 1.4 in review until the verdict is accepted Codex review of the closure commit raised a P1 against it, and it was right: flipping `1.4-perf-spike` to `done` while that story's owner gate is still open makes the lifecycle source of truth signal that Epic 2/3 work may proceed before the decision that releases it. The row goes back to `review` with the reason in-file, on the maintainer's call. The other three Epic 1 rows stay `done`. The retrospective records the finding. Refs: epic-1-contract-foundations Agent: superman (terminal-agents, claude-opus-5)
Second codex finding on the closure commits, also correct: the readiness section said Epic 2 could launch while the same document records 2.5 / 3.3 / 3.5 as gated on the maintainer accepting the 1.4 verdict. Readiness now names 2.1-2.4 as launchable and keeps 2.5, 3.3 and 3.5 blocked until acceptance, without pre-empting the decision either way. Refs: epic-1-contract-foundations Agent: superman (terminal-agents, claude-opus-5)
Third codex finding on the closure commits, also correct: the readiness section listed 2.4 among the stories that can launch now, but its spec declares Depends_on: [2.1-scanner-core] and requires the scanner merged into its base first. Readiness now names 2.1, 2.2 and 2.3 as immediately launchable and puts 2.4 behind 2.1, the same shape 1.2 had behind 1.1 in this epic. Refs: epic-1-contract-foundations Agent: superman (terminal-agents, claude-opus-5)
Fourth codex finding on the closure commits, and the previous fix overshot: 2.4 does not have to wait for the scanner merge to start. Its spec directs the agent to build the config resolver and progress plumbing while waiting, and to integrate against contract types with inert stubs until each analyzer lands. Readiness now separates launching the story from wiring the scanner-dependent pipeline, and says why Epic 2's chain is not serial the way Epic 1's was. Refs: epic-1-contract-foundations Agent: superman (terminal-agents, claude-opus-5)
Walked the six manual checks in MANUAL_TESTING.md against a valid run and ticked them, and closed story 1.4's owner gate: the maintainer accepts VERDICT: canvas-2d viable (>= 55 fps sustained in phases b and c).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Epic 1 delivers the thing every later story stands on: a pnpm workspace of six packages, the frozen
analysis.jsoncontract with generated types and a compiled validator, the edge-case fixtures and deterministic fixture repos thatvizbuilds against until M2, and a measured answer on whether canvas 2D holds 2,000 nodes.The four stories
validateAnalysis(ajv, compiled once),SUPPORTED_SCHEMA_MAJOR, the four intermediate pipeline typesfixtures/README.md, a fixture-repo builder with pinned dates and identity, rootpretestwiringPlanned merge order and actual merge order are identical: #1 → #2 → #3 → #4, no reordering, no story re-scoped, no PR abandoned or reverted. PRs #5–#8 are the supervisor's closure bookkeeping (retrospective, status rows and three corrections to them).
Verification of the epic head
Run against
41fb746in the supervisor's own worktree, not inherited from the story agents' reports:pnpm install --frozen-lockfile— cleanpnpm lint— clean, "All matched files use Prettier code style!"pnpm typecheck— clean across six packagespnpm test— contract 103, viz 50, scanner 2, deps 2, githist 2, cli 2 = 161 tests, 0 failurespnpm build— tsup (cli) and vite (viz) both succeed; browser bundle 131.25 kBCI is
workflow_dispatch-only under the maintainer's 2026-08-11 decision to skip GitHub Actions until story 4.2, so all verification is local by design. No run URL is quoted here because no workflow ran.Cross-story integration, checked at the merged head
vizhas no dependency on any analyzer — the precondition for parallel work in Epic 2, verified by grep rather than by intent.Date.nowandMath.randombanned in scanner/deps/githist, viz and cli exempt as designed.packages/contract/tsconfig.json, which 1.2 owns; the agents agreed it before either landed, and 1.2's environment-neutrality criterion still holds at this head (nonode:import in the contract runtime path,types: []preserved, browser bundle builds with the contract imported).sprint-status.yamldid not collide — no agent overwrote another's row; it was written once, at closure.The 1.4 verdict
VERDICT: canvas-2d viable (>= 55 fps sustained in phases b and c)— 59 sustained fps in all three phases across three runs against 1.3's committedsynthetic-100x2000.json; worst frame in the unfold phase 4.2 ms against a 16.7 ms budget; non-member displacement 0.0 px against the 0.5 px/frame Settled bound. Read from the three raw result files, not from the report's table.Eight codex findings landed on this story, every one about the measurement rather than the code under measurement. The largest: phase (c) never collapsed a module on viewport exit, so the pan accumulated 1,820 of 2,000 files — the opposite of ADR-0006, and therefore not the mechanism the story claims to measure. It hid well, because the run held 59 fps either way. Fixed and re-measured; the earlier numbers were conservative rather than optimistic, so the verdict did not move — but the evidence behind it is materially different from the first version of the report. The committed measurements and the final code are the same commit (
fd1260f), checked rather than assumed.PENDING OWNER — none of these is marked passed anywhere
sprint-status.yamldeliberately holds1.4-perf-spike: reviewuntil you record acceptance — the lifecycle source of truth must not read "go" before the decision.docs/dev/epic-1/1.4-perf-spike/MANUAL_TESTING.md— your eyeball pass over the spike. All six unticked, as the spec requires.sprint-status.yamlstill says "CI green", which the CI-skip decision deviates. Reconciling a milestone definition is yours; an agent should not.This project declares no security gate and no other agent-unrunnable gate, so the list is complete rather than truncated. The
human-review-checklist.mditems are all M2/M3-era and Epic 1 closes none of them; none has been ticked.Known issues and debt carried forward
workflow_dispatch-only until story 4.2.countis bounded>= 1rather than>= 3(githist 2.3 must enforce it) andrepo.stats.languagesis a share map (scanner 2.1 must normalize).spawn-agent.shwrites the defaultDOCS_TASK_DIRinstead of the project's, andTA_PROJECTleaking into agent shells silently disables agent identity in the hooks. Both are written up with cause and fix in the retrospective.Full detail:
docs/implementation-artifacts/epic-1-contract-foundations/epic-1-retrospective.md, in the diff.Refs: epic-1-contract-foundations
Agent: superman (terminal-agents, claude-opus-5)