Skip to content

feat(baton): per-type → endpoint keying for generated type files (closes #313)#320

Merged
montfort merged 1 commit into
mainfrom
feat/baton-generated-type-keying-313
Jun 26, 2026
Merged

feat(baton): per-type → endpoint keying for generated type files (closes #313)#320
montfort merged 1 commit into
mainfrom
feat/baton-generated-type-keying-313

Conversation

@montfort

Copy link
Copy Markdown
Contributor

What & why

Closes #313. The coherence engine keyed a contract's shape (fields, enum variants) by the nearest /api/... anchor at or above a declaration. On a generated types file (web/src/api/types.gen.ts) holding all API types in one file with sparse endpoint comments, every interface collapsed onto a few coarse ContractIds (on Sentinel: all onto services), so the #304 field/enum mismatch (C2/C3) could never isolate on the right contract — the dogfood's "0 blocking by design".

This adds a higher-priority keying source — call-site binding — keying a type to its route from the usage site, where the association actually lives:

api.get<HealthSnapshot>(`/services/${serviceId}/health`)   // → services.health
api.get<ListIncidentsResponse>('/incidents')               // → incidents

It is the issue's second proposed option, and more general than parsing OpenAPI (Sentinel does not even commit an OpenAPI file — it is gated).

Changes

  1. Call-site binding pre-pass (codescan::collect_bindings) — mines IDENT<Type>('/route') repo-wide into Type → ContractId. Conservative (R6): ambiguous (two routes) → dropped; only a quoted first arg starting with / counts, so useState<T>('loading'), useQuery<T>({…}), JSX <Table<Row> never bind.
  2. Binding-first keying (codescan::extract_file) — binding(type) ?? nearest_anchor(line); a bound declaration keys even when its file has no anchor.
  3. Enum-by-reference — an interface keyed to a route attributes the type X = 'A'|'B' its fields reference to the same contract (needed for C3).
  4. Import-specifier false anchors (scan::scan_endpoints) — /api/ glued to an alias/relative path (import … from '@/api/types.gen') is skipped; it was producing a bogus types.gen contract.
  5. normalize_endpoint — strips query strings (?…) and template params (${id}).

Verification

  • cargo test -p straymark-baton ✓ — 47 tests (+6: 3 unit, 3 integration); clippy clean.
  • New fixture tests/fixtures/generated-types-project/: an anchorless types.gen.ts whose two interfaces bind to two routes via call sites → C2 + C3 fire on services.health, the matching services sibling stays silent. Proves the "Done when" (per-contract isolation).
  • Sentinel dogfood (read-only, git status unchanged): consumers now spread across services.health / audit.records / incidents / incidents.reopen instead of one services blob; bogus types.gen contract gone. Run stays 0 blocking — correctly: Sentinel's types.gen.ts was remediated to match the backend, so no drift, no false positives (R6 holds). The Cross-spec decision propagation: a post-MVP backend decision silently diverged a later consumer spec's contract — the system view should link them #304 C4 finding still fires.

Full write-up: experiment-baton/AILOG-2026-06-26-001.

Scope boundary

Consumer-side (generated type) keying was #313's chartered scope and is done. The dogfood surfaced the symmetric producer-side gaphuma registers routes in one block while response structs sit far below, so the Go producer mis-keys (services.health shows producer=None). It does not change the Sentinel result (remediated → 0 blocking either way) and the capability is already proven on the fixture. Filed as #319.

🤖 Generated with Claude Code

 #313)

Key a contract's shape by call-site binding (api.get<T>('/route')) in addition
to nearest endpoint anchor, so a generated types.gen.ts whose interfaces carry
no per-type anchor no longer collapses onto one coarse contract. C2/C3 now fire
for the correct contract (proven on a new anchorless fixture). Also resolves the
union an interface references to the same contract (for C3), skips /api/ inside
module specifiers (import '@/api/...' was a bogus `types.gen` contract), and
makes normalize_endpoint strip query strings + ${id} template params.

Sentinel dogfood (read-only): consumers now spread across services.health /
audit.records / incidents / incidents.reopen instead of one `services` blob; run
stays 0 blocking, correctly (the file was remediated to match the backend). The
symmetric producer-side gap (huma route-registration separated from output
structs) is filed as #319.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

Baton: per-type → endpoint contract keying for generated type files (unblocks C2/C3)

1 participant