@kirrosh/zond 0.23.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +164 -1
  2. package/README.md +8 -7
  3. package/package.json +2 -3
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/commands/add-api.ts +19 -7
  6. package/src/cli/commands/api/annotate/index.ts +359 -4
  7. package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
  8. package/src/cli/commands/api/annotate/overlay.ts +1 -1
  9. package/src/cli/commands/api/annotate/pagination.ts +10 -6
  10. package/src/cli/commands/api/annotate/prompts.ts +39 -2
  11. package/src/cli/commands/audit.ts +360 -54
  12. package/src/cli/commands/check.ts +15 -2
  13. package/src/cli/commands/checks.ts +352 -36
  14. package/src/cli/commands/cleanup.ts +4 -30
  15. package/src/cli/commands/coverage.ts +275 -57
  16. package/src/cli/commands/db.ts +311 -8
  17. package/src/cli/commands/discover.ts +281 -161
  18. package/src/cli/commands/doctor.ts +57 -3
  19. package/src/cli/commands/fixtures.ts +1 -1
  20. package/src/cli/commands/generate.ts +24 -7
  21. package/src/cli/commands/init/bootstrap.ts +4 -1
  22. package/src/cli/commands/init/index.ts +2 -2
  23. package/src/cli/commands/init/skills.ts +43 -0
  24. package/src/cli/commands/init/templates/agents.md +12 -3
  25. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  26. package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
  27. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  28. package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
  29. package/src/cli/commands/init/templates/skills/zond.md +274 -64
  30. package/src/cli/commands/init/templates/zond-config.yml +1 -1
  31. package/src/cli/commands/prepare-fixtures.ts +14 -52
  32. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  33. package/src/cli/commands/probe/mass-assignment.ts +101 -10
  34. package/src/cli/commands/probe/security.ts +95 -12
  35. package/src/cli/commands/probe/webhooks.ts +2 -0
  36. package/src/cli/commands/probe.ts +87 -11
  37. package/src/cli/commands/refresh-api.ts +59 -1
  38. package/src/cli/commands/report-bundle.ts +3 -11
  39. package/src/cli/commands/request.ts +116 -0
  40. package/src/cli/commands/run.ts +33 -5
  41. package/src/cli/commands/schema-from-runs.ts +128 -0
  42. package/src/cli/commands/secrets.ts +133 -0
  43. package/src/cli/json-envelope.ts +0 -20
  44. package/src/cli/json-schemas.ts +51 -0
  45. package/src/cli/output.ts +17 -1
  46. package/src/cli/program.ts +5 -4
  47. package/src/cli/safe-live.ts +24 -0
  48. package/src/cli/status-filter.ts +0 -10
  49. package/src/core/audit/persist.ts +183 -0
  50. package/src/core/checks/budget.ts +59 -0
  51. package/src/core/checks/checks/cross_call_references.ts +17 -4
  52. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  53. package/src/core/checks/checks/idempotency_replay.ts +1 -5
  54. package/src/core/checks/checks/ignored_auth.ts +44 -1
  55. package/src/core/checks/checks/index.ts +3 -0
  56. package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
  57. package/src/core/checks/checks/negative_data_rejection.ts +119 -16
  58. package/src/core/checks/checks/not_a_server_error.ts +8 -0
  59. package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
  60. package/src/core/checks/checks/pagination_invariants.ts +298 -117
  61. package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
  62. package/src/core/checks/checks/status_code_conformance.ts +78 -7
  63. package/src/core/checks/mode.ts +3 -0
  64. package/src/core/checks/recommended-action.ts +5 -1
  65. package/src/core/checks/runner.ts +614 -27
  66. package/src/core/checks/spec-findings.ts +308 -0
  67. package/src/core/checks/types.ts +117 -1
  68. package/src/core/checks/zond-extensions.ts +73 -0
  69. package/src/core/classifier/recommended-action.ts +35 -6
  70. package/src/core/coverage/loader.ts +31 -0
  71. package/src/core/diagnostics/db-analysis.ts +200 -106
  72. package/src/core/diagnostics/failure-class.ts +21 -1
  73. package/src/core/diagnostics/failure-hints.ts +4 -208
  74. package/src/core/diagnostics/suggested-fixes.ts +2 -3
  75. package/src/core/generator/chunker.ts +1 -8
  76. package/src/core/generator/data-factory.ts +199 -61
  77. package/src/core/generator/fixtures-builder.ts +38 -31
  78. package/src/core/generator/index.ts +0 -2
  79. package/src/core/generator/openapi-reader.ts +98 -4
  80. package/src/core/generator/path-param-disambig.ts +30 -4
  81. package/src/core/generator/resources-builder.ts +276 -26
  82. package/src/core/generator/schema-utils.ts +22 -0
  83. package/src/core/generator/suite-generator.ts +168 -15
  84. package/src/core/generator/types.ts +6 -0
  85. package/src/core/identity/identity-file.ts +0 -0
  86. package/src/core/output/README.md +11 -29
  87. package/src/core/output/index.ts +1 -1
  88. package/src/core/output/run.ts +0 -35
  89. package/src/core/output/types.ts +0 -7
  90. package/src/core/parser/dynamic-values.ts +160 -0
  91. package/src/core/parser/variables.ts +0 -0
  92. package/src/core/probe/dry-run-envelope.ts +4 -0
  93. package/src/core/probe/mass-assignment/classify.ts +175 -0
  94. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  95. package/src/core/probe/mass-assignment/digest.ts +114 -0
  96. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  97. package/src/core/probe/mass-assignment/regression.ts +141 -0
  98. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  99. package/src/core/probe/mass-assignment/types.ts +135 -0
  100. package/src/core/probe/mass-assignment-probe.ts +23 -1118
  101. package/src/core/probe/mass-assignment-template.ts +32 -4
  102. package/src/core/probe/path-discovery.ts +3 -4
  103. package/src/core/probe/probe-harness.ts +21 -22
  104. package/src/core/probe/security/baseline.ts +174 -0
  105. package/src/core/probe/security/classify.ts +341 -0
  106. package/src/core/probe/security/cleanup.ts +125 -0
  107. package/src/core/probe/security/detectors.ts +71 -0
  108. package/src/core/probe/security/digest.ts +104 -0
  109. package/src/core/probe/security/orchestrator.ts +398 -0
  110. package/src/core/probe/security/regression.ts +103 -0
  111. package/src/core/probe/security/types.ts +151 -0
  112. package/src/core/probe/security-probe-class.ts +8 -2
  113. package/src/core/probe/security-probe.ts +28 -1449
  114. package/src/core/probe/shared.ts +26 -0
  115. package/src/core/probe/webhooks-probe.ts +5 -7
  116. package/src/core/runner/assertions.ts +1 -1
  117. package/src/core/runner/executor.ts +3 -18
  118. package/src/core/runner/form-encode.ts +8 -18
  119. package/src/core/runner/http-client.ts +38 -1
  120. package/src/core/runner/preflight-vars.ts +19 -15
  121. package/src/core/runner/rate-limiter.ts +11 -29
  122. package/src/core/runner/run-kind.ts +7 -1
  123. package/src/core/runner/schema-validator.ts +2 -6
  124. package/src/core/runner/send-request.ts +11 -6
  125. package/src/core/runner/types.ts +6 -0
  126. package/src/core/setup-api.ts +53 -15
  127. package/src/core/severity/index.ts +0 -63
  128. package/src/core/spec/infer-schema.ts +102 -0
  129. package/src/core/spec/merge-specs.ts +156 -0
  130. package/src/core/spec/schema-from-runs.ts +117 -0
  131. package/src/core/spec/schema-overlay.ts +130 -0
  132. package/src/core/util/ajv.ts +13 -0
  133. package/src/core/util/headers.ts +9 -0
  134. package/src/core/util/url.ts +24 -0
  135. package/src/core/workspace/fixture-gap-report.ts +84 -0
  136. package/src/core/workspace/fixture-gaps.ts +71 -0
  137. package/src/core/workspace/root.ts +13 -11
  138. package/src/db/migrate.ts +2 -0
  139. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  140. package/src/db/queries/collections.ts +2 -2
  141. package/src/db/queries/results.ts +88 -0
  142. package/src/db/queries/runs.ts +56 -2
  143. package/src/db/queries.ts +3 -0
  144. package/src/db/schema.ts +7 -7
  145. package/src/cli/commands/bootstrap.ts +0 -710
  146. package/src/core/anti-fp/bootstrap.ts +0 -34
  147. package/src/core/anti-fp/index.ts +0 -33
  148. package/src/core/anti-fp/registry.ts +0 -44
  149. package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
  150. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
  151. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
  152. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
  153. package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
  154. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
  155. package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
  156. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
  157. package/src/core/anti-fp/types.ts +0 -68
  158. package/src/core/generator/create-body.ts +0 -89
@@ -1,9 +1,8 @@
1
1
  import { getDb } from "../../db/schema.ts";
2
2
  import { listCollections, listRuns, getRunById, getResultsByRunId, getCollectionById } from "../../db/queries.ts";
3
3
  import { join } from "node:path";
4
- import { statusHint, classifyFailure, envHint, envCategory, schemaHint, computeSharedEnvIssue, clusterEnvIssues, buildEnvIssue, recommendedActionForGenerated, isGeneratedTest, softDeleteHint, type RecommendedAction, type EnvIssue } from "./failure-hints.ts";
4
+ import { classifyFailure, recommendedActionForGenerated, isGeneratedTest, type RecommendedAction } from "./failure-hints.ts";
5
5
  import { buildSuggestedFixes, type SuggestedFix } from "./suggested-fixes.ts";
6
- import { AUTH_PATH_RE } from "../runner/auth-path.ts";
7
6
 
8
7
  function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean): string | undefined {
9
8
  if (!raw) return undefined;
@@ -25,6 +24,48 @@ function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean)
25
24
  return msgLines.join("\n");
26
25
  }
27
26
 
27
+ /**
28
+ * ARV-305: build a short reason string for `by_recommended_action.examples`.
29
+ *
30
+ * Preference order:
31
+ * 1. trimmed `error_message` (assertion- or network-level message)
32
+ * 2. first failing assertion → `<field> <rule>: got <actual>`
33
+ * 3. undefined (nothing left to say)
34
+ *
35
+ * Without (2) every assertion-only failure leaves examples[].reason as
36
+ * undefined, so triage agents lose the one signal that lets them route
37
+ * past method/path/status (regenerate_suite vs tighten_validation
38
+ * collapse to identical-looking buckets).
39
+ */
40
+ function buildExampleReason(
41
+ errorMessage: unknown,
42
+ assertions: unknown,
43
+ ): string | undefined {
44
+ const trim = (s: string) => (s.length > 120 ? `${s.slice(0, 117)}...` : s);
45
+ if (typeof errorMessage === "string" && errorMessage.length > 0) {
46
+ return trim(errorMessage);
47
+ }
48
+ if (!Array.isArray(assertions)) return undefined;
49
+ for (const a of assertions) {
50
+ if (!a || typeof a !== "object") continue;
51
+ const row = a as Record<string, unknown>;
52
+ if (row.passed === false) {
53
+ const field = typeof row.field === "string" ? row.field : "";
54
+ const rule = typeof row.rule === "string" ? row.rule : "";
55
+ const actual = "actual" in row ? row.actual : undefined;
56
+ const actualStr = actual === undefined || actual === null
57
+ ? ""
58
+ : typeof actual === "string"
59
+ ? actual
60
+ : JSON.stringify(actual);
61
+ const parts = [field, rule].filter(Boolean).join(" ");
62
+ const text = actualStr ? `${parts}: got ${actualStr}` : parts;
63
+ return text ? trim(text) : undefined;
64
+ }
65
+ }
66
+ return undefined;
67
+ }
68
+
28
69
  function parseBodySafe(raw: string | null | undefined): unknown {
29
70
  if (!raw) return undefined;
30
71
  const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "\u2026[truncated]" : raw;
@@ -145,7 +186,6 @@ export interface FailureGroup {
145
186
  count: number;
146
187
  failure_type: string;
147
188
  recommended_action: RecommendedAction;
148
- hint?: string;
149
189
  examples: string[];
150
190
  response_status: number | null;
151
191
  }
@@ -171,9 +211,6 @@ export interface DiagnoseResult {
171
211
  assertion_failures: number;
172
212
  network_errors: number;
173
213
  };
174
- agent_directive?: string;
175
- env_issue?: EnvIssue;
176
- auth_hint?: string;
177
214
  cascade_skips?: CascadeSkipGroup[];
178
215
  /** TASK-29: actionable suggestions populated from 404 placeholder
179
216
  * detection + .env.yaml unfilled-key audit. Empty / undefined when
@@ -190,8 +227,6 @@ export interface DiagnoseResult {
190
227
  request_method: string | null;
191
228
  request_url: string | null;
192
229
  response_status: number | null;
193
- hint?: string;
194
- schema_hint?: string;
195
230
  response_body?: unknown;
196
231
  response_headers?: Record<string, string>;
197
232
  assertions: unknown;
@@ -212,7 +247,24 @@ export interface DiagnoseResult {
212
247
  * subset), so counts match `.summary.failed`. Each bucket carries
213
248
  * total count + a small examples list (`<suite>/<test>`). Empty when
214
249
  * there are no failures. */
215
- by_recommended_action?: Record<string, { count: number; examples: string[] }>;
250
+ by_recommended_action?: Record<string, {
251
+ count: number;
252
+ /** ARV-228: each example carries the per-failure context the
253
+ * zond-triage skill renders in its output template (`POST
254
+ * /v1/projects → 500 (×3) — <reason>`). Previously a bare
255
+ * `string[]` of `<suite>/<test>` ids — agents had to cross-join
256
+ * with `failures[]` to recover method/path/status, which broke
257
+ * triage scripts on large runs. Bounded to 5 entries per bucket
258
+ * (same cap as the legacy string form). */
259
+ examples: Array<{
260
+ suite: string;
261
+ test: string;
262
+ method: string;
263
+ path: string;
264
+ status: number;
265
+ reason?: string;
266
+ }>;
267
+ }>;
216
268
  }
217
269
 
218
270
  export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, maxExamples?: number): DiagnoseResult {
@@ -233,9 +285,6 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
233
285
  .filter(r => r.status === "fail" || r.status === "error")
234
286
  .map(r => {
235
287
  const parsedBody = parseBodySafe(r.response_body);
236
- const hint = envHint(r.request_url, r.error_message, envFilePath) ??
237
- softDeleteHint(r.response_status, r.request_method, parsedBody) ??
238
- statusHint(r.response_status);
239
288
  const failure_type = classifyFailure(r.status, r.response_status);
240
289
  // ARV-42: generator-emitted suites should not route to fix_test_logic —
241
290
  // editing the YAML gets clobbered on the next `zond audit`.
@@ -247,7 +296,6 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
247
296
  // test-logic mistakes.
248
297
  const schema_violation = hasSchemaAssertion(r.assertions);
249
298
  const rec_action = recommendedActionForGenerated(failure_type, r.response_status, generated, schema_violation);
250
- const sHint = schemaHint(failure_type, r.response_status);
251
299
  return {
252
300
  suite_name: r.suite_name,
253
301
  test_name: r.test_name,
@@ -259,8 +307,6 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
259
307
  request_method: r.request_method,
260
308
  request_url: r.request_url,
261
309
  response_status: r.response_status,
262
- ...(hint ? { hint } : {}),
263
- ...(sHint ? { schema_hint: sHint } : {}),
264
310
  response_body: parsedBody,
265
311
  response_headers: filterHeaders(r.response_headers),
266
312
  assertions: r.assertions,
@@ -268,77 +314,11 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
268
314
  };
269
315
  });
270
316
 
271
- // TASK-70 + TASK-98 — env_issue detector.
272
- //
273
- // Two passes:
274
- // 1. Run-level: if every non-5xx failure shares a single env-category,
275
- // treat it as a global env_issue (legacy TASK-70 behaviour). This
276
- // catches the most common case — base_url unset, every test broken.
277
- // 2. Suite-level clustering: group failures by suite, flag each suite
278
- // whose non-5xx failures are ≥80% env-symptomatic (TASK-98). Catches
279
- // per-suite missing variables, expired auth tokens, dead webhook
280
- // hosts — situations where the run is *mixed* but a specific suite
281
- // is clearly env-broken.
282
- //
283
- // The fix_env override only applies to failures inside an affected suite.
284
- // 5xx (api_error) is excluded everywhere — backend bugs stay
285
- // report_backend_bug regardless of env state.
286
- let env_issue: EnvIssue | undefined;
287
- const legacyEnvHint = computeSharedEnvIssue(failures, envFilePath);
288
- const clusters = clusterEnvIssues(failures);
289
- const built = buildEnvIssue(clusters, envFilePath);
290
-
291
- let affectedSuites: Set<string>;
292
- if (built) {
293
- env_issue = built;
294
- affectedSuites = new Set(built.affected_suites);
295
- } else if (legacyEnvHint) {
296
- // Legacy global env_issue (no clustered match — e.g. only one failure,
297
- // or every suite has a single failing test). Preserve the original
298
- // single-message form but expose it via the new envelope shape so
299
- // downstream consumers see one stable contract.
300
- const allSuites = [...new Set(failures.filter(f => f.failure_type !== "api_error").map(f => f.suite_name))].sort();
301
- env_issue = {
302
- message: legacyEnvHint,
303
- scope: "run",
304
- affected_suites: allSuites,
305
- symptoms: {},
306
- };
307
- affectedSuites = new Set(allSuites);
308
- } else {
309
- affectedSuites = new Set();
310
- }
311
-
312
- if (env_issue) {
313
- for (const f of failures) {
314
- if (f.failure_type === "api_error") continue; // real backend bug — keep
315
- if (!affectedSuites.has(f.suite_name)) continue; // out-of-scope suite
316
- f.recommended_action = "fix_env";
317
- delete f.hint;
318
- delete f.schema_hint;
319
- }
320
- }
321
-
322
317
  let apiErrors = 0, assertionFailures = 0, networkErrors = 0;
323
- let authFailureCount = 0;
324
318
  for (const f of failures) {
325
319
  if (f.failure_type === "api_error") apiErrors++;
326
320
  else if (f.failure_type === "assertion_failed") assertionFailures++;
327
321
  else if (f.failure_type === "network_error") networkErrors++;
328
- if (f.response_status === 401 || f.response_status === 403) authFailureCount++;
329
- }
330
-
331
- let agent_directive: string | undefined;
332
- if (apiErrors > 0) {
333
- const fixable = assertionFailures + networkErrors;
334
- agent_directive =
335
- `${apiErrors} test${apiErrors === 1 ? "" : "s"} returned 5xx server errors. ` +
336
- `Do NOT change test expectations to accept 5xx responses. ` +
337
- `These are backend bugs, not test logic errors. ` +
338
- `Stop iterating on these tests and report the failures to the API team.` +
339
- (fixable > 0
340
- ? ` The remaining ${fixable} failure${fixable === 1 ? "" : "s"} may be fixable in test logic.`
341
- : "");
342
322
  }
343
323
 
344
324
  // Cascade skips: skipped tests due to missing captures from failed create steps
@@ -361,19 +341,6 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
361
341
  }))
362
342
  : undefined;
363
343
 
364
- // Auth hint: when many tests fail with 401/403, suggest auth setup
365
- let auth_hint: string | undefined;
366
- if (authFailureCount >= 5 && authFailureCount / diagRun.total >= 0.3) {
367
- const loginEndpoint = allResults.find(
368
- r => r.request_method?.toUpperCase() === "POST" && AUTH_PATH_RE.test(r.request_url ?? "")
369
- );
370
- if (loginEndpoint) {
371
- auth_hint = `${authFailureCount} tests failed with 401/403. Found auth endpoint: POST ${loginEndpoint.request_url} — add \`setup: true\` to your auth suite so its captured token is shared with all other suites, or set auth_token manually in .env.yaml`;
372
- } else {
373
- auth_hint = `${authFailureCount} tests failed with 401/403 — add \`setup: true\` to your auth suite so its captured token is shared with all other suites, or set auth_token in .env.yaml`;
374
- }
375
- }
376
-
377
344
  const { grouped_failures, compactFailures } = verbose
378
345
  ? { grouped_failures: undefined, compactFailures: failures }
379
346
  : groupFailures(failures, maxExamples);
@@ -395,7 +362,13 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
395
362
  // from the full failure set (not compactFailures) so counts match
396
363
  // .summary.failed. Bounded examples list (5) keeps payload small while
397
364
  // still pointing at concrete suites the agent can open.
398
- const by_recommended_action: Record<string, { count: number; examples: string[] }> = {};
365
+ //
366
+ // ARV-228: each example is now an object carrying method/path/status/
367
+ // reason so the zond-triage skill can render its output template
368
+ // ("POST /v1/projects → 500 (×3) — TypeError") without cross-joining
369
+ // failures[]. Bound preserved at 5/bucket; ordering = insertion order
370
+ // (matches failures[] traversal, deterministic per run).
371
+ const by_recommended_action: Record<string, NonNullable<DiagnoseResult["by_recommended_action"]>[string]> = {};
399
372
  for (const f of failures) {
400
373
  const key = f.recommended_action;
401
374
  let bucket = by_recommended_action[key];
@@ -405,7 +378,27 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
405
378
  }
406
379
  bucket.count += 1;
407
380
  if (bucket.examples.length < 5) {
408
- bucket.examples.push(`${f.suite_name}/${f.test_name}`);
381
+ // Trim reason to keep the bucket compact — full error_message lives
382
+ // in failures[].error_message for agents that want it.
383
+ //
384
+ // ARV-305: when there is no top-level error_message (typical for
385
+ // assertion failures — the row carries the failing rule in
386
+ // .assertions, not a free-form message), build a reason out of
387
+ // the first failing assertion so the example is not stripped
388
+ // down to method/path/status. The fallback keeps the field
389
+ // populated whenever any failure context exists.
390
+ const reason = buildExampleReason(f.error_message, f.assertions);
391
+ bucket.examples.push({
392
+ suite: f.suite_name,
393
+ test: f.test_name,
394
+ // DB columns are nullable (early steps may not have a request
395
+ // recorded yet — e.g. fixture-load failures). Coerce to "" / 0
396
+ // rather than leaking null into the contract.
397
+ method: f.request_method ?? "",
398
+ path: f.request_url ?? "",
399
+ status: f.response_status ?? 0,
400
+ ...(reason ? { reason } : {}),
401
+ });
409
402
  }
410
403
  }
411
404
 
@@ -424,9 +417,6 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
424
417
  assertion_failures: assertionFailures,
425
418
  network_errors: networkErrors,
426
419
  },
427
- ...(agent_directive ? { agent_directive } : {}),
428
- ...(env_issue ? { env_issue } : {}),
429
- ...(auth_hint ? { auth_hint } : {}),
430
420
  ...(cascade_skips ? { cascade_skips } : {}),
431
421
  ...(suggestedFixes.length > 0 ? { suggested_fixes: suggestedFixes } : {}),
432
422
  failures: compactFailures,
@@ -435,7 +425,7 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
435
425
  };
436
426
  }
437
427
 
438
- type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction; hint?: string; response_status: number | null; group_count?: number };
428
+ type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction; response_status: number | null; group_count?: number };
439
429
 
440
430
  /** Group similar failures for compact output. Exported for testing. */
441
431
  export function groupFailures<T extends FailureItem>(failures: T[], maxExamples = 2): { grouped_failures?: FailureGroup[]; compactFailures: T[] } {
@@ -443,7 +433,7 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
443
433
  return { compactFailures: failures };
444
434
  }
445
435
 
446
- const groupMap = new Map<string, { items: T[]; failure_type: string; hint?: string; response_status: number | null }>();
436
+ const groupMap = new Map<string, { items: T[]; failure_type: string; response_status: number | null }>();
447
437
 
448
438
  for (const f of failures) {
449
439
  const key = `${f.response_status ?? "null"}|${f.failure_type}`;
@@ -454,7 +444,6 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
454
444
  groupMap.set(key, {
455
445
  items: [f],
456
446
  failure_type: f.failure_type,
457
- hint: f.hint,
458
447
  response_status: f.response_status,
459
448
  });
460
449
  }
@@ -482,7 +471,6 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
482
471
  count: group.items.length,
483
472
  failure_type: group.failure_type,
484
473
  recommended_action: group.items[0]!.recommended_action,
485
- hint: group.hint,
486
474
  examples: (showAll ? group.items : group.items.slice(0, maxExamples)).map(f => `${f.suite_name}/${f.test_name}`),
487
475
  response_status: group.response_status,
488
476
  });
@@ -499,6 +487,29 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
499
487
  return { grouped_failures, compactFailures };
500
488
  }
501
489
 
490
+ export interface BodyFieldChange {
491
+ field: string;
492
+ change: "added" | "removed" | "type_changed";
493
+ before?: string;
494
+ after?: string;
495
+ /** ARV-352: structural scope of the change, derived deterministically from
496
+ * the path. `element` = the path crosses an array boundary (`[]`), i.e.
497
+ * it's a field of a *collection item* — on list/log endpoints two samplings
498
+ * return DIFFERENT objects, so element-level added/removed/type_changed is
499
+ * schema-of-union variance across the sampled set, not a contract move.
500
+ * `container` = no `[]` in the path — the response envelope/pagination
501
+ * skeleton, where a change IS real drift. NOT a suppression heuristic
502
+ * (ARV-337): nothing is dropped or down-ranked; the agent judges using the
503
+ * scope tag + endpoint context. */
504
+ scope: "container" | "element";
505
+ }
506
+
507
+ export interface BodyDiff {
508
+ suite: string;
509
+ test: string;
510
+ changes: BodyFieldChange[];
511
+ }
512
+
502
513
  export interface CompareResult {
503
514
  runA: { id: number; started_at: string };
504
515
  runB: { id: number; started_at: string };
@@ -508,12 +519,73 @@ export interface CompareResult {
508
519
  unchanged: number;
509
520
  newTests: number;
510
521
  removedTests: number;
522
+ bodyChanges: number;
523
+ /** ARV-352: of `bodyChanges`, how many touch the response envelope
524
+ * (`container`) vs collection-item fields (`element`). Element-heavy diffs
525
+ * on list/log endpoints are schema-of-union variance across a re-sampled
526
+ * set, not contract drift — split so triage doesn't read them as regression. */
527
+ bodyChangesContainer: number;
528
+ bodyChangesElement: number;
511
529
  };
512
530
  regressions: Array<{ suite: string; test: string; before: string; after: string }>;
513
531
  fixes: Array<{ suite: string; test: string; before: string; after: string }>;
532
+ /** ARV-339: field-level response-shape diff for tests present in both runs.
533
+ * Status-diff answers "what broke"; this answers "how the contract moved". */
534
+ body_changes: BodyDiff[];
514
535
  hasRegressions: boolean;
515
536
  }
516
537
 
538
+ /** ARV-339: flatten a parsed JSON body into `path → union of leaf types`.
539
+ * Array elements collapse under `[]` so item count/order don't add noise. */
540
+ function bodyShape(value: unknown, path: string, out: Map<string, Set<string>>): void {
541
+ if (Array.isArray(value)) {
542
+ if (value.length === 0) addShape(out, path, "array");
543
+ for (const item of value) bodyShape(item, `${path}[]`, out);
544
+ } else if (value !== null && typeof value === "object") {
545
+ const entries = Object.entries(value as Record<string, unknown>);
546
+ if (entries.length === 0) addShape(out, path, "object");
547
+ for (const [k, v] of entries) bodyShape(v, path ? `${path}.${k}` : k, out);
548
+ } else {
549
+ addShape(out, path, value === null ? "null" : typeof value);
550
+ }
551
+ }
552
+
553
+ function addShape(out: Map<string, Set<string>>, path: string, type: string): void {
554
+ const key = path || "$";
555
+ const set = out.get(key) ?? new Set<string>();
556
+ set.add(type);
557
+ out.set(key, set);
558
+ }
559
+
560
+ const typeLabel = (s: Set<string>): string => [...s].sort().join("|");
561
+
562
+ /** ARV-339: diff two stored response bodies at field level. Returns [] when
563
+ * either side is missing / non-JSON — a shape diff of prose is meaningless. */
564
+ export function diffBodyShapes(rawA: string | null, rawB: string | null): BodyFieldChange[] {
565
+ if (!rawA || !rawB || rawA === rawB) return [];
566
+ let a: unknown, b: unknown;
567
+ try { a = JSON.parse(rawA); b = JSON.parse(rawB); } catch { return []; }
568
+ if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return [];
569
+ const shapeA = new Map<string, Set<string>>();
570
+ const shapeB = new Map<string, Set<string>>();
571
+ bodyShape(a, "", shapeA);
572
+ bodyShape(b, "", shapeB);
573
+ const scopeOf = (field: string): "container" | "element" =>
574
+ field.includes("[]") ? "element" : "container";
575
+ const changes: BodyFieldChange[] = [];
576
+ for (const [field, typesB] of shapeB) {
577
+ const typesA = shapeA.get(field);
578
+ if (!typesA) changes.push({ field, change: "added", after: typeLabel(typesB), scope: scopeOf(field) });
579
+ else if (typeLabel(typesA) !== typeLabel(typesB)) {
580
+ changes.push({ field, change: "type_changed", before: typeLabel(typesA), after: typeLabel(typesB), scope: scopeOf(field) });
581
+ }
582
+ }
583
+ for (const [field, typesA] of shapeA) {
584
+ if (!shapeB.has(field)) changes.push({ field, change: "removed", before: typeLabel(typesA), scope: scopeOf(field) });
585
+ }
586
+ return changes.sort((x, y) => x.field.localeCompare(y.field));
587
+ }
588
+
517
589
  export function compareRuns(idA: number, idB: number, dbPath?: string): CompareResult {
518
590
  getDb(dbPath);
519
591
  const runARecord = getRunById(idA);
@@ -526,8 +598,16 @@ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareR
526
598
 
527
599
  const mapA = new Map<string, string>();
528
600
  const mapB = new Map<string, string>();
529
- for (const r of resultsA) mapA.set(`${r.suite_name}::${r.test_name}`, r.status);
530
- for (const r of resultsB) mapB.set(`${r.suite_name}::${r.test_name}`, r.status);
601
+ const bodyA = new Map<string, string | null>();
602
+ const bodyB = new Map<string, string | null>();
603
+ for (const r of resultsA) {
604
+ mapA.set(`${r.suite_name}::${r.test_name}`, r.status);
605
+ bodyA.set(`${r.suite_name}::${r.test_name}`, r.response_body);
606
+ }
607
+ for (const r of resultsB) {
608
+ mapB.set(`${r.suite_name}::${r.test_name}`, r.status);
609
+ bodyB.set(`${r.suite_name}::${r.test_name}`, r.response_body);
610
+ }
531
611
 
532
612
  const regressions: Array<{ suite: string; test: string; before: string; after: string }> = [];
533
613
  const fixes: Array<{ suite: string; test: string; before: string; after: string }> = [];
@@ -535,10 +615,14 @@ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareR
535
615
  let newTests = 0;
536
616
  let removedTests = 0;
537
617
 
618
+ const body_changes: BodyDiff[] = [];
619
+
538
620
  for (const [key, statusB] of mapB) {
539
621
  const statusA = mapA.get(key);
540
622
  if (statusA === undefined) { newTests++; continue; }
541
623
  const [suite, test] = key.split("::") as [string, string];
624
+ const changes = diffBodyShapes(bodyA.get(key) ?? null, bodyB.get(key) ?? null);
625
+ if (changes.length > 0) body_changes.push({ suite, test, changes });
542
626
  const wasPass = statusA === "pass";
543
627
  const isPass = statusB === "pass";
544
628
  const wasFail = statusA === "fail" || statusA === "error";
@@ -554,9 +638,19 @@ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareR
554
638
  return {
555
639
  runA: { id: idA, started_at: runARecord.started_at },
556
640
  runB: { id: idB, started_at: runBRecord.started_at },
557
- summary: { regressions: regressions.length, fixes: fixes.length, unchanged, newTests, removedTests },
641
+ summary: {
642
+ regressions: regressions.length,
643
+ fixes: fixes.length,
644
+ unchanged,
645
+ newTests,
646
+ removedTests,
647
+ bodyChanges: body_changes.length,
648
+ bodyChangesContainer: body_changes.filter(d => d.changes.some(c => c.scope === "container")).length,
649
+ bodyChangesElement: body_changes.filter(d => d.changes.every(c => c.scope === "element")).length,
650
+ },
558
651
  regressions,
559
652
  fixes,
653
+ body_changes,
560
654
  hasRegressions: regressions.length > 0,
561
655
  };
562
656
  }
@@ -91,7 +91,27 @@ export function classifyFailure(result: StepResult): FailureClassification | nul
91
91
  }
92
92
  }
93
93
 
94
- // 4. Negative-probe family distinguish "API accepted bad input" (likely_bug)
94
+ // 4. ARV-236: 401/403 on a step that expected success → env_issue
95
+ // ("token_scope"). Surfaces narrowly-scoped tokens (Resend free plan,
96
+ // GitHub PAT without `repo`, Stripe restricted key, etc.) so coverage
97
+ // and triage don't tag them as fail. Only fires when the failing
98
+ // assertion is on `status` and the expected status was 2xx — a real
99
+ // "expect 403" test deliberately checking auth still classifies normally.
100
+ if (typeof respStatus === "number" && (respStatus === 401 || respStatus === 403)) {
101
+ const statusFail = failed.find((a) => a.field === "status");
102
+ if (statusFail) {
103
+ const expected = expectedStatusList(statusFail);
104
+ const allExpected2xx = expected?.every((s) => s >= 200 && s < 300) ?? false;
105
+ if (allExpected2xx) {
106
+ return {
107
+ failure_class: "env_issue",
108
+ failure_class_reason: `token_scope: API returned ${respStatus} where the test expected ${expected!.join("/")} — token lacks scope or plan for this endpoint`,
109
+ };
110
+ }
111
+ }
112
+ }
113
+
114
+ // 5. Negative-probe family — distinguish "API accepted bad input" (likely_bug)
95
115
  // from "API rejected with a different 4xx" (quirk).
96
116
  if (generator === "negative-probe" || generator === "method-probe") {
97
117
  const statusFail = failed.find((a) => a.field === "status");