feat(contract): analysis.json schema, generated types and validator - #2
Merged
Merged
Conversation
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)
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)
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)
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)
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)
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)
This was referenced Aug 11, 2026
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.
Adds the
analysis.jsoncontract: the frozen draft 2020-12 JSON Schema, theTypeScript types generated from it, a compiled-once ajv validator and the
intermediate pipeline types. This is the single interface between modules
(AD-1) — every other package now has one machine-checked truth to build
against.
The schema is the source; the types are generated (AD-9).
scripts/generate-types.mjswritessrc/generated/analysis.ts, which iscommitted with a do-not-edit banner and excluded from ESLint. A new CI step
regenerates it and runs
git diff --exit-code, so hand-edited drift cannotreach the base branch. The hand-written intermediate types derive from the
generated ones via
Pick/Omit, which turns a future schema change into acompile error in scanner/deps/githist/cli rather than silent divergence.
validateAnalysiscompiles the schema once at module load, because both thecli (validating what it just assembled) and the Viewer (validating what it just
fetched) sit on a hot path. It narrows to
AnalysisDocumenton success, andmissing-property errors are rewritten to point at the absent field
(
/nodes/0/loc) instead of at its parent object, so an AD-7 cli message canprint the pointer verbatim.
The package stays environment-neutral (AC-5): no
node:import anywhere undersrc/, enforced structurally by a tsconfig with neither DOM lib nor nodetypes, and verified by the viz browser bundle building with the contract
imported.
scripts/is build tooling, outside the runtime path, and uses Nodefreely.
Decisions worth a reviewer's attention
countis bounded>= 1in the schema, not>= 3. FR-7 marksthat threshold as tunable analyzer policy; baking it into a frozen schema
would make tuning a version bump. githist (2.3) must enforce it — the
contract will not.
repo.stats.languagesis a share map (name →0..1), per FR-7's"language shares". scanner (2.1) must normalize, not emit raw counts.
lastChangedAtis nullable — the zero-history fixture has no commitinstant and AD-13 forbids clock reads, so there is no fallback. Story 1.3's
zero-history fixture relies on this.
description/descriptionSourceare typednull, required-present, perFR-8. Widening them to
string | nullstays the describe layer's own minor,non-breaking act.
date-timeformat is deliberately narrower than RFC 3339: it rejectsa leap second (
23:59:60), which §5.6 permits. Nothing on either side canrepresent one — git stores POSIX epoch seconds, and
new Date("2026-12-31T23:59:60Z")isInvalid Date— so accepting it wouldmove the failure from validation time to a NaN in the Viewer's panel. The
governing rule is stated as an invariant and asserted by test: every instant
the contract accepts parses with
new Date(...). Five Codex review passesdrove this, each one correct; the format began as a digit-placement regex that
accepted
2026-99-99T25:61:61Z. Year0000is rejected on the same reasoning:it parses, but a zero year is an upstream parsing bug, not a commit timestamp.
docs/dev/epic-1/…path rather than thegeneric checklist's
docs/features/epic-1-omdb-data-layer/…, which belongsto another project's map.
The schema-change protocol — when a change is minor versus major, and what a
major bump obliges — is written up in
docs/dev/epic-1/1.2-contract-schema/README.md.Manual verification
Not applicable, and deliberately so rather than skipped: this story ships no
UI, no CLI entry point and no observable runtime behaviour. Its whole surface is
a JSON Schema, types generated from it, and one pure function
unknown -> ValidationResult. Every acceptance criterion is a statement aboutwhich documents validate — exactly what a unit test expresses and what clicking
cannot. The two criteria that are not pure unit assertions were executed anyway:
pnpm --filter @gitnebula/contract generatethengit diff --exit-code -- packages/contract/src/generated→ no drift.pnpm build— the vite browser bundlebuilds with the contract imported, and
src/contains nonode:import.Automated: 82 tests pass; root
pnpm lint,pnpm typecheck,pnpm testandpnpm buildare clean. The suite was mutation-checked rather than merelyobserved passing — eight separate mutations of the validator and schema each
fail between 1 and 35 tests (details in the story's Dev Agent Record).
Codex review: 4 findings total, 4 fixed, 0 dismissed, 0 escalated, across five
passes — every one on the
date-timeformat. The concurrent auto-review of thesame tree reported no actionable regressions.
Refs: 1.2-contract-schema
Agent: bob (terminal-agents, claude-opus-5)