Skip to content

feat(epic-1): contract, fixtures and the canvas-2d perf verdict - #9

Merged
jundymek merged 9 commits into
masterfrom
epic/1-workspace-scaffold
Aug 11, 2026
Merged

feat(epic-1): contract, fixtures and the canvas-2d perf verdict#9
jundymek merged 9 commits into
masterfrom
epic/1-workspace-scaffold

Conversation

@jundymek

Copy link
Copy Markdown
Owner

Epic 1 delivers the thing every later story stands on: a pnpm workspace of six packages, the frozen analysis.json contract with generated types and a compiled validator, the edge-case fixtures and deterministic fixture repos that viz builds against until M2, and a measured answer on whether canvas 2D holds 2,000 nodes.

The four stories

PR story what landed
#1 1.1-workspace-scaffold six-package pnpm workspace, tsconfig chain, ESLint 9 flat config with the AD-4 determinism ban, MIT licence, CONTRIBUTING
#2 1.2-contract-schema draft 2020-12 JSON Schema, types generated from it with a CI freshness check, validateAnalysis (ajv, compiled once), SUPPORTED_SCHEMA_MAJOR, the four intermediate pipeline types
#3 1.3-contract-fixtures six validating fixtures incl. a seeded 100-module/2,000-file synthetic document, fixtures/README.md, a fixture-repo builder with pinned dates and identity, root pretest wiring
#4 1.4-perf-spike scripted three-phase measurement harness, the report, and the verdict

Planned 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 41fb746 in the supervisor's own worktree, not inherited from the story agents' reports:

  • pnpm install --frozen-lockfile — clean
  • pnpm lint — clean, "All matched files use Prettier code style!"
  • pnpm typecheck — clean across six packages
  • pnpm test — contract 103, viz 50, scanner 2, deps 2, githist 2, cli 2 = 161 tests, 0 failures
  • pnpm build — tsup (cli) and vite (viz) both succeed; browser bundle 131.25 kB

CI 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

  • AD-2 package boundaries intact. contract → ajv only; scanner, deps and githist → contract only; viz → contract + d3-force; cli → contract + the three analyzers.
  • viz has no dependency on any analyzer — the precondition for parallel work in Epic 2, verified by grep rather than by intent.
  • AD-4 determinism rule still armedDate.now and Math.random banned in scanner/deps/githist, viz and cli exempt as designed.
  • The one shared-file collision was negotiated, not stumbled into. 1.3 changed 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 (no node: import in the contract runtime path, types: [] preserved, browser bundle builds with the contract imported).
  • sprint-status.yaml did 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 committed synthetic-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

  1. The merge click on this PR.
  2. Acceptance of the 1.4 perf verdict. Story 1.4's task 7 requires it to stay unticked by the agent, and it is unticked. This is the one that matters: accepting it freezes 2.5, 3.3 and 3.5 against this mechanism; rejecting it amends them toward the cosmos.gl path. sprint-status.yaml deliberately holds 1.4-perf-spike: review until you record acceptance — the lifecycle source of truth must not read "go" before the decision.
  3. The six unticked steps in docs/dev/epic-1/1.4-perf-spike/MANUAL_TESTING.md — your eyeball pass over the spike. All six unticked, as the spec requires.
  4. The M1 milestone text in sprint-status.yaml still 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.md items are all M2/M3-era and Epic 1 closes none of them; none has been ticked.

Known issues and debt carried forward

  • CI stays workflow_dispatch-only until story 4.2.
  • The 1.4 measurements were taken at DPR 1; a Retina run rasterises 4× the pixels — worth one confirming run when 2.5 has a real renderer.
  • Cross-module file imports are drawn but not simulated in the spike's unfold phase, a consequence of per-module wakes; flagged for the Epic 3 unfold story.
  • The contract deliberately leaves two thresholds to later stories: co-change count is bounded >= 1 rather than >= 3 (githist 2.3 must enforce it) and repo.stats.languages is a share map (scanner 2.1 must normalize).
  • Two harness defects found during the run remain unfixed and will recur wave-for-wave: spawn-agent.sh writes the default DOCS_TASK_DIR instead of the project's, and TA_PROJECT leaking 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)

* 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).
@jundymek
jundymek merged commit f850d94 into master Aug 11, 2026
@jundymek
jundymek deleted the epic/1-workspace-scaffold branch August 14, 2026 09:31
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.

1 participant