Skip to content

feat(contract): analysis.json schema, generated types and validator - #2

Merged
jundymek merged 6 commits into
epic/1-workspace-scaffoldfrom
story/1.2-contract-schema
Aug 11, 2026
Merged

feat(contract): analysis.json schema, generated types and validator#2
jundymek merged 6 commits into
epic/1-workspace-scaffoldfrom
story/1.2-contract-schema

Conversation

@jundymek

@jundymek jundymek commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Adds the analysis.json contract: the frozen draft 2020-12 JSON Schema, the
TypeScript 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.mjs writes src/generated/analysis.ts, which is
committed 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 cannot
reach the base branch. The hand-written intermediate types derive from the
generated ones via Pick/Omit, which turns a future schema change into a
compile error in scanner/deps/githist/cli rather than silent divergence.

validateAnalysis compiles the schema once at module load, because both the
cli (validating what it just assembled) and the Viewer (validating what it just
fetched) sit on a hot path. It narrows to AnalysisDocument on success, and
missing-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 can
print the pointer verbatim.

The package stays environment-neutral (AC-5): no node: import anywhere under
src/, enforced structurally by a tsconfig with neither DOM lib nor node
types, and verified by the viz browser bundle building with the contract
imported. scripts/ is build tooling, outside the runtime path, and uses Node
freely.

Decisions worth a reviewer's attention

  • Co-change count is bounded >= 1 in the schema, not >= 3. FR-7 marks
    that 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.languages is a share map (name → 0..1), per FR-7's
    "language shares". scanner (2.1) must normalize, not emit raw counts.
  • lastChangedAt is nullable — the zero-history fixture has no commit
    instant and AD-13 forbids clock reads, so there is no fallback. Story 1.3's
    zero-history fixture relies on this.
  • description/descriptionSource are typed null, required-present, per
    FR-8. Widening them to string | null stays the describe layer's own minor,
    non-breaking act.
  • The date-time format is deliberately narrower than RFC 3339: it rejects
    a leap second (23:59:60), which §5.6 permits. Nothing on either side can
    represent one — git stores POSIX epoch seconds, and
    new Date("2026-12-31T23:59:60Z") is Invalid Date — so accepting it would
    move 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 passes
    drove this, each one correct; the format began as a digit-placement regex that
    accepted 2026-99-99T25:61:61Z. Year 0000 is rejected on the same reasoning:
    it parses, but a zero year is an upstream parsing bug, not a commit timestamp.
  • Per-story docs follow the spec's docs/dev/epic-1/… path rather than the
    generic checklist's docs/features/epic-1-omdb-data-layer/…, which belongs
    to 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 about
which documents validate — exactly what a unit test expresses and what clicking
cannot. The two criteria that are not pure unit assertions were executed anyway:

  • AC-2 (type freshness): ran the CI step locally —
    pnpm --filter @gitnebula/contract generate then
    git diff --exit-code -- packages/contract/src/generated → no drift.
  • AC-5 (environment neutrality): ran pnpm build — the vite browser bundle
    builds with the contract imported, and src/ contains no node: import.

Automated: 82 tests pass; root pnpm lint, pnpm typecheck, pnpm test and
pnpm build are clean. The suite was mutation-checked rather than merely
observed 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-time format. The concurrent auto-review of the
same tree reported no actionable regressions.

Refs: 1.2-contract-schema
Agent: bob (terminal-agents, claude-opus-5)

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)
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