From 289bf0f6e606be8c280f2662d482e2d495f87c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Villase=C3=B1or=20Montfort?= <195970+montfort@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:55:37 -0600 Subject: [PATCH] =?UTF-8?q?feat(baton):=20per-type=20=E2=86=92=20endpoint?= =?UTF-8?q?=20keying=20for=20generated=20type=20files=20(closes=20#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key a contract's shape by call-site binding (api.get('/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) --- .../03-sentinel-dogfood-report.md | 23 +- ...6-06-26-001-baton-generated-type-keying.md | 110 ++++++++ experiment-baton/src/codescan.rs | 237 ++++++++++++++++-- experiment-baton/src/scan.rs | 44 +++- .../internal/svc/handler.go | 25 ++ .../web/src/api/types.gen.ts | 21 ++ .../web/src/features/dashboard.ts | 10 + experiment-baton/tests/generated_keying.rs | 65 +++++ 8 files changed, 504 insertions(+), 31 deletions(-) create mode 100644 experiment-baton/AILOG-2026-06-26-001-baton-generated-type-keying.md create mode 100644 experiment-baton/tests/fixtures/generated-types-project/internal/svc/handler.go create mode 100644 experiment-baton/tests/fixtures/generated-types-project/web/src/api/types.gen.ts create mode 100644 experiment-baton/tests/fixtures/generated-types-project/web/src/features/dashboard.ts create mode 100644 experiment-baton/tests/generated_keying.rs diff --git a/experiment-baton/03-sentinel-dogfood-report.md b/experiment-baton/03-sentinel-dogfood-report.md index ecdd899..bb82448 100644 --- a/experiment-baton/03-sentinel-dogfood-report.md +++ b/experiment-baton/03-sentinel-dogfood-report.md @@ -88,14 +88,21 @@ intended-not-implemented gaps (DevPortal, UsageGuard) and a naming drift Phase 1 is a calibrated first cut, not a finished product. Known gaps: -1. **Contract keying on generated type files.** Sentinel's `web/src/api/types.gen.ts` - holds *all* API types in one file with sparse endpoint anchors, so many - interfaces collapse onto a few `ContractId`s. The #304 *field/enum-level* health - mismatch (C2/C3) therefore isn't isolated on Sentinel the way it is on the - fixture — it merges into a coarse `services` contract and is currently dropped - (no non-test producer). **Follow-up:** map each generated type to its endpoint - (response-type ↔ route), e.g. via an OpenAPI/codegen manifest, so C2/C3 fire per - real contract. *This is why the Sentinel run has 0 blocking findings.* +1. **Contract keying on generated type files.** ✅ *Addressed (#313, + `AILOG-2026-06-26-001`).* Sentinel's `web/src/api/types.gen.ts` held *all* API + types in one file with sparse endpoint anchors, so every interface collapsed + onto the coarse `services` contract. Keying now adds a **call-site binding** + source (`api.get(`/services/${id}/health`)` → `services.health`) + that beats nearest-anchor and works on anchorless generated files; the + consumers now spread across the correct contracts (`HealthSnapshot → + services.health`, `SearchRecordsResponse → audit.records`, …). The Sentinel run + stays **0 blocking** — but now *correctly*: its `types.gen.ts` was remediated to + match the Go backend, so there is no drift to flag (de-collapsing introduced no + false positives). **Remaining (sibling follow-up #319):** the *producer* side has the + symmetric gap — `huma` registers routes in one block while the response struct + sits far below, so the Go producer mis-keys away from `services.health`. The + same binding idea (route-registration → handler → output struct) is needed for + C2/C3 to fire end-to-end on an *un-remediated* `huma`-style repo. 2. **C1 is inherently fuzzy.** Free-form `.specify/memory` naming conflates real gaps with architectural concepts; kept info-grade. **Follow-up:** optional explicit component→path mapping (in memory or `model.yml`) to promote C1 to a diff --git a/experiment-baton/AILOG-2026-06-26-001-baton-generated-type-keying.md b/experiment-baton/AILOG-2026-06-26-001-baton-generated-type-keying.md new file mode 100644 index 0000000..b7e61f1 --- /dev/null +++ b/experiment-baton/AILOG-2026-06-26-001-baton-generated-type-keying.md @@ -0,0 +1,110 @@ +--- +id: AILOG-2026-06-26-001 +title: Baton — per-type → endpoint keying for generated type files (call-site binding) +status: accepted +created: 2026-06-26 +agent: claude-code-opus-4.8 +confidence: high +review_required: true +risk_level: low +eu_ai_act_risk: not_applicable +nist_genai_risks: [] +iso_42001_clause: [] +lines_changed: 280 +files_modified: [experiment-baton/src/codescan.rs, experiment-baton/src/scan.rs, experiment-baton/tests/generated_keying.rs, experiment-baton/tests/fixtures/generated-types-project] +observability_scope: none +tags: [baton, coherence, keying, codescan, speckit, generated-types] +related: [AILOG-2026-06-25-005, CHARTER-01-coherence-bridge] +--- + +# AILOG: Baton — per-type → endpoint keying for generated type files + +## Summary + +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`) that holds all API types in one +file with sparse endpoint comments, every interface collapsed onto a few coarse +`ContractId`s (on Sentinel: all of them onto `services`), so the #304 field/enum +mismatch (C2/C3) could never isolate on the right contract. That is why the +Sentinel dogfood reported **0 blocking by design**. + +This adds a second, higher-priority keying source — **call-site binding** — that +keys a *type* to its *route* from the usage site, exactly where the association +actually lives: + +```ts +api.get(`/services/${serviceId}/health`) // HealthSnapshot → services.health +api.get('/incidents') // ListIncidentsResponse → incidents +``` + +This is the issue's second proposed option ("associate a TS type to a route via +the API client call site"). It is more general than parsing OpenAPI — Sentinel +does not even commit an OpenAPI file (it is gated; see its `FU-005-001`). + +## What changed + +1. **Call-site binding pre-pass** (`codescan::collect_bindings`). A repo-wide scan + of TS files mines `IDENT('/route')` into a `Type → ContractId` map. + Conservative (R6): a type bound to two *different* routes is dropped as + ambiguous; only a quoted first argument that starts with `/` counts (so + `useState('loading')`, `useQuery({…})` and JSX `` never bind). + +2. **Binding-first keying** (`codescan::extract_file`). Each declaration resolves + its contract as `binding(type) ?? nearest_anchor(line)`. An anchorless + declaration with no binding still yields nothing (unchanged conservatism), but + a *bound* declaration now keys even when its file has no anchor at all. + +3. **Enum-by-reference resolution.** A generated file binds the *interface* to a + route, but the `type X = 'A' | 'B'` it uses sits in a sibling decl with no + anchor/binding of its own. An interface keyed to a contract now attributes the + union its fields reference to the same contract — needed for C3 to see the + consumer's enum variants. + +4. **Import-specifier false anchors** (`scan::scan_endpoints`). `import … from + '@/api/types.gen'` was scanned as an endpoint and produced a bogus `types.gen` + contract. A `/api/` glued to an identifier/alias/relative path (preceded by an + alphanumeric, `_`, `@`, or `.`) is now skipped — it is a module specifier, not + an HTTP endpoint. This also tightens spec-prose scanning. + +5. **`normalize_endpoint` robustness.** Strips a query string (`?…`) and drops + template params (`${id}`) in addition to `{id}` / `:id`, so call-site relative + paths normalize to the same `ContractId` a Go handler comment yields. + +## Verification + +- `cargo test -p straymark-baton` ✓ — 47 tests (6 new: 3 unit in `codescan`, 3 + integration in `generated_keying.rs`), no regressions; `cargo 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` (orphan `status`/`cpu`, enum `OPERATIONAL…` vs + `GREEN…`); the matching `services` sibling stays silent. Proves per-contract + isolation — the issue's "Done when". +- **Sentinel dogfood** (read-only, `git status` unchanged): the generated + consumers now spread across the correct contracts instead of collapsing onto + `services` — `HealthSnapshot → services.health`, `SearchRecordsResponse → + audit.records`, `IncidentDetailResponse → incidents`, `ReopenResponse → + incidents.reopen` — and the bogus `types.gen` contract is gone. The run stays + **0 blocking**, correctly: Sentinel's `types.gen.ts` was remediated to match the + Go backend, so there is no drift to flag (de-collapsing introduced no false + positives — R6 holds). The #304 C4 cross-spec finding still fires. + +## Scope boundary — producer-side keying gap (filed separately) + +Keying the *consumer* (generated type) was #313's chartered scope and is done. The +dogfood surfaced the **symmetric** gap on the producer side: Sentinel's Go backend +uses the `huma` framework, which registers every route in one block +(`huma.Get(api, "/api/v1/services/{id}/health", h.getServiceHealth)`) while the +response struct `getServiceHealthOutput` is defined far below. Nearest-anchor +mis-keys it away from `services.health` (the contract shows `producer=None`). The +same binding idea applies — bind the route in the registration call to the +handler, then the handler's output struct — but it is framework-shaped and beyond +the #313 scope. It does **not** change the Sentinel result (the repo is remediated → +0 blocking either way), and the capability is already demonstrated on the fixture +(whose Go producer carries a normal comment anchor). Filed as **#319** so C2/C3 +can fire end-to-end on an un-remediated `huma`-style repo. + +## EU AI Act Considerations + +Not applicable — local developer tooling; no automated decision-making, no +personal data, no model inference. Read-only over the target tree (NFR1). diff --git a/experiment-baton/src/codescan.rs b/experiment-baton/src/codescan.rs index 0afb4d0..56dc0cf 100644 --- a/experiment-baton/src/codescan.rs +++ b/experiment-baton/src/codescan.rs @@ -1,9 +1,20 @@ //! Heuristic contract-shape extraction from code (Go producers, TS consumers). //! //! Not a real parser — a tolerant, line-oriented heuristic good enough to key a -//! contract's *shape* (field names, enum variants) to a `ContractId` derived -//! from the nearest `/api/...` endpoint anchor. Conservative by design (R3/R6): -//! a file with no endpoint anchor yields nothing rather than a guess. +//! contract's *shape* (field names, enum variants) to a `ContractId`. Two keying +//! sources, in priority order: +//! +//! 1. **Call-site binding** (#313): a usage-grounded `api.get( +//! `/services/${id}/health`)` binds the *type* to the *route*. This is what +//! keys a generated types file (`types.gen.ts`) whose declarations carry no +//! endpoint anchor of their own — they'd otherwise all collapse onto one +//! coarse contract (or be dropped entirely). +//! 2. **Nearest endpoint anchor**: the `/api/...` reference at or above a +//! declaration. Used when no call-site binds the type. +//! +//! Conservative by design (R3/R6): a declaration with neither a binding nor an +//! anchor yields nothing rather than a guess; a type bound to two different +//! routes is dropped as ambiguous. //! //! Cross-language keying (spec Q4): the join key is the **normalized endpoint**, //! the one anchor a Go handler and a TS type provably share. @@ -27,7 +38,8 @@ const SKIP_DIRS: &[&str] = &[ /// Scan all Go/TS source under `root` for contract shapes. pub fn scan(root: &Path) -> Vec { - let mut out = Vec::new(); + // Read every code file once; keep (rel, lang, content). + let mut files: Vec<(String, Lang, String)> = Vec::new(); for path in walk_code(root) { let rel = path .strip_prefix(root) @@ -42,17 +54,126 @@ pub fn scan(root: &Path) -> Vec { Some("ts") | Some("tsx") => Lang::Ts, _ => continue, }; - out.extend(extract_file(&rel, &content, lang)); + files.push((rel, lang, content)); + } + + // Pass 1: repo-wide type→endpoint bindings mined from TS call sites — what + // keys an anchorless generated types file (#313). + let bindings = collect_bindings(&files); + + // Pass 2: extract shapes, binding-first keying. + let mut out = Vec::new(); + for (rel, lang, content) in &files { + out.extend(extract_file(rel, content, *lang, &bindings)); } out.sort_by(|a, b| (&a.contract, &a.source.file).cmp(&(&b.contract, &b.source.file))); out } -fn extract_file(rel: &str, content: &str, lang: Lang) -> Vec { - let anchors = anchors(content); - if anchors.is_empty() { - return Vec::new(); +/// Mine `IDENT('/route')` call sites across TS files into a +/// `Type → ContractId` map (the binding keying source for #313). Conservative +/// (R6): a type bound to two *different* contracts is dropped as ambiguous. +fn collect_bindings(files: &[(String, Lang, String)]) -> BTreeMap { + let mut seen: BTreeMap> = BTreeMap::new(); + for (_, lang, content) in files { + if *lang != Lang::Ts { + continue; + } + for line in content.lines() { + for (ty, cid) in scan_type_route_bindings(line) { + match seen.get(&ty) { + None => { + seen.insert(ty, Some(cid)); + } + // already bound to a different route → ambiguous, drop it + Some(Some(prev)) if *prev != cid => { + seen.insert(ty, None); + } + _ => {} + } + } + } + } + seen.into_iter().filter_map(|(k, v)| v.map(|c| (k, c))).collect() +} + +/// Extract `(TypeName, ContractId)` from a single line's `…('/path')` call +/// sites. Recognizes the typed-client shape generated API clients use; ignores +/// JSX (``) and generics with no leading-`/` route literal argument. +fn scan_type_route_bindings(line: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + for (i, c) in line.char_indices() { + if c != '>' { + continue; + } + let Some(lt) = line[..i].rfind('<') else { + continue; + }; + let Some(ty) = valid_type_name(&line[lt + 1..i]) else { + continue; + }; + // require `(` after the closing `>` (optional whitespace) + let rest = line[i + 1..].trim_start(); + let Some(after_paren) = rest.strip_prefix('(') else { + continue; + }; + // the first argument must be a quoted `/`-path literal + let Some(path) = first_path_literal(after_paren) else { + continue; + }; + let cid = normalize_endpoint(&path); + if !cid.is_empty() { + out.push((ty, cid)); + } } + out +} + +/// A single PascalCase/`_`-leading type identifier (optionally `[]`-suffixed), +/// else `None`. Rejects multi-token generics and comparison noise. +fn valid_type_name(s: &str) -> Option { + let s = s.trim().strip_suffix("[]").unwrap_or_else(|| s.trim()).trim(); + let mut chars = s.chars(); + let first = chars.next()?; + if !(first.is_ascii_uppercase() || first == '_') { + return None; + } + s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + .then(|| s.to_string()) +} + +/// The first argument, if it is a quoted (or backtick) literal whose value +/// starts with `/`. Returns the literal's content (interpolations intact). +fn first_path_literal(arg: &str) -> Option { + let s = arg.trim_start(); + let q = s.chars().next()?; + if q != '\'' && q != '"' && q != '`' { + return None; + } + let from = q.len_utf8(); + let rel = s[from..].find(q)?; + let lit = &s[from..from + rel]; + lit.starts_with('/').then(|| lit.to_string()) +} + +/// The leading type identifier of a type hint: `HealthState[]` → `HealthState`, +/// `Record` → `Record`. +fn base_type(hint: &str) -> &str { + let h = hint.trim(); + let end = h + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(h.len()); + &h[..end] +} + +fn extract_file( + rel: &str, + content: &str, + lang: Lang, + bindings: &BTreeMap, +) -> Vec { + let anchors = anchors(content); let decls = match lang { Lang::Go => parse_go(content), Lang::Ts => parse_ts(content), @@ -63,21 +184,50 @@ fn extract_file(rel: &str, content: &str, lang: Lang) -> Vec { _ => ShapeRole::Consumer, }; + // Index named enum decls so an interface bound to a route can pull in the + // `type X = 'A'|'B'` its fields reference — that union has no anchor/binding + // of its own in a generated types file (needed for C3 enum mismatches). + let union_by_name: BTreeMap<&str, &EnumDef> = decls + .iter() + .filter_map(|d| { + d.enums + .iter() + .find_map(|e| e.name.as_deref().map(|n| (n, e))) + }) + .collect(); + let mut by_contract: BTreeMap = BTreeMap::new(); - for d in decls { - let Some(cid) = contract_for_line(&anchors, d.line) else { + for d in &decls { + // Binding-first (a usage-grounded type→route link), then nearest anchor. + let Some(cid) = bindings + .get(&d.name) + .cloned() + .or_else(|| contract_for_line(&anchors, d.line)) + else { continue; }; let agg = by_contract.entry(cid).or_default(); if agg.symbol.is_none() { agg.symbol = Some(d.name.clone()); } - for f in d.fields { + for f in &d.fields { if !agg.fields.iter().any(|x| x.name == f.name) { - agg.fields.push(f); + agg.fields.push(f.clone()); + } + // Attribute a referenced union's variants to this contract. + if let Some(hint) = &f.type_hint { + if let Some(e) = union_by_name.get(base_type(hint)) { + if !agg.enums.iter().any(|x| x.name == e.name) { + agg.enums.push((*e).clone()); + } + } + } + } + for e in &d.enums { + if !agg.enums.iter().any(|x| x.name == e.name) { + agg.enums.push(e.clone()); } } - agg.enums.extend(d.enums); } by_contract @@ -363,6 +513,10 @@ fn walk_code(root: &Path) -> Vec { mod tests { use super::*; + fn no_bindings() -> BTreeMap { + BTreeMap::new() + } + #[test] fn go_struct_and_enum_extracted() { let src = "// GET /api/v1/services/{id}/health\n\ @@ -376,7 +530,7 @@ mod tests { \tA HealthState = \"OPERATIONAL\"\n\ \tB HealthState = \"IDLE\"\n\ )\n"; - let shapes = extract_file("h.go", src, Lang::Go); + let shapes = extract_file("h.go", src, Lang::Go, &no_bindings()); assert_eq!(shapes.len(), 1); let s = &shapes[0]; assert_eq!(s.contract, "services.health"); @@ -397,7 +551,7 @@ mod tests { \tstatus: HealthStatus;\n\ \tcpu?: number;\n\ }\n"; - let shapes = extract_file("t.ts", src, Lang::Ts); + let shapes = extract_file("t.ts", src, Lang::Ts, &no_bindings()); assert_eq!(shapes.len(), 1); let s = &shapes[0]; assert_eq!(s.contract, "services.health"); @@ -411,6 +565,55 @@ mod tests { #[test] fn no_endpoint_anchor_yields_nothing() { let src = "type Foo struct {\n\tBar string `json:\"bar\"`\n}\n"; - assert!(extract_file("x.go", src, Lang::Go).is_empty()); + assert!(extract_file("x.go", src, Lang::Go, &no_bindings()).is_empty()); + } + + // ---- call-site binding (#313) ---------------------------------------- + + #[test] + fn scan_bindings_parses_typed_client_call_sites() { + let b = scan_type_route_bindings(" queryFn: () => api.get(`/services/${id}/health`),"); + assert_eq!(b, vec![("HealthSnapshot".to_string(), "services.health".to_string())]); + + let b = scan_type_route_bindings("api.post('/incidents', input)"); + assert_eq!(b, vec![("Incident".to_string(), "incidents".to_string())]); + + // query string stripped + let b = scan_type_route_bindings("api.get('/audit/records?action_type=AI_AUTO')"); + assert_eq!(b, vec![("SearchRecordsResponse".to_string(), "audit.records".to_string())]); + } + + #[test] + fn scan_bindings_ignores_jsx_and_argless_generics() { + // JSX generic component, no route → not a binding. + assert!(scan_type_route_bindings(" ").is_empty()); + // generic call with a non-path first argument → not a binding. + assert!(scan_type_route_bindings("const [x] = useState(null);").is_empty()); + assert!(scan_type_route_bindings("useQuery({ queryKey })").is_empty()); + } + + #[test] + fn binding_keys_anchorless_type_and_resolves_its_enum() { + // A generated types file: no endpoint anchor anywhere. + let src = "export type Semaphore = 'GREEN' | 'RED';\n\ + export interface DashboardComponent {\n\ + \tname: string;\n\ + \tstatus: Semaphore;\n\ + \tcpu?: number;\n\ + }\n"; + // Without a binding the anchorless file yields nothing (conservative). + assert!(extract_file("types.gen.ts", src, Lang::Ts, &no_bindings()).is_empty()); + + // With a call-site binding the interface keys to its route, and the + // referenced union's variants ride along (for C3). + let bindings = BTreeMap::from([("DashboardComponent".to_string(), "services.health".to_string())]); + let shapes = extract_file("types.gen.ts", src, Lang::Ts, &bindings); + assert_eq!(shapes.len(), 1); + let s = &shapes[0]; + assert_eq!(s.contract, "services.health"); + let names: Vec<&str> = s.fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, vec!["name", "status", "cpu"]); + let variants: Vec = s.enums.iter().flat_map(|e| e.variants.clone()).collect(); + assert_eq!(variants, vec!["GREEN", "RED"]); } } diff --git a/experiment-baton/src/scan.rs b/experiment-baton/src/scan.rs index fb47f00..6758777 100644 --- a/experiment-baton/src/scan.rs +++ b/experiment-baton/src/scan.rs @@ -36,13 +36,24 @@ pub(crate) fn scan_ids(text: &str, prefix: &str) -> Vec { out } -/// Scan `/api/...` endpoint references; trims trailing punctuation. +/// Scan `/api/...` endpoint references; trims trailing punctuation. Skips a +/// `/api/` glued to an identifier/alias/relative path (preceded by an +/// alphanumeric, `_`, `@`, or `.`) — those are module specifiers like +/// `import … from '@/api/types.gen'`, not HTTP endpoints. pub(crate) fn scan_endpoints(text: &str) -> Vec { let mut out = Vec::new(); let mut seen = HashSet::new(); let mut idx = 0; while let Some(rel) = text[idx..].find("/api/") { let start = idx + rel; + let is_module_path = text[..start] + .chars() + .next_back() + .is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '@' | '.')); + if is_module_path { + idx = start + 1; + continue; + } let mut end = start; for c in text[start..].chars() { if c.is_whitespace() || matches!(c, '`' | '"' | '\'' | '(' | ')' | '<' | '>' | '|') { @@ -63,11 +74,14 @@ pub(crate) fn scan_endpoints(text: &str) -> Vec { /// Normalize an endpoint path into a stable, cross-language `ContractId`. /// -/// Drops a leading HTTP method, the `/api/` prefix, a `vN` version segment, and -/// path parameters (`{id}` / `:id`); joins the rest with `.`. -/// `GET /api/v1/services/{id}/health` → `services.health`. +/// Drops a leading HTTP method, any query string, the `/api/` prefix, a `vN` +/// version segment, and path parameters (`{id}` / `:id` / template `${id}`); +/// joins the rest with `.`. `GET /api/v1/services/{id}/health` → `services.health`. +/// Tolerant of call-site forms too: `` `/services/${id}/health?x=1` `` → `services.health`. pub(crate) fn normalize_endpoint(ep: &str) -> String { - let path = ep.split_whitespace().next_back().unwrap_or(ep).trim(); + // Last whitespace token drops a leading HTTP method; then strip a query string. + let token = ep.split_whitespace().next_back().unwrap_or(ep).trim(); + let path = token.split('?').next().unwrap_or(token); let mut segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); if segs.first() == Some(&"api") { segs.remove(0); @@ -79,7 +93,7 @@ pub(crate) fn normalize_endpoint(ep: &str) -> String { } } segs.into_iter() - .filter(|s| !(s.starts_with('{') || s.starts_with(':'))) + .filter(|s| !(s.starts_with('{') || s.starts_with(':') || s.starts_with('$'))) .collect::>() .join(".") } @@ -126,4 +140,22 @@ mod tests { assert_eq!(normalize_endpoint("/api/v2/users/:uid/profile"), "users.profile"); assert_eq!(normalize_endpoint("/api/v1/services"), "services"); } + + #[test] + fn endpoints_skip_module_specifiers() { + // `@/api/...` and relative `./api/...` imports are not HTTP endpoints. + assert!(scan_endpoints("import type { HealthSnapshot } from '@/api/types.gen';").is_empty()); + assert!(scan_endpoints("import { api } from './api/client';").is_empty()); + // A real reference in prose/comments still scans. + assert_eq!(scan_endpoints("// serves GET /api/v1/services"), vec!["/api/v1/services"]); + } + + #[test] + fn normalize_handles_call_site_forms() { + // Relative path (the typed client adds the `/api/v1` base) + template param. + assert_eq!(normalize_endpoint("/services/${serviceId}/health"), "services.health"); + // Query string is dropped. + assert_eq!(normalize_endpoint("/audit/records?action_type=AI_AUTO"), "audit.records"); + assert_eq!(normalize_endpoint("/services"), "services"); + } } diff --git a/experiment-baton/tests/fixtures/generated-types-project/internal/svc/handler.go b/experiment-baton/tests/fixtures/generated-types-project/internal/svc/handler.go new file mode 100644 index 0000000..b7f3143 --- /dev/null +++ b/experiment-baton/tests/fixtures/generated-types-project/internal/svc/handler.go @@ -0,0 +1,25 @@ +package svc + +// getServiceHealth serves GET /api/v1/services/{id}/health — the source of truth +// for the service health contract. + +type getServiceHealthOutput struct { + Name string `json:"name"` + State string `json:"state"` +} + +type HealthState string + +const ( + HealthStateOperational HealthState = "OPERATIONAL" + HealthStateDegraded HealthState = "DEGRADED" + HealthStateIdle HealthState = "IDLE" +) + +// listServices serves GET /api/v1/services — the source of truth for the service +// list contract. The frontend's ServiceRow matches this exactly. + +type serviceRow struct { + ServiceID string `json:"service_id"` + DisplayName string `json:"display_name"` +} diff --git a/experiment-baton/tests/fixtures/generated-types-project/web/src/api/types.gen.ts b/experiment-baton/tests/fixtures/generated-types-project/web/src/api/types.gen.ts new file mode 100644 index 0000000..334cdbf --- /dev/null +++ b/experiment-baton/tests/fixtures/generated-types-project/web/src/api/types.gen.ts @@ -0,0 +1,21 @@ +// AUTO-GENERATED — do not edit. All API types live in one file with no per-type +// endpoint anchors (the shape that collapses onto one coarse contract under +// nearest-anchor keying — issue #313). The route↔type binding lives at the call +// sites in ../features/dashboard.ts, not here. + +export type Semaphore = 'GREEN' | 'RED' | 'UNKNOWN'; + +// Consumer of GET /api/v1/services/{id}/health — encodes the *assumed* contract: +// wrong enum (Semaphore vs the backend's HealthState) and a phantom `cpu` the +// producer never models. +export interface DashboardHealth { + name: string; + status: Semaphore; + cpu?: number; +} + +// Consumer of GET /api/v1/services — matches its producer exactly (no drift). +export interface ServiceRow { + service_id: string; + display_name: string; +} diff --git a/experiment-baton/tests/fixtures/generated-types-project/web/src/features/dashboard.ts b/experiment-baton/tests/fixtures/generated-types-project/web/src/features/dashboard.ts new file mode 100644 index 0000000..82a5256 --- /dev/null +++ b/experiment-baton/tests/fixtures/generated-types-project/web/src/features/dashboard.ts @@ -0,0 +1,10 @@ +// Call sites that bind a generated type to its route — the keying source for +// #313. The typed client adds the `/api/v1` base, so paths here are relative and +// use `${...}` interpolation for params. +import { api } from '@/api/client'; +import type { DashboardHealth, ServiceRow } from '@/api/types.gen'; + +export const fetchHealth = (serviceId: string) => + api.get(`/services/${serviceId}/health`); + +export const fetchServices = () => api.get('/services'); diff --git a/experiment-baton/tests/generated_keying.rs b/experiment-baton/tests/generated_keying.rs new file mode 100644 index 0000000..96f9405 --- /dev/null +++ b/experiment-baton/tests/generated_keying.rs @@ -0,0 +1,65 @@ +//! #313 — per-type → endpoint keying for generated type files. +//! +//! A `types.gen.ts` holds all API types in one anchorless file; under the old +//! nearest-anchor keying its declarations collapse onto one coarse contract (or +//! are dropped entirely). With call-site binding the field/enum mismatch (C2/C3) +//! fires for the *correct* contract, and a clean sibling contract stays silent. + +use std::path::PathBuf; + +use straymark_baton::coherence::{CoherenceReport, FindingClass, Severity}; + +fn report() -> CoherenceReport { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/generated-types-project"); + CoherenceReport::build(root) +} + +#[test] +fn binding_isolates_the_drifting_contract_not_a_coarse_blob() { + let r = report(); + + // C2: the drifting consumer fields land on `services.health`, not a `services` blob. + let c2 = r + .findings + .iter() + .find(|f| f.class == FindingClass::ConsumerFieldWithoutProducer) + .expect("C2 expected on the health contract"); + assert_eq!(c2.severity, Severity::Blocking); + assert_eq!(c2.contract.as_deref(), Some("services.health")); + for orphan in ["status", "cpu"] { + assert!(c2.message.contains(orphan), "C2 should list orphan {orphan}"); + } + + // C3: the enum mismatch is attributed to the same correct contract. + let c3 = r + .findings + .iter() + .find(|f| f.class == FindingClass::ContractShapeMismatch) + .expect("C3 expected on the health contract"); + assert_eq!(c3.contract.as_deref(), Some("services.health")); + assert!(c3.message.contains("OPERATIONAL")); + assert!(c3.message.contains("GREEN")); +} + +#[test] +fn the_clean_sibling_contract_stays_silent() { + let r = report(); + // `ServiceRow` (→ `services`) matches its producer exactly: no finding there. + assert!( + !r.findings + .iter() + .any(|f| f.contract.as_deref() == Some("services")), + "the matching `services` contract must not produce findings" + ); +} + +#[test] +fn exactly_the_health_pair_is_blocking() { + let r = report(); + assert_eq!( + r.blocking_count(), + 2, + "only C2 + C3 on services.health should block" + ); +}