Skip to content

Commit c88c871

Browse files
fix(wire-shape): close 4 review-surfaced gaps from #182 + #184 (#185)
* fix(wire-shape): close 4 review gaps from #182 + #184 follow-up CI-internal hardenings; no user-visible behaviour change. - refresh.js: `--sha` with no value used to be silently consumed as the positional `specsDir`, masking the missing argument and routing the script through the gitHeadSHA fallback. Now exits 2 with a clear message. - validate.js: an empty `registered_types` paired with non-empty cross_spec / intra_file / per_type_drift entries used to silently disable Gate 4 (rename-escape). The validator now treats this combination as a baseline corruption and fails the run. A genuinely fresh baseline (every section empty) still skips the gate as before, since the first-pin run has nothing to guard. - lib.js writeBaseline: a crash between writeFileSync(tmp, ...) and renameSync(tmp, BASELINE_PATH) used to leave a `.tmp.<pid>` sidecar, which a future writer with the recycled PID would collide on. Now wraps in try/catch + unlinkSync on any throw. - lib.js extractInterfaceProps: an `interface X extends Y` would silently under-report fields because we walk only iface.members. No such interface ships in the SDK today; the gate now warns loudly the first time one appears so coverage gets resolved before drift can hide. Full heritage walking is the proper fix if this becomes a routine pattern. Verifications: - refresh.js with `--sha` as last token exits 2; with `--sha foo` works. - validate.js with corrupted baseline (empty registered_types but populated drift/cross_spec) exits 1 with explanatory message. - writeBaseline temp-file is cleaned up on synthetic write error. - Real baseline regenerates byte-identical to origin/main. * fix(wire-shape): close stale cross-spec baseline shielding gap Gate 1 (cross-spec divergence) only iterated currently observed schemas. A baselined divergence that the platform has since reconciled silently lingered in the baseline forever; the same old incompatible shape could be reintroduced and pass the gate because its fingerprint matched a stale entry that should have been deleted. Adds the reverse pass that Gate 2 already does for intra-file duplicates: any baselined cross-spec name that is no longer observed in the current specs fails the run with a pointer at the specific baseline key to delete. Mirrors the same fix landing on the Java arm in PR #137. Verified locally: - Positive run on clean baseline still exits 0. - Adding a phantom 'PhantomDivergence' entry to baseline cross_spec_duplicates causes the validator to exit 1 with a clear remove-from-baseline message naming the stale key. * chore: remove stray session-export artifact from previous commit The accidental git add -A in the prior commit swept up a session-export .txt file that lives in this worktree. Removing. * fix(wire-shape): canonicalize TS interface fields to wire snake_case The TS validator was extracting in-memory property names (camelCase by TS idiom) and diffing them directly against OpenAPI snake_case wire names, generating ~22 false-positive drift entries (Category A in the audit) where the SDK's hand-rolled transformer in client.ts already correctly bridged the two names. The other three SDK validators don't have this issue: pydantic alias for Python, `json:"…"` struct tag for Go, `@JsonProperty(…)` for Java — each extracts the WIRE name natively. TS has no first-class equivalent. This change adds equivalent wire-name extraction for TS: - toSnakeCase() converts camelCase to snake_case with three boundaries (acronym→word, lowercase→uppercase, lowercase→digit). The digit boundary fixes ModelPricing.inputPer1k → input_per_1k, which the earlier categorization regex got wrong. - loadTransformerEvidence() reads src/client.ts once and collects every snake_case-shaped identifier. This is the bridge audit: a TS field is treated as 'bridged to its snake_case wire form' only when the snake form actually appears somewhere in client.ts. - canonicalizeWireNames() applies the conversion per-field. If the snake form is found in client.ts, the field is normalized; if not, the camelCase form is kept and the type is recorded as having unbridged fields. - The safety net: a type's unbridged fields are warned about ONLY when the same type has at least one OTHER field that IS bridged. If no field in the type is bridged, the type is almost certainly SDK-internal (configs, options, adapter metadata, etc.) and the whole type isn't wire-bound; warning would be noise. Effect: 22 Category A drift entries disappear from the baseline (AuditToolCallRequest, BudgetCheckRequest, FEATAssessment, KillSwitch, ModelPricing, PlanVersionEntry, PolicyMatch, etc.). 63 → 41 drift entries. The 19 unbridged-field warnings the validator now emits all line up with audit-flagged broken/orphan reads in client.ts (PolicyEvaluationResult.databaseAccessed, StaticPolicy.hasOverride, StepGateResponse.policiesMatched, etc.) — the validator is now correctly distinguishing 'bridged drift' from 'genuinely missing transformer'. No SDK property names changed. Wire behavior is unchanged. This is purely a validator measurement fix; the SDK's existing transformer architecture is preserved as-is. Per platform-side spec corrections needed (filed separately): - AISystemRegistry.materiality_classification (#1708) - DynamicPolicyInfo schema completely wrong (#1709) * feat(types): wire-shape alignment sweep (Cat B + RENAME_SAFE + UNRECOVERABLE) Audit-driven cleanup against the wire-shape contract gate. All changes are additive (new fields are optional). Where a TS field was reading `undefined` because the decoder is JSON.parse passthrough and the wire emits a different key, the wire-canonical name is added and the orphan field is marked `@deprecated` (kept for compile-time compat; removed in v7). Per-type changes (full detail in CHANGELOG): Pure additions (Cat B): - WebhookSubscription.{secret, org_id, tenant_id} — security-critical; `secret` is the HMAC-SHA256 signing key required to verify inbound webhook payload signatures. - StepGateRequest.{cost_usd, tokens_in, tokens_out} - StepGateResponse.decision_id - ListWorkflowsResponse.{limit, offset} - StaticPolicy.{policy_id, priority, has_override} - CreateStaticPolicyRequest.{priority, tags} - UpdateStaticPolicyRequest.{priority, tags} - UpdatePlanRequest.metadata - UsageBreakdownItem.group_by - BudgetAlert.acknowledged - Budget.{org_id, tenant_id} - WorkflowStatusResponse.metadata - ExecutionSnapshot.retryCount - Finding.article RENAME_SAFE (orphan reads → wire-aligned canonical, old marked @deprecated): - StepGateResponse.policies_evaluated/policies_matched (snake) + policiesEvaluated/policiesMatched @deprecated - StaticPolicy.has_override (canonical) + hasOverride @deprecated - DynamicPolicyMatch.message + reason @deprecated - ExfiltrationCheckInfo.{exceeded, limit_type} + within_limits @deprecated - PolicyOverride.{id, enabled_override} + active @deprecated - PolicyVersion.{id, policy_id, change_summary, snapshot} + {changeDescription, previousValues, newValues} @deprecated - CancelPlanResponse.success + message @deprecated - CreateWorkflowResponse.started_at + {created_at, source} @deprecated - UsageRecord.{created_at, success, error_message, latency_ms, team_id, tenant_id, user_id, workflow_id} + timestamp @deprecated - ResumePlanResponse.result + 5 declared-but-never-populated fields marked @deprecated (workflowId, message, stepResult, nextStep, nextStepName, totalSteps) - PolicyEvaluationResult.{required_actions, processing_time_ms, database_accessed} (snake) + camelCase forms @deprecated UNRECOVERABLE realignments (no decoder; safe to add wire shape): - EffectivePoliciesResponse: tier-stratified wire shape ({static, dynamic, tenant_id, organization_id, computed_at}) - Policy: rich wire shape (policy_id, category, tier, pattern, severity, action, actions, conditions, description, organization_id, tenant_id, created_at/_by, updated_at/_by, version) — was exposing only 5 of 21 wire fields - PlanResponse: wire top-level fields (success, version, result, error, workflow_execution_id, policy_info) Transformer updates: - BudgetAlert decoder propagates acknowledged - UsageBreakdownItem decoder propagates groupBy - ExecutionSnapshot decoder propagates retryCount Wire-shape baseline regenerated: - 41 → 34 drift entries - All Cat B types fully resolved (RESOLVED ✓) - Remaining entries are all SDK-only fields (mostly @deprecated aliases retained for type-compat + Plugin Batch 1 SDK additions pending platform-side spec coverage) Filed alongside (no SDK change needed; spec is wrong): - axonflow-enterprise#1708 — AISystemRegistry.materiality_classification - axonflow-enterprise#1709 — DynamicPolicyInfo schema wrong shape Tests: 859 pass. Lint clean. * fix(client): propagate v6 fields through transformers (read + write) Companion to the type-definition sweep in this PR. The type updates added wire-canonical fields, but the hand-rolled transformers in client.ts weren't updated in parallel — so the fields were unreachable at runtime even though TypeScript types said they existed. The 5 reported gaps + 4 systematic-audit-found gaps = 9 fixes: Read-path fixes (decoders that dropped new wire fields): - generatePlan: PlanResponse now carries the wire top-level fields (success, version, result, error, workflow_execution_id, policy_info), sourced from agentResponse.* with data.* fallback. - resumePlan: ResumePlanResponse.result now populated from data.result. The deprecated `message` alias remains populated for back-compat. - getStaticPolicyVersions: inline-typed wire shape now declares the canonical fields (id, policy_id, change_summary, snapshot) alongside the legacy `change_description`/`previous_values`/ `new_values` keys; the transformer emits both halves. - listUsageRecords: UsageRecord transformer now emits the 8 new canonical fields (created_at, success, error_message, latency_ms, team_id, tenant_id, user_id, workflow_id). Legacy `timestamp` still emitted (it has always been undefined; both kept for source compat). - getExecution: inline step shape now declares retry_count, and the per-step mapper emits retryCount. (getExecutionSteps was already correct; only this method was out of sync.) - mapBudgetResponse: emits tenant_id, org_id (used by every Budget- returning method). - mapFindingResponse: emits article (used by mapAssessmentResponse). Write-path fixes (request builders that dropped new SDK fields): - updatePlan: body now includes metadata when set on the request. - createStaticPolicy: requestBody now includes priority, tags when set on the request. Verified clean (no gap): updateStaticPolicy (passthrough; field names match wire), stepGate (passthrough; new tokens_in/tokens_out/ cost_usd field names match wire), every method that flows through orchestratorRequest<T>/policyRequest<T> with a typed cast (the JSON parses naturally into the typed shape, so new fields appear without explicit transformer code). Meta-pattern observed: any method with a hand-rolled mapper helper or an explicit return-literal builder is a candidate gap when types gain fields. Methods that pass through to orchestratorRequest<T> inherit the new fields for free. The audit covered every type touched in the sweep across both directions. Tests: 859 pass. Lint clean. * fix(client): cancelPlan now propagates the canonical success field Same class as the 5 transformer gaps fixed in commit 2fac16f. The type-update added CancelPlanResponse.success but the cancelPlan() method's return literal didn't include it. The deprecated `message` slot is also kept populated so existing callers that read it see no behavior change (it has always been undefined against current servers, but zero-clobber). The systematic audit clearly missed cancelPlan in the previous round. The expanded sweep below documents every type/method pair to keep the contract honest: Read-path verifications (every method that returns a touched type): - BudgetAlert via getBudgetAlerts: acknowledged emitted ✓ (round 2) - UsageBreakdownItem via getUsageBreakdown: groupBy emitted ✓ (round 2) - ExecutionSnapshot via getExecutionSteps + getExecution: retryCount emitted ✓ (round 2) - PolicyEvaluationResult: embedded in PlanResponse.policy_info populated by generatePlan ✓ (round 2) - PlanResponse via generatePlan: 6 wire top-level fields ✓ (round 2) - CancelPlanResponse via cancelPlan: success now emitted (this commit) - ResumePlanResponse via resumePlan: result emitted ✓ (round 2) - StaticPolicy via list/get/create/update/toggle/getEffective: passthrough through policyRequest<T> ✓ - PolicyOverride via createPolicyOverride/listPolicyOverrides: passthrough through policyRequest<T> ✓ - PolicyVersion via getStaticPolicyVersions: id, policy_id, change_summary, snapshot emitted ✓ (round 2) - WorkflowStatusResponse via getWorkflow: passthrough ✓ - ListWorkflowsResponse via listWorkflows: passthrough ✓ - WebhookSubscription via createWebhook/getWebhook/updateWebhook: passthrough ✓ - ExfiltrationCheckInfo, DynamicPolicyMatch: embedded in MCP response shapes; passthrough ✓ - Finding via mapFindingResponse: article emitted ✓ (round 2) - Budget via mapBudgetResponse (used by createBudget/getBudget/ listBudgets/updateBudget/checkBudget/getBudgetStatus): tenant_id + org_id emitted ✓ (round 2) - UsageRecord via listUsageRecords: 8 wire fields emitted ✓ (round 2) - StepGateResponse via stepGate: passthrough; decision_id + policies_evaluated + policies_matched flow through ✓ - CreateWorkflowResponse via createWorkflow: passthrough; started_at flows through ✓ Write-path verifications (every method that accepts a touched request type): - StepGateRequest via stepGate: JSON.stringify passthrough; new cost_usd / tokens_in / tokens_out names match wire ✓ - UpdatePlanRequest via updatePlan: metadata in body builder ✓ (round 2) - CreateStaticPolicyRequest via createStaticPolicy: priority + tags in body builder ✓ (round 2) - UpdateStaticPolicyRequest via updateStaticPolicy: passthrough; field names match wire ✓ Falsey-clobber audit: 0 || patterns introduced in this PR's client.ts diff (verified with git diff grep). The transformer code uses ?? semantics or explicit guards. Tests: 859 pass. Lint clean. * chore(transformer-coverage): refresh baseline after v6 sweep burns down resumePlan PR #185's transformer fix populated all 5 fields previously missing on ResumePlanResponse (nextStep, nextStepName, stepResult, totalSteps, workflowId). Drop the stale baseline entry; remaining entries are executePlan and getPlanStatus on PolicyEvaluationResult, untouched this round.
1 parent 27d6fa5 commit c88c871

15 files changed

Lines changed: 715 additions & 844 deletions

File tree

.lint_baselines/transformer_coverage.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
"_comment": "Pre-existing transformer-coverage findings. Generated by scripts/transformer-coverage/check.js --write-baseline. CI fails on any finding NOT listed here. Burn this list down via targeted PRs that either populate the missing keys in the transformer or mark them @deprecated on the type definition.",
33
"findings": [
44
"src/client.ts:executePlan:PlanExecutionResponse:policyInfo",
5-
"src/client.ts:getPlanStatus:PlanExecutionResponse:policyInfo,workflowId",
6-
"src/client.ts:resumePlan:ResumePlanResponse:nextStep,nextStepName,stepResult,totalSteps,workflowId"
5+
"src/client.ts:getPlanStatus:PlanExecutionResponse:policyInfo,workflowId"
76
]
87
}

CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **`WebhookSubscription.secret`** — HMAC-SHA256 signing key now exposed on the response from `createWebhook`. Required to verify the `X-AxonFlow-Signature` header on inbound webhook deliveries; without it, callers couldn't validate payload authenticity. Also adds `org_id` and `tenant_id` (ownership scoping).
13+
- **`StepGateRequest`** carries `cost_usd`, `tokens_in`, `tokens_out` so budget-based policies can evaluate gate-time cost estimates.
14+
- **`StepGateResponse.decision_id`** — unique audit correlator that links a gate response to its audit row (previously absent on the SDK, present on the wire).
15+
- **`StepGateResponse.policies_evaluated` / `policies_matched`** (snake_case wire-canonical). The previous camelCase `policiesEvaluated` / `policiesMatched` always read `undefined` because the gate decoder is JSON.parse passthrough; both forms are kept (camel marked `@deprecated`) to preserve type-compat. Removed in v7.
16+
- **`ListWorkflowsResponse.limit` / `offset`** — pagination echo, surfaced on the response.
17+
- **`StaticPolicy.policy_id` / `priority` / `has_override`** — wire-canonical fields surfaced. `hasOverride` (camelCase) kept as `@deprecated` alias; it has always read `undefined` against the JSON.parse passthrough decoder.
18+
- **`CreateStaticPolicyRequest.priority` / `tags`** and **`UpdateStaticPolicyRequest.priority` / `tags`** — match the spec.
19+
- **`UpdatePlanRequest.metadata`** — accept arbitrary plan metadata, opaque to the platform.
20+
- **`UsageBreakdownItem.group_by`** — dimension name (provider/model/agent/etc.) is now exposed on each item.
21+
- **`BudgetAlert.acknowledged`** — alert dismissal flag.
22+
- **`Budget.org_id` / `tenant_id`** — ownership scoping.
23+
- **`UsageRecord`** gains `created_at`, `success`, `error_message`, `latency_ms`, `team_id`, `tenant_id`, `user_id`, `workflow_id` to match the wire. The legacy `timestamp` field is `@deprecated`; the decoder reads `r.timestamp`, but the wire emits `created_at`, so `timestamp` has always read `undefined`.
24+
- **`WorkflowStatusResponse.metadata`** — arbitrary workflow metadata.
25+
- **`CreateWorkflowResponse.started_at`** — wire-canonical timestamp. Legacy `created_at` and `source` are `@deprecated`; they have always read `undefined` (wire emits neither).
26+
- **`ExecutionSnapshot.retryCount`** — number of retry attempts on a step.
27+
- **`Finding.article`** — regulatory article reference (e.g. MAS FEAT principle number).
28+
- **`PolicyOverride.id` / `enabled_override`** — wire-canonical fields. `active` is `@deprecated`; the wire emits `enabled_override`, so `active` has always read `undefined`.
29+
- **`PolicyVersion.id` / `policy_id` / `change_summary` / `snapshot`** — match the wire shape (versions are immutable snapshots, not before/after diffs). `changeDescription`, `previousValues`, `newValues` are `@deprecated` orphan-reads.
30+
- **`DynamicPolicyMatch.message`** — wire-canonical name. `reason` is `@deprecated` (read `undefined` today).
31+
- **`ExfiltrationCheckInfo.exceeded` / `limit_type`** — match the wire. `within_limits` is `@deprecated`.
32+
- **`CancelPlanResponse.success`** — wire-canonical boolean. `message` is `@deprecated` (orphan read).
33+
- **`PlanResponse`** gains the wire top-level fields `success`, `version`, `result`, `error`, `workflow_execution_id`, `policy_info`. The decoder is JSON.parse passthrough, so consumers can now read these directly.
34+
- **`EffectivePoliciesResponse`** gains the tier-stratified wire shape (`static`, `dynamic`, `tenant_id`, `organization_id`, `computed_at`). Legacy `policies` flat array and `inheritance` summary kept as SDK-side conveniences.
35+
- **`Policy`** gains the rich wire shape (`policy_id`, `category`, `tier`, `pattern`, `severity`, `action`, `actions`, `conditions`, `description`, `organization_id`, `tenant_id`, `created_at`/`_by`, `updated_at`/`_by`, `version`). The legacy rules-only shape has only seen ~5 of 21 wire fields.
36+
- **`ResumePlanResponse.result`** — final aggregated result (canonical wire field). The interface previously declared 5 fields (`workflowId`, `message`, `stepResult`, `nextStep`, `nextStepName`, `totalSteps`) that the transformer never populated; all 5 are now `@deprecated`.
37+
1038
### Fixed
1139

1240
- Telemetry path is bounded at `TELEMETRY_TIMEOUT_MS` (3s) total; the `/health` probe and checkpoint POST share a single monotonic deadline instead of stacking independent timeouts. Aligns with python/go/java SDKs.
1341

42+
### Notes
43+
44+
The above is an audit-driven sweep against the wire-shape contract gate. The validator now snake_case-normalizes TS interface field names against transformer evidence in `client.ts` (matching what pydantic alias / Go `json:"…"` tag / Java `@JsonProperty(…)` do natively in the other SDKs). The `@deprecated` marks above are set on fields that historically read `undefined` against the JSON.parse-passthrough decoder paths — keeping the typed name kept compile-time compat for callers that referenced the now-dead names. Removal scheduled for v7.
45+
46+
Two platform-side spec corrections filed alongside this work, for issues the audit surfaced where the spec was wrong (server emits the SDK's name): `AISystemRegistry.materiality_classification` (axonflow-enterprise#1708), `DynamicPolicyInfo` schema completely wrong shape (axonflow-enterprise#1709). No SDK change for those — the SDK is correct.
47+
1448
## [5.6.0] - 2026-04-22
1549

1650
### Added

scripts/wire-shape/lib.js

Lines changed: 156 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,48 @@ const YAML = require('yaml');
2727

2828
const SDK_ROOT = path.resolve(__dirname, '..', '..');
2929
const SRC_DIR = path.join(SDK_ROOT, 'src');
30+
const CLIENT_TS = path.join(SRC_DIR, 'client.ts');
3031
const BASELINE_PATH = path.join(SDK_ROOT, 'tests', 'fixtures', 'wire-shape-baseline.json');
3132

33+
/**
34+
* Convert a camelCase identifier to snake_case. Handles three boundaries:
35+
* - acronym → word: `AIRequest` → `AI_Request` (preserves consecutive caps)
36+
* - lowercase → uppercase: `myField` → `my_Field`
37+
* - lowercase letter → digit: `per1k` → `per_1k`
38+
*
39+
* Digit → lowercase letter is intentionally NOT a boundary so that
40+
* `1k` stays as a unit-suffix (matches OpenAPI convention `input_per_1k`,
41+
* `tokens_per_1k`, etc.).
42+
*/
43+
function toSnakeCase(s) {
44+
return s
45+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
46+
.replace(/([a-z])([A-Z])/g, '$1_$2')
47+
.replace(/([a-z])(\d)/g, '$1_$2')
48+
.toLowerCase();
49+
}
50+
51+
/**
52+
* Read src/client.ts (the hand-rolled snake_case ↔ camelCase
53+
* transformer surface) and return a Set of every snake_case identifier
54+
* that appears anywhere in the file. Used as the safety-net check for
55+
* the case-normalizer below: we only treat a TS interface field as
56+
* "bridged to its snake_case wire form" if the snake form actually
57+
* appears in client.ts. Without this guard a missing transformer would
58+
* pass the gate by silent normalization.
59+
*/
60+
function loadTransformerEvidence() {
61+
if (!fs.existsSync(CLIENT_TS)) {
62+
return new Set();
63+
}
64+
const content = fs.readFileSync(CLIENT_TS, 'utf8');
65+
// Snake-case-shaped identifier: starts with a lowercase letter, must
66+
// contain at least one underscore, lowercase + digits + underscores
67+
// only. Avoids matching uppercase constants like `MAX_RETRIES`.
68+
const matches = content.match(/\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b/g) || [];
69+
return new Set(matches);
70+
}
71+
3272
/**
3373
* Load every *.yaml in specDir. Returns {merged, crossSpecDuplicates,
3474
* intraFileDuplicates}.
@@ -174,7 +214,84 @@ function discoverSDKInterfaces() {
174214
);
175215
ts.forEachChild(sourceFile, (node) => collectTopLevelTypes(node, result));
176216
});
177-
return result;
217+
return canonicalizeWireNames(result);
218+
}
219+
220+
/**
221+
* Canonicalize each TS interface field name to its on-the-wire form.
222+
*
223+
* The TS SDK convention is camelCase TS properties + hand-rolled
224+
* snake_case transformers in client.ts. Comparing TS in-memory names
225+
* to OpenAPI wire names produces false drift (cosmetic camelCase ↔
226+
* snake_case mismatches that the transformers already bridge).
227+
*
228+
* For each TS field name:
229+
* - If it's already snake_case, keep it.
230+
* - If its snake_case form appears in client.ts (transformer
231+
* evidence), output the snake_case form.
232+
* - If it's camelCase but no transformer evidence is found, emit a
233+
* console.warn and KEEP the camelCase name. The validator will
234+
* then surface this as drift, which is the correct behavior — a
235+
* camelCase TS field with no bridging transformer means the SDK
236+
* is wired to read/write camelCase on the wire, which won't match
237+
* a snake_case OpenAPI spec.
238+
*
239+
* Mirrors the wire-name extraction the Python (pydantic alias),
240+
* Go (`json:"…"` tag), and Java (`@JsonProperty(…)`) discovery layers
241+
* already do natively.
242+
*/
243+
function canonicalizeWireNames(typeMap) {
244+
const evidence = loadTransformerEvidence();
245+
const out = {};
246+
// Per-type unbridged tracker — used downstream to suppress noise on
247+
// SDK-internal config types (every field camelCase, none bridged) and
248+
// surface only the wire-bound types where a transformer is missing.
249+
const unbridgedByType = {};
250+
for (const [typeName, fields] of Object.entries(typeMap)) {
251+
const canonical = new Set();
252+
let bridged = 0;
253+
const unbridgedHere = [];
254+
for (const field of fields) {
255+
const snake = toSnakeCase(field);
256+
if (snake === field) {
257+
// Already snake_case or a single word — no normalization needed.
258+
canonical.add(field);
259+
continue;
260+
}
261+
if (evidence.has(snake)) {
262+
canonical.add(snake);
263+
bridged += 1;
264+
} else {
265+
unbridgedHere.push(field);
266+
canonical.add(field);
267+
}
268+
}
269+
out[typeName] = [...canonical].sort();
270+
// Only flag a type as having unbridged fields if at least one OTHER
271+
// field in the same type IS bridged. If no field is bridged, the
272+
// type is almost certainly SDK-internal (configs, options, adapter
273+
// metadata) and the whole type isn't wire-bound — there's nothing
274+
// for a transformer to bridge against.
275+
if (unbridgedHere.length > 0 && bridged > 0) {
276+
unbridgedByType[typeName] = unbridgedHere;
277+
}
278+
}
279+
const unbridgedFlat = Object.entries(unbridgedByType).flatMap(([t, fs]) =>
280+
fs.map((f) => `${t}.${f}`),
281+
);
282+
if (unbridgedFlat.length > 0) {
283+
console.warn(
284+
`wire-shape: ${unbridgedFlat.length} TS field(s) on wire-bound types ` +
285+
`are camelCase with no snake_case transformer evidence in client.ts; ` +
286+
`keeping camelCase (may surface as drift):\n ${unbridgedFlat
287+
.slice(0, 20)
288+
.join('\n ')}` +
289+
(unbridgedFlat.length > 20
290+
? `\n ... and ${unbridgedFlat.length - 20} more`
291+
: ''),
292+
);
293+
}
294+
return out;
178295
}
179296

180297
function walkTsFiles(dir, cb) {
@@ -220,6 +337,31 @@ function hasExportModifier(node) {
220337
}
221338

222339
function extractInterfaceProps(iface) {
340+
// A `heritageClauses` (e.g. `interface Foo extends Bar`) means the
341+
// interface inherits members from its parents that we won't see by
342+
// walking iface.members alone. The wire-shape gate would then
343+
// under-report fields and silently miss drift on inherited
344+
// properties. The TS SDK has no such interfaces today; flag it
345+
// loudly the first time one appears so coverage gets resolved
346+
// before it ships, instead of slipping past as invisible
347+
// under-counting.
348+
if (iface.heritageClauses && iface.heritageClauses.length > 0) {
349+
const parents = [];
350+
for (const clause of iface.heritageClauses) {
351+
for (const t of clause.types || []) {
352+
const expr = t.expression;
353+
if (expr && ts.isIdentifier(expr)) {
354+
parents.push(expr.text);
355+
}
356+
}
357+
}
358+
console.warn(
359+
`wire-shape: interface ${iface.name.text} extends [${parents.join(', ')}]; ` +
360+
'inherited fields are NOT walked. Add explicit field flattening to ' +
361+
'scripts/wire-shape/lib.js::extractInterfaceProps before merging an ' +
362+
'extending wire interface, or wire-shape coverage will be incomplete.',
363+
);
364+
}
223365
const names = [];
224366
for (const member of iface.members) {
225367
const name = propertyName(member);
@@ -290,8 +432,19 @@ function writeBaseline(baseline) {
290432
fs.mkdirSync(dir, { recursive: true });
291433
}
292434
const tmp = `${BASELINE_PATH}.tmp.${process.pid}`;
293-
fs.writeFileSync(tmp, JSON.stringify(baseline, null, 2) + '\n');
294-
fs.renameSync(tmp, BASELINE_PATH);
435+
try {
436+
fs.writeFileSync(tmp, JSON.stringify(baseline, null, 2) + '\n');
437+
fs.renameSync(tmp, BASELINE_PATH);
438+
} catch (e) {
439+
// Don't leave a `.tmp.<pid>` sidecar behind — the next run from
440+
// the same PID would collide and confuse a future writer.
441+
try {
442+
fs.unlinkSync(tmp);
443+
} catch {
444+
/* tmp may not exist yet; ignore */
445+
}
446+
throw e;
447+
}
295448
}
296449

297450
function difference(a, b) {

scripts/wire-shape/refresh.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,14 @@ function main() {
4444
let specsDir = null;
4545
let explicitSha = null;
4646
for (let i = 0; i < args.length; i += 1) {
47-
if (args[i] === '--sha' && i + 1 < args.length) {
47+
if (args[i] === '--sha') {
48+
if (i + 1 >= args.length) {
49+
// `--sha` as the last token used to be silently consumed as
50+
// a positional `specsDir`, masking the missing value. Fail
51+
// loudly instead.
52+
console.error('error: --sha requires a value (e.g. --sha bf1ca22…)');
53+
process.exit(2);
54+
}
4855
explicitSha = args[i + 1];
4956
i += 1;
5057
} else if (!specsDir) {

scripts/wire-shape/validate.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,19 @@ function main() {
104104
crossProblems.push(lines.join('\n'));
105105
}
106106
}
107+
// Reverse pass: a baselined cross-spec divergence that is no longer
108+
// observed must be removed from the baseline. Otherwise the stale
109+
// fingerprint shields a future reintroduction of the same old
110+
// incompatible shape from the gate. (Mirrors Gate 2's reverse pass
111+
// for intra_file_duplicates.)
112+
for (const name of Object.keys(baselinedCross)) {
113+
if (!crossSpecDuplicates[name]) {
114+
crossProblems.push(
115+
` ${name}: baselined cross-spec divergence no longer observed — remove from ` +
116+
`baseline.cross_spec_duplicates.${name} so a future reintroduction of the same shape is caught as new.`,
117+
);
118+
}
119+
}
107120
if (crossProblems.length > 0) {
108121
console.error('Cross-spec schema divergence gate failed:\n');
109122
for (const p of crossProblems) {
@@ -223,7 +236,29 @@ function main() {
223236
}
224237

225238
// Gate 4: registered-type coverage.
226-
if (baseline.registered_types.length > 0) {
239+
// After the first introduction, the baseline always has registered
240+
// types — emptying the list would silently disable the rename-
241+
// escape guard. Fail loudly on an empty list unless the baseline
242+
// is itself empty (genuine first-pin / fresh-repo case where every
243+
// baseline section is empty).
244+
const baselineIsFresh =
245+
baseline.registered_types.length === 0 &&
246+
Object.keys(baseline.per_type_drift).length === 0 &&
247+
Object.keys(baseline.cross_spec_duplicates).length === 0 &&
248+
Object.keys(baseline.intra_file_duplicates).length === 0;
249+
if (baseline.registered_types.length === 0 && !baselineIsFresh) {
250+
console.error(
251+
'baseline.registered_types is empty but other baseline sections are not.',
252+
);
253+
console.error(
254+
'This combination silently disables the rename-escape gate. Regenerate',
255+
);
256+
console.error(
257+
'tests/fixtures/wire-shape-baseline.json via scripts/wire-shape/refresh.js,',
258+
);
259+
console.error('or remove the file entirely if introducing a fresh baseline.');
260+
errors += 1;
261+
} else if (baseline.registered_types.length > 0) {
227262
const missingSDK = [];
228263
const missingSpec = [];
229264
for (const name of baseline.registered_types) {

0 commit comments

Comments
 (0)