@kirrosh/zond 0.22.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 (282) hide show
  1. package/CHANGELOG.md +811 -0
  2. package/README.md +59 -6
  3. package/package.json +9 -7
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/argv.ts +122 -0
  6. package/src/cli/commands/add-api.ts +146 -0
  7. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  8. package/src/cli/commands/api/annotate/index.ts +880 -0
  9. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  10. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  11. package/src/cli/commands/api/annotate/pagination.ts +64 -0
  12. package/src/cli/commands/api/annotate/prompts.ts +220 -0
  13. package/src/cli/commands/api/annotate/readback.ts +58 -0
  14. package/src/cli/commands/api/annotate/resources.ts +91 -0
  15. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  16. package/src/cli/commands/audit.ts +786 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +361 -0
  19. package/src/cli/commands/checks.ts +1072 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +236 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +823 -132
  25. package/src/cli/commands/db.ts +486 -12
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1356 -0
  28. package/src/cli/commands/doctor.ts +661 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +438 -47
  31. package/src/cli/commands/init/bootstrap.ts +34 -2
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +99 -3
  34. package/src/cli/commands/init/templates/agents.md +77 -64
  35. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  36. package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
  37. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +802 -125
  40. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  41. package/src/cli/commands/prepare-fixtures.ts +97 -0
  42. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  43. package/src/cli/commands/probe/mass-assignment.ts +594 -0
  44. package/src/cli/commands/probe/security.ts +537 -0
  45. package/src/cli/commands/probe/static.ts +255 -0
  46. package/src/cli/commands/probe/webhooks.ts +163 -0
  47. package/src/cli/commands/probe.ts +535 -0
  48. package/src/cli/commands/reference.ts +87 -0
  49. package/src/cli/commands/refresh-api.ts +227 -0
  50. package/src/cli/commands/remove-api.ts +150 -0
  51. package/src/cli/commands/report-bundle.ts +310 -0
  52. package/src/cli/commands/report.ts +241 -0
  53. package/src/cli/commands/request.ts +495 -4
  54. package/src/cli/commands/run.ts +870 -53
  55. package/src/cli/commands/schema-from-runs.ts +128 -0
  56. package/src/cli/commands/secrets.ts +133 -0
  57. package/src/cli/commands/session.ts +244 -0
  58. package/src/cli/commands/use.ts +18 -1
  59. package/src/cli/index.ts +20 -3
  60. package/src/cli/json-envelope.ts +92 -3
  61. package/src/cli/json-schemas.ts +314 -0
  62. package/src/cli/output.ts +17 -1
  63. package/src/cli/program.ts +199 -635
  64. package/src/cli/resolve.ts +105 -0
  65. package/src/cli/safe-live.ts +24 -0
  66. package/src/cli/status-filter.ts +114 -0
  67. package/src/cli/util/api-context.ts +85 -0
  68. package/src/cli/version.ts +5 -0
  69. package/src/core/audit/persist.ts +183 -0
  70. package/src/core/checks/budget.ts +59 -0
  71. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  72. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  73. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  74. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  75. package/src/core/checks/checks/cross_call_references.ts +147 -0
  76. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  77. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  78. package/src/core/checks/checks/idempotency_replay.ts +242 -0
  79. package/src/core/checks/checks/ignored_auth.ts +254 -0
  80. package/src/core/checks/checks/index.ts +68 -0
  81. package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
  82. package/src/core/checks/checks/missing_required_header.ts +40 -0
  83. package/src/core/checks/checks/negative_data_rejection.ts +148 -0
  84. package/src/core/checks/checks/not_a_server_error.ts +35 -0
  85. package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
  86. package/src/core/checks/checks/pagination_invariants.ts +419 -0
  87. package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
  88. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  89. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  90. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  91. package/src/core/checks/checks/status_code_conformance.ts +132 -0
  92. package/src/core/checks/checks/unsupported_method.ts +63 -0
  93. package/src/core/checks/checks/use_after_free.ts +78 -0
  94. package/src/core/checks/index.ts +30 -0
  95. package/src/core/checks/mode.ts +82 -0
  96. package/src/core/checks/recommended-action.ts +68 -0
  97. package/src/core/checks/registry.ts +78 -0
  98. package/src/core/checks/runner.ts +1461 -0
  99. package/src/core/checks/sarif.ts +230 -0
  100. package/src/core/checks/spec-findings.ts +308 -0
  101. package/src/core/checks/stateful.ts +121 -0
  102. package/src/core/checks/types.ts +305 -0
  103. package/src/core/checks/zond-extensions.ts +73 -0
  104. package/src/core/classifier/recommended-action.ts +251 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +216 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +293 -59
  110. package/src/core/diagnostics/failure-class.ts +140 -0
  111. package/src/core/diagnostics/failure-hints.ts +88 -89
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +155 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +38 -19
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/data-factory.ts +586 -22
  124. package/src/core/generator/describe.ts +1 -1
  125. package/src/core/generator/fixtures-builder.ts +332 -0
  126. package/src/core/generator/index.ts +5 -5
  127. package/src/core/generator/openapi-reader.ts +135 -7
  128. package/src/core/generator/path-param-disambig.ts +140 -0
  129. package/src/core/generator/resources-builder.ts +898 -0
  130. package/src/core/generator/schema-utils.ts +33 -3
  131. package/src/core/generator/serializer.ts +103 -13
  132. package/src/core/generator/suite-generator.ts +583 -122
  133. package/src/core/generator/types.ts +14 -0
  134. package/src/core/identity/identity-file.ts +0 -0
  135. package/src/core/lint/affects.ts +28 -0
  136. package/src/core/lint/config.ts +96 -0
  137. package/src/core/lint/format.ts +42 -0
  138. package/src/core/lint/index.ts +94 -0
  139. package/src/core/lint/reporter.ts +128 -0
  140. package/src/core/lint/rules/consistency.ts +158 -0
  141. package/src/core/lint/rules/heuristics.ts +97 -0
  142. package/src/core/lint/rules/strictness.ts +109 -0
  143. package/src/core/lint/types.ts +96 -0
  144. package/src/core/lint/walker.ts +248 -0
  145. package/src/core/meta/meta-store.ts +6 -73
  146. package/src/core/output/README.md +73 -0
  147. package/src/core/output/index.ts +13 -0
  148. package/src/core/output/run.ts +91 -0
  149. package/src/core/output/types.ts +122 -0
  150. package/src/core/parser/dynamic-values.ts +160 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +61 -0
  159. package/src/core/probe/mass-assignment/classify.ts +175 -0
  160. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  161. package/src/core/probe/mass-assignment/digest.ts +114 -0
  162. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  163. package/src/core/probe/mass-assignment/regression.ts +141 -0
  164. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  165. package/src/core/probe/mass-assignment/types.ts +135 -0
  166. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  167. package/src/core/probe/mass-assignment-probe.ts +27 -0
  168. package/src/core/probe/mass-assignment-template.ts +240 -0
  169. package/src/core/probe/method-probe.ts +43 -76
  170. package/src/core/probe/method-shared.ts +69 -0
  171. package/src/core/probe/negative-probe.ts +183 -149
  172. package/src/core/probe/orphan-tracker.ts +188 -0
  173. package/src/core/probe/path-discovery.ts +439 -0
  174. package/src/core/probe/probe-harness.ts +119 -0
  175. package/src/core/probe/registry.ts +89 -0
  176. package/src/core/probe/runner.ts +136 -0
  177. package/src/core/probe/security/baseline.ts +174 -0
  178. package/src/core/probe/security/classify.ts +341 -0
  179. package/src/core/probe/security/cleanup.ts +125 -0
  180. package/src/core/probe/security/detectors.ts +71 -0
  181. package/src/core/probe/security/digest.ts +104 -0
  182. package/src/core/probe/security/orchestrator.ts +398 -0
  183. package/src/core/probe/security/regression.ts +103 -0
  184. package/src/core/probe/security/types.ts +151 -0
  185. package/src/core/probe/security-probe-class.ts +207 -0
  186. package/src/core/probe/security-probe.ts +32 -0
  187. package/src/core/probe/shared.ts +531 -0
  188. package/src/core/probe/static-probe-class.ts +125 -0
  189. package/src/core/probe/types.ts +165 -0
  190. package/src/core/probe/verdict-aggregator.ts +33 -0
  191. package/src/core/probe/webhooks-probe.ts +282 -0
  192. package/src/core/reporter/console.ts +41 -2
  193. package/src/core/reporter/index.ts +2 -3
  194. package/src/core/reporter/json.ts +11 -1
  195. package/src/core/reporter/junit.ts +27 -12
  196. package/src/core/reporter/ndjson.ts +37 -0
  197. package/src/core/reporter/types.ts +3 -0
  198. package/src/core/runner/assertions.ts +59 -2
  199. package/src/core/runner/async-pool.ts +108 -0
  200. package/src/core/runner/auth-path.ts +8 -0
  201. package/src/core/runner/ci-context.ts +72 -0
  202. package/src/core/runner/executor.ts +265 -36
  203. package/src/core/runner/form-encode.ts +41 -0
  204. package/src/core/runner/http-client.ts +112 -2
  205. package/src/core/runner/learn-drift.ts +293 -0
  206. package/src/core/runner/preflight-vars.ts +153 -0
  207. package/src/core/runner/progress-tracker.ts +73 -0
  208. package/src/core/runner/rate-limiter.ts +87 -33
  209. package/src/core/runner/run-kind.ts +45 -0
  210. package/src/core/runner/schema-validator.ts +308 -0
  211. package/src/core/runner/send-request.ts +158 -20
  212. package/src/core/runner/types.ts +44 -0
  213. package/src/core/secrets/registry.ts +164 -0
  214. package/src/core/secrets/secrets-file.ts +115 -0
  215. package/src/core/selectors/operation-filter.ts +144 -0
  216. package/src/core/setup-api.ts +457 -20
  217. package/src/core/severity/category.ts +94 -0
  218. package/src/core/severity/index.ts +58 -0
  219. package/src/core/spec/infer-schema.ts +102 -0
  220. package/src/core/spec/layers.ts +154 -0
  221. package/src/core/spec/merge-specs.ts +156 -0
  222. package/src/core/spec/schema-from-runs.ts +117 -0
  223. package/src/core/spec/schema-overlay.ts +130 -0
  224. package/src/core/util/ajv.ts +13 -0
  225. package/src/core/util/format-eta.ts +21 -0
  226. package/src/core/util/headers.ts +9 -0
  227. package/src/core/util/url.ts +24 -0
  228. package/src/core/utils.ts +5 -1
  229. package/src/core/workspace/config.ts +129 -0
  230. package/src/core/workspace/fixture-gap-report.ts +84 -0
  231. package/src/core/workspace/fixture-gaps.ts +71 -0
  232. package/src/core/workspace/manifest.ts +283 -0
  233. package/src/core/workspace/output-rotation.ts +62 -0
  234. package/src/core/workspace/root.ts +13 -11
  235. package/src/core/workspace/triage-path.ts +87 -0
  236. package/src/db/lint-runs.ts +47 -0
  237. package/src/db/migrate.ts +128 -0
  238. package/src/db/migrations/0001_run_kind.sql +25 -0
  239. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  240. package/src/db/migrations/sql.d.ts +4 -0
  241. package/src/db/queries/collections.ts +133 -0
  242. package/src/db/queries/coverage.ts +9 -0
  243. package/src/db/queries/dashboard.ts +59 -0
  244. package/src/db/queries/results.ts +216 -0
  245. package/src/db/queries/runs.ts +289 -0
  246. package/src/db/queries/sessions.ts +42 -0
  247. package/src/db/queries/settings.ts +28 -0
  248. package/src/db/queries/types.ts +172 -0
  249. package/src/db/queries.ts +75 -802
  250. package/src/db/schema.ts +178 -50
  251. package/src/cli/commands/export.ts +0 -144
  252. package/src/cli/commands/guide.ts +0 -127
  253. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  254. package/src/cli/commands/probe-methods.ts +0 -108
  255. package/src/cli/commands/probe-validation.ts +0 -124
  256. package/src/cli/commands/serve.ts +0 -114
  257. package/src/cli/commands/sync.ts +0 -268
  258. package/src/cli/commands/update.ts +0 -189
  259. package/src/cli/commands/validate.ts +0 -34
  260. package/src/core/diagnostics/render-md.ts +0 -112
  261. package/src/core/exporter/postman.ts +0 -963
  262. package/src/core/generator/guide-builder.ts +0 -253
  263. package/src/core/meta/types.ts +0 -19
  264. package/src/core/parser/index.ts +0 -21
  265. package/src/core/runner/execute-run.ts +0 -132
  266. package/src/core/runner/index.ts +0 -12
  267. package/src/core/sync/spec-differ.ts +0 -38
  268. package/src/web/data/collection-state.ts +0 -362
  269. package/src/web/routes/api.ts +0 -314
  270. package/src/web/routes/dashboard.ts +0 -350
  271. package/src/web/routes/runs.ts +0 -64
  272. package/src/web/schemas.ts +0 -121
  273. package/src/web/server.ts +0 -134
  274. package/src/web/static/htmx.min.cjs +0 -1
  275. package/src/web/static/style.css +0 -1148
  276. package/src/web/views/endpoints-tab.ts +0 -174
  277. package/src/web/views/explorer-tab.ts +0 -402
  278. package/src/web/views/health-strip.ts +0 -92
  279. package/src/web/views/layout.ts +0 -48
  280. package/src/web/views/results.ts +0 -210
  281. package/src/web/views/runs-tab.ts +0 -126
  282. package/src/web/views/suites-tab.ts +0 -181
@@ -1,10 +1,10 @@
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, recommendedAction, softDeleteHint, type RecommendedAction } from "./failure-hints.ts";
5
- import { AUTH_PATH_RE } from "../runner/execute-run.ts";
4
+ import { classifyFailure, recommendedActionForGenerated, isGeneratedTest, type RecommendedAction } from "./failure-hints.ts";
5
+ import { buildSuggestedFixes, type SuggestedFix } from "./suggested-fixes.ts";
6
6
 
7
- export function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean): string | undefined {
7
+ function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean): string | undefined {
8
8
  if (!raw) return undefined;
9
9
  if (verbose || raw.length < 500) return raw;
10
10
  const lines = raw.split(/\r?\n/);
@@ -24,7 +24,49 @@ export function truncateErrorMessage(raw: string | null | undefined, verbose?: b
24
24
  return msgLines.join("\n");
25
25
  }
26
26
 
27
- export function parseBodySafe(raw: string | null | undefined): unknown {
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
+
69
+ function parseBodySafe(raw: string | null | undefined): unknown {
28
70
  if (!raw) return undefined;
29
71
  const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "\u2026[truncated]" : raw;
30
72
  try {
@@ -40,7 +82,33 @@ const USEFUL_HEADERS = new Set([
40
82
  ]);
41
83
  const USEFUL_PREFIXES = ["x-", "ratelimit"];
42
84
 
43
- export function filterHeaders(raw: string | null | undefined): Record<string, string> | undefined {
85
+ /** ARV-103 (F8): true when at least one assertion on the failing step is
86
+ * a schema-validation kind. `--validate-schema` annotates each violated
87
+ * field with `kind: "schema"` (set in src/core/runner/schema-validator.ts).
88
+ * The assertions column is stored as JSON in SQLite; parse defensively. */
89
+ function hasSchemaAssertion(raw: string | unknown[] | null | undefined): boolean {
90
+ if (raw === null || raw === undefined) return false;
91
+ let arr: unknown[];
92
+ if (Array.isArray(raw)) {
93
+ arr = raw;
94
+ } else if (typeof raw === "string") {
95
+ try {
96
+ const parsed = JSON.parse(raw);
97
+ if (!Array.isArray(parsed)) return false;
98
+ arr = parsed;
99
+ } catch {
100
+ return false;
101
+ }
102
+ } else {
103
+ return false;
104
+ }
105
+ for (const a of arr) {
106
+ if (a && typeof a === "object" && (a as { kind?: unknown }).kind === "schema") return true;
107
+ }
108
+ return false;
109
+ }
110
+
111
+ function filterHeaders(raw: string | null | undefined): Record<string, string> | undefined {
44
112
  if (!raw) return undefined;
45
113
  try {
46
114
  const h = JSON.parse(raw) as Record<string, string>;
@@ -118,7 +186,6 @@ export interface FailureGroup {
118
186
  count: number;
119
187
  failure_type: string;
120
188
  recommended_action: RecommendedAction;
121
- hint?: string;
122
189
  examples: string[];
123
190
  response_status: number | null;
124
191
  }
@@ -144,10 +211,11 @@ export interface DiagnoseResult {
144
211
  assertion_failures: number;
145
212
  network_errors: number;
146
213
  };
147
- agent_directive?: string;
148
- env_issue?: string;
149
- auth_hint?: string;
150
214
  cascade_skips?: CascadeSkipGroup[];
215
+ /** TASK-29: actionable suggestions populated from 404 placeholder
216
+ * detection + .env.yaml unfilled-key audit. Empty / undefined when
217
+ * nothing actionable was found. */
218
+ suggested_fixes?: SuggestedFix[];
151
219
  failures: Array<{
152
220
  suite_name: string;
153
221
  test_name: string;
@@ -159,14 +227,44 @@ export interface DiagnoseResult {
159
227
  request_method: string | null;
160
228
  request_url: string | null;
161
229
  response_status: number | null;
162
- hint?: string;
163
- schema_hint?: string;
164
230
  response_body?: unknown;
165
231
  response_headers?: Record<string, string>;
166
232
  assertions: unknown;
167
233
  duration_ms: number | null;
234
+ /** ARV-159: when this entry is the representative of a collapsed group
235
+ * (status|failure_type signature), the total size of that group. Lets
236
+ * consumers reading `.data.failures[]` see "this signature stands for
237
+ * N underlying tests" without cross-referencing `.grouped_failures[]`.
238
+ * Omitted when no collapsing occurred (failures ≤ 5 or
239
+ * --verbose). */
240
+ group_count?: number;
168
241
  }>;
169
242
  grouped_failures?: FailureGroup[];
243
+ /** ARV-101 (F6): top-level aggregation keyed by `recommended_action`
244
+ * enum so triage agents (zond-triage skill) can route on the canonical
245
+ * action without re-folding `failures[].recommended_action` through
246
+ * `jq | group_by`. Built from the *full* failure set (not the compact
247
+ * subset), so counts match `.summary.failed`. Each bucket carries
248
+ * total count + a small examples list (`<suite>/<test>`). Empty when
249
+ * there are no failures. */
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
+ }>;
170
268
  }
171
269
 
172
270
  export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, maxExamples?: number): DiagnoseResult {
@@ -187,12 +285,17 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
187
285
  .filter(r => r.status === "fail" || r.status === "error")
188
286
  .map(r => {
189
287
  const parsedBody = parseBodySafe(r.response_body);
190
- const hint = envHint(r.request_url, r.error_message, envFilePath) ??
191
- softDeleteHint(r.response_status, r.request_method, parsedBody) ??
192
- statusHint(r.response_status);
193
288
  const failure_type = classifyFailure(r.status, r.response_status);
194
- const rec_action = recommendedAction(failure_type, r.response_status);
195
- const sHint = schemaHint(failure_type, r.response_status);
289
+ // ARV-42: generator-emitted suites should not route to fix_test_logic —
290
+ // editing the YAML gets clobbered on the next `zond audit`.
291
+ const generated = isGeneratedTest(r.provenance, r.suite_file);
292
+ // ARV-103 (F8): walk the assertions array to detect a schema-kind
293
+ // failure (--validate-schema annotates each assertion with its kind).
294
+ // When present, propagate the flag so the classifier routes to
295
+ // report_backend_bug — schema violations are real contract bugs, not
296
+ // test-logic mistakes.
297
+ const schema_violation = hasSchemaAssertion(r.assertions);
298
+ const rec_action = recommendedActionForGenerated(failure_type, r.response_status, generated, schema_violation);
196
299
  return {
197
300
  suite_name: r.suite_name,
198
301
  test_name: r.test_name,
@@ -204,8 +307,6 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
204
307
  request_method: r.request_method,
205
308
  request_url: r.request_url,
206
309
  response_status: r.response_status,
207
- ...(hint ? { hint } : {}),
208
- ...(sHint ? { schema_hint: sHint } : {}),
209
310
  response_body: parsedBody,
210
311
  response_headers: filterHeaders(r.response_headers),
211
312
  assertions: r.assertions,
@@ -213,28 +314,11 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
213
314
  };
214
315
  });
215
316
 
216
- const sharedEnvHint = computeSharedEnvIssue(failures, envFilePath);
217
-
218
317
  let apiErrors = 0, assertionFailures = 0, networkErrors = 0;
219
- let authFailureCount = 0;
220
318
  for (const f of failures) {
221
319
  if (f.failure_type === "api_error") apiErrors++;
222
320
  else if (f.failure_type === "assertion_failed") assertionFailures++;
223
321
  else if (f.failure_type === "network_error") networkErrors++;
224
- if (f.response_status === 401 || f.response_status === 403) authFailureCount++;
225
- }
226
-
227
- let agent_directive: string | undefined;
228
- if (apiErrors > 0) {
229
- const fixable = assertionFailures + networkErrors;
230
- agent_directive =
231
- `${apiErrors} test${apiErrors === 1 ? "" : "s"} returned 5xx server errors. ` +
232
- `Do NOT change test expectations to accept 5xx responses. ` +
233
- `These are backend bugs, not test logic errors. ` +
234
- `Stop iterating on these tests and report the failures to the API team.` +
235
- (fixable > 0
236
- ? ` The remaining ${fixable} failure${fixable === 1 ? "" : "s"} may be fixable in test logic.`
237
- : "");
238
322
  }
239
323
 
240
324
  // Cascade skips: skipped tests due to missing captures from failed create steps
@@ -257,23 +341,67 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
257
341
  }))
258
342
  : undefined;
259
343
 
260
- // Auth hint: when many tests fail with 401/403, suggest auth setup
261
- let auth_hint: string | undefined;
262
- if (authFailureCount >= 5 && authFailureCount / diagRun.total >= 0.3) {
263
- const loginEndpoint = allResults.find(
264
- r => r.request_method?.toUpperCase() === "POST" && AUTH_PATH_RE.test(r.request_url ?? "")
265
- );
266
- if (loginEndpoint) {
267
- 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`;
268
- } else {
269
- 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`;
270
- }
271
- }
272
-
273
344
  const { grouped_failures, compactFailures } = verbose
274
345
  ? { grouped_failures: undefined, compactFailures: failures }
275
346
  : groupFailures(failures, maxExamples);
276
347
 
348
+ // TASK-29: surface placeholder path-params + unfilled .env.yaml keys.
349
+ const suggestedFixes = buildSuggestedFixes({
350
+ failures: failures.map(f => ({
351
+ response_status: f.response_status,
352
+ request_url: f.request_url,
353
+ suite_name: f.suite_name,
354
+ test_name: f.test_name,
355
+ })),
356
+ envFilePath,
357
+ });
358
+
359
+ // ARV-101 (F6): aggregate failures by recommended_action enum so triage
360
+ // agents read .data.by_recommended_action.fix_env.count instead of
361
+ // re-folding failures[].recommended_action through `jq | group_by`. Built
362
+ // from the full failure set (not compactFailures) so counts match
363
+ // .summary.failed. Bounded examples list (5) keeps payload small while
364
+ // still pointing at concrete suites the agent can open.
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]> = {};
372
+ for (const f of failures) {
373
+ const key = f.recommended_action;
374
+ let bucket = by_recommended_action[key];
375
+ if (!bucket) {
376
+ bucket = { count: 0, examples: [] };
377
+ by_recommended_action[key] = bucket;
378
+ }
379
+ bucket.count += 1;
380
+ if (bucket.examples.length < 5) {
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
+ });
402
+ }
403
+ }
404
+
277
405
  return {
278
406
  run: {
279
407
  id: diagRun.id,
@@ -289,16 +417,15 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
289
417
  assertion_failures: assertionFailures,
290
418
  network_errors: networkErrors,
291
419
  },
292
- ...(agent_directive ? { agent_directive } : {}),
293
- ...(sharedEnvHint ? { env_issue: sharedEnvHint } : {}),
294
- ...(auth_hint ? { auth_hint } : {}),
295
420
  ...(cascade_skips ? { cascade_skips } : {}),
421
+ ...(suggestedFixes.length > 0 ? { suggested_fixes: suggestedFixes } : {}),
296
422
  failures: compactFailures,
297
423
  ...(grouped_failures ? { grouped_failures } : {}),
424
+ ...(failures.length > 0 ? { by_recommended_action } : {}),
298
425
  };
299
426
  }
300
427
 
301
- type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction; hint?: string; response_status: number | null };
428
+ type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction; response_status: number | null; group_count?: number };
302
429
 
303
430
  /** Group similar failures for compact output. Exported for testing. */
304
431
  export function groupFailures<T extends FailureItem>(failures: T[], maxExamples = 2): { grouped_failures?: FailureGroup[]; compactFailures: T[] } {
@@ -306,7 +433,7 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
306
433
  return { compactFailures: failures };
307
434
  }
308
435
 
309
- 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 }>();
310
437
 
311
438
  for (const f of failures) {
312
439
  const key = `${f.response_status ?? "null"}|${f.failure_type}`;
@@ -317,7 +444,6 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
317
444
  groupMap.set(key, {
318
445
  items: [f],
319
446
  failure_type: f.failure_type,
320
- hint: f.hint,
321
447
  response_status: f.response_status,
322
448
  });
323
449
  }
@@ -345,20 +471,45 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
345
471
  count: group.items.length,
346
472
  failure_type: group.failure_type,
347
473
  recommended_action: group.items[0]!.recommended_action,
348
- hint: group.hint,
349
474
  examples: (showAll ? group.items : group.items.slice(0, maxExamples)).map(f => `${f.suite_name}/${f.test_name}`),
350
475
  response_status: group.response_status,
351
476
  });
352
477
  if (isApiError) {
353
478
  compactFailures.push(...group.items);
354
479
  } else {
355
- compactFailures.push(group.items[0]!);
480
+ // ARV-159: tag the representative with the group size so
481
+ // `.data.failures[]` carries the multiplier inline.
482
+ const rep = { ...group.items[0]!, group_count: group.items.length };
483
+ compactFailures.push(rep as T);
356
484
  }
357
485
  }
358
486
 
359
487
  return { grouped_failures, compactFailures };
360
488
  }
361
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
+
362
513
  export interface CompareResult {
363
514
  runA: { id: number; started_at: string };
364
515
  runB: { id: number; started_at: string };
@@ -368,12 +519,73 @@ export interface CompareResult {
368
519
  unchanged: number;
369
520
  newTests: number;
370
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;
371
529
  };
372
530
  regressions: Array<{ suite: string; test: string; before: string; after: string }>;
373
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[];
374
535
  hasRegressions: boolean;
375
536
  }
376
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
+
377
589
  export function compareRuns(idA: number, idB: number, dbPath?: string): CompareResult {
378
590
  getDb(dbPath);
379
591
  const runARecord = getRunById(idA);
@@ -386,8 +598,16 @@ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareR
386
598
 
387
599
  const mapA = new Map<string, string>();
388
600
  const mapB = new Map<string, string>();
389
- for (const r of resultsA) mapA.set(`${r.suite_name}::${r.test_name}`, r.status);
390
- 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
+ }
391
611
 
392
612
  const regressions: Array<{ suite: string; test: string; before: string; after: string }> = [];
393
613
  const fixes: Array<{ suite: string; test: string; before: string; after: string }> = [];
@@ -395,10 +615,14 @@ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareR
395
615
  let newTests = 0;
396
616
  let removedTests = 0;
397
617
 
618
+ const body_changes: BodyDiff[] = [];
619
+
398
620
  for (const [key, statusB] of mapB) {
399
621
  const statusA = mapA.get(key);
400
622
  if (statusA === undefined) { newTests++; continue; }
401
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 });
402
626
  const wasPass = statusA === "pass";
403
627
  const isPass = statusB === "pass";
404
628
  const wasFail = statusA === "fail" || statusA === "error";
@@ -414,9 +638,19 @@ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareR
414
638
  return {
415
639
  runA: { id: idA, started_at: runARecord.started_at },
416
640
  runB: { id: idB, started_at: runBRecord.started_at },
417
- 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
+ },
418
651
  regressions,
419
652
  fixes,
653
+ body_changes,
420
654
  hasRegressions: regressions.length > 0,
421
655
  };
422
656
  }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * TASK-101: failure classification — definitely_bug / likely_bug / quirk / env_issue.
3
+ *
4
+ * Goal: бэкендер за секунду видит «реально баг» vs «quirk зонда / probe
5
+ * фолс-позитив». Чисто read-only классификация: не меняет статус step,
6
+ * не влияет на pass/fail. Только tag-овая аналитика.
7
+ */
8
+
9
+ import type { StepResult, AssertionResult } from "../runner/types.ts";
10
+ import type { SourceMetadata } from "../parser/types.ts";
11
+
12
+ export type FailureClass =
13
+ | "definitely_bug"
14
+ | "likely_bug"
15
+ | "quirk"
16
+ | "env_issue"
17
+ /** Step was skipped because an upstream step failed to produce a required
18
+ * capture (or produced a tainted one). Not a failure on its own — render
19
+ * collapsed under the root failure to avoid drowning the user. */
20
+ | "cascade";
21
+
22
+ export interface FailureClassification {
23
+ failure_class: FailureClass;
24
+ failure_class_reason: string;
25
+ }
26
+
27
+ function failedAssertionsOf(result: StepResult): AssertionResult[] {
28
+ return result.assertions.filter((a) => !a.passed);
29
+ }
30
+
31
+ function ruleStartsWith(a: AssertionResult, prefix: string): boolean {
32
+ return typeof a.rule === "string" && a.rule.startsWith(prefix);
33
+ }
34
+
35
+ function expectedStatusList(a: AssertionResult): number[] | null {
36
+ if (Array.isArray(a.expected)) {
37
+ const arr = a.expected.filter((v): v is number => typeof v === "number");
38
+ return arr.length > 0 ? arr : null;
39
+ }
40
+ return typeof a.expected === "number" ? [a.expected] : null;
41
+ }
42
+
43
+ /**
44
+ * Classify a failed step. Returns `null` for pass/skip/unclassifiable failures —
45
+ * UI renders those as "unclassified" rather than crashing.
46
+ */
47
+ export function classifyFailure(result: StepResult): FailureClassification | null {
48
+ if (result.status === "pass" || result.status === "skip") return null;
49
+
50
+ // Network/runtime error before any HTTP response → env-side
51
+ if (result.status === "error") {
52
+ return {
53
+ failure_class: "env_issue",
54
+ failure_class_reason: result.error ?? "request failed before producing a response",
55
+ };
56
+ }
57
+
58
+ const respStatus = result.response?.status;
59
+ const provenance: SourceMetadata | null | undefined = result.provenance;
60
+ const generator = typeof provenance?.generator === "string" ? provenance.generator : undefined;
61
+ const failed = failedAssertionsOf(result);
62
+
63
+ // 1. Backend 5xx — always a backend bug regardless of test intent.
64
+ if (typeof respStatus === "number" && respStatus >= 500) {
65
+ return {
66
+ failure_class: "definitely_bug",
67
+ failure_class_reason: `API returned ${respStatus} — server-side error`,
68
+ };
69
+ }
70
+
71
+ // 2. Response did not match its OpenAPI schema — spec guarantees X, server returned ≠ X.
72
+ const schemaFail = failed.find((a) => ruleStartsWith(a, "schema."));
73
+ if (schemaFail) {
74
+ return {
75
+ failure_class: "definitely_bug",
76
+ failure_class_reason: `Response violates OpenAPI schema at ${schemaFail.field}`,
77
+ };
78
+ }
79
+
80
+ // 3. Mass-assignment probe: extras must not apply. A failed not_equals
81
+ // assertion on this generator means the sentinel value leaked through.
82
+ if (generator === "mass-assignment-probe") {
83
+ const extrasLeak = failed.find(
84
+ (a) => ruleStartsWith(a, "not_equals") || ruleStartsWith(a, "set_equals"),
85
+ );
86
+ if (extrasLeak) {
87
+ return {
88
+ failure_class: "definitely_bug",
89
+ failure_class_reason: `Mass-assignment: client-supplied extras were accepted (${extrasLeak.field})`,
90
+ };
91
+ }
92
+ }
93
+
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)
115
+ // from "API rejected with a different 4xx" (quirk).
116
+ if (generator === "negative-probe" || generator === "method-probe") {
117
+ const statusFail = failed.find((a) => a.field === "status");
118
+ if (statusFail && typeof respStatus === "number") {
119
+ const expected = expectedStatusList(statusFail);
120
+ const allExpected4xx = expected?.every((s) => s >= 400 && s < 500) ?? false;
121
+ if (allExpected4xx) {
122
+ if (respStatus >= 200 && respStatus < 300) {
123
+ return {
124
+ failure_class: "likely_bug",
125
+ failure_class_reason: `Negative probe expected 4xx, got ${respStatus} — API accepts invalid input`,
126
+ };
127
+ }
128
+ if (respStatus >= 400 && respStatus < 500) {
129
+ return {
130
+ failure_class: "quirk",
131
+ failure_class_reason: `Negative probe expected ${expected!.join("/")}, got ${respStatus} — different 4xx code`,
132
+ };
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ // Default: leave unclassified. UI / CLI render as "unclassified".
139
+ return null;
140
+ }