@kirrosh/zond 0.22.0 → 0.23.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 (256) hide show
  1. package/CHANGELOG.md +648 -0
  2. package/README.md +58 -6
  3. package/package.json +9 -6
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -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 +262 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +178 -7
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -46
  31. package/src/cli/commands/init/bootstrap.ts +30 -1
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +56 -3
  34. package/src/cli/commands/init/templates/agents.md +65 -61
  35. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  36. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  37. package/src/cli/commands/init/templates/skills/zond.md +592 -125
  38. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  39. package/src/cli/commands/prepare-fixtures.ts +135 -0
  40. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  41. package/src/cli/commands/probe/security.ts +454 -0
  42. package/src/cli/commands/probe/static.ts +255 -0
  43. package/src/cli/commands/probe/webhooks.ts +161 -0
  44. package/src/cli/commands/probe.ts +459 -0
  45. package/src/cli/commands/reference.ts +87 -0
  46. package/src/cli/commands/refresh-api.ts +169 -0
  47. package/src/cli/commands/remove-api.ts +150 -0
  48. package/src/cli/commands/report-bundle.ts +318 -0
  49. package/src/cli/commands/report.ts +241 -0
  50. package/src/cli/commands/request.ts +379 -4
  51. package/src/cli/commands/run.ts +842 -53
  52. package/src/cli/commands/session.ts +244 -0
  53. package/src/cli/commands/use.ts +18 -1
  54. package/src/cli/index.ts +20 -3
  55. package/src/cli/json-envelope.ts +112 -3
  56. package/src/cli/json-schemas.ts +263 -0
  57. package/src/cli/program.ts +198 -635
  58. package/src/cli/resolve.ts +105 -0
  59. package/src/cli/status-filter.ts +124 -0
  60. package/src/cli/util/api-context.ts +85 -0
  61. package/src/cli/version.ts +5 -0
  62. package/src/core/anti-fp/bootstrap.ts +34 -0
  63. package/src/core/anti-fp/index.ts +33 -0
  64. package/src/core/anti-fp/registry.ts +44 -0
  65. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  66. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  67. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  68. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  69. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  70. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  71. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  72. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  73. package/src/core/anti-fp/types.ts +68 -0
  74. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  75. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  76. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  77. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  78. package/src/core/checks/checks/cross_call_references.ts +134 -0
  79. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  80. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  81. package/src/core/checks/checks/ignored_auth.ts +211 -0
  82. package/src/core/checks/checks/index.ts +65 -0
  83. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  84. package/src/core/checks/checks/missing_required_header.ts +40 -0
  85. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  86. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  87. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  88. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  89. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  90. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  91. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  92. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  93. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  94. package/src/core/checks/checks/unsupported_method.ts +63 -0
  95. package/src/core/checks/checks/use_after_free.ts +78 -0
  96. package/src/core/checks/index.ts +30 -0
  97. package/src/core/checks/mode.ts +79 -0
  98. package/src/core/checks/recommended-action.ts +64 -0
  99. package/src/core/checks/registry.ts +78 -0
  100. package/src/core/checks/runner.ts +874 -0
  101. package/src/core/checks/sarif.ts +230 -0
  102. package/src/core/checks/stateful.ts +121 -0
  103. package/src/core/checks/types.ts +189 -0
  104. package/src/core/classifier/recommended-action.ts +222 -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 +185 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +151 -11
  110. package/src/core/diagnostics/failure-class.ts +120 -0
  111. package/src/core/diagnostics/failure-hints.ts +212 -9
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +156 -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 +42 -16
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/create-body.ts +89 -0
  124. package/src/core/generator/data-factory.ts +445 -19
  125. package/src/core/generator/describe.ts +1 -1
  126. package/src/core/generator/fixtures-builder.ts +325 -0
  127. package/src/core/generator/index.ts +7 -5
  128. package/src/core/generator/openapi-reader.ts +37 -3
  129. package/src/core/generator/path-param-disambig.ts +114 -0
  130. package/src/core/generator/resources-builder.ts +648 -0
  131. package/src/core/generator/schema-utils.ts +11 -3
  132. package/src/core/generator/serializer.ts +103 -13
  133. package/src/core/generator/suite-generator.ts +419 -111
  134. package/src/core/generator/types.ts +8 -0
  135. package/src/core/identity/identity-file.ts +129 -0
  136. package/src/core/lint/affects.ts +28 -0
  137. package/src/core/lint/config.ts +96 -0
  138. package/src/core/lint/format.ts +42 -0
  139. package/src/core/lint/index.ts +94 -0
  140. package/src/core/lint/reporter.ts +128 -0
  141. package/src/core/lint/rules/consistency.ts +158 -0
  142. package/src/core/lint/rules/heuristics.ts +97 -0
  143. package/src/core/lint/rules/strictness.ts +109 -0
  144. package/src/core/lint/types.ts +96 -0
  145. package/src/core/lint/walker.ts +248 -0
  146. package/src/core/meta/meta-store.ts +6 -73
  147. package/src/core/output/README.md +91 -0
  148. package/src/core/output/index.ts +13 -0
  149. package/src/core/output/run.ts +126 -0
  150. package/src/core/output/types.ts +129 -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 +57 -0
  159. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  160. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  161. package/src/core/probe/mass-assignment-template.ts +212 -0
  162. package/src/core/probe/method-probe.ts +43 -76
  163. package/src/core/probe/method-shared.ts +69 -0
  164. package/src/core/probe/negative-probe.ts +183 -149
  165. package/src/core/probe/orphan-tracker.ts +188 -0
  166. package/src/core/probe/path-discovery.ts +440 -0
  167. package/src/core/probe/probe-harness.ts +120 -0
  168. package/src/core/probe/registry.ts +89 -0
  169. package/src/core/probe/runner.ts +136 -0
  170. package/src/core/probe/security-probe-class.ts +201 -0
  171. package/src/core/probe/security-probe.ts +1453 -0
  172. package/src/core/probe/shared.ts +505 -0
  173. package/src/core/probe/static-probe-class.ts +125 -0
  174. package/src/core/probe/types.ts +165 -0
  175. package/src/core/probe/verdict-aggregator.ts +33 -0
  176. package/src/core/probe/webhooks-probe.ts +284 -0
  177. package/src/core/reporter/console.ts +41 -2
  178. package/src/core/reporter/index.ts +2 -3
  179. package/src/core/reporter/json.ts +11 -1
  180. package/src/core/reporter/junit.ts +27 -12
  181. package/src/core/reporter/ndjson.ts +37 -0
  182. package/src/core/reporter/types.ts +3 -0
  183. package/src/core/runner/assertions.ts +58 -1
  184. package/src/core/runner/async-pool.ts +108 -0
  185. package/src/core/runner/auth-path.ts +8 -0
  186. package/src/core/runner/ci-context.ts +72 -0
  187. package/src/core/runner/executor.ts +264 -20
  188. package/src/core/runner/form-encode.ts +51 -0
  189. package/src/core/runner/http-client.ts +75 -2
  190. package/src/core/runner/learn-drift.ts +293 -0
  191. package/src/core/runner/preflight-vars.ts +149 -0
  192. package/src/core/runner/progress-tracker.ts +73 -0
  193. package/src/core/runner/rate-limiter.ts +89 -17
  194. package/src/core/runner/run-kind.ts +39 -0
  195. package/src/core/runner/schema-validator.ts +312 -0
  196. package/src/core/runner/send-request.ts +153 -20
  197. package/src/core/runner/types.ts +38 -0
  198. package/src/core/secrets/registry.ts +164 -0
  199. package/src/core/secrets/secrets-file.ts +115 -0
  200. package/src/core/selectors/operation-filter.ts +144 -0
  201. package/src/core/setup-api.ts +415 -16
  202. package/src/core/severity/category.ts +94 -0
  203. package/src/core/severity/index.ts +121 -0
  204. package/src/core/spec/layers.ts +154 -0
  205. package/src/core/util/format-eta.ts +21 -0
  206. package/src/core/utils.ts +5 -1
  207. package/src/core/workspace/config.ts +129 -0
  208. package/src/core/workspace/manifest.ts +283 -0
  209. package/src/core/workspace/output-rotation.ts +62 -0
  210. package/src/core/workspace/triage-path.ts +87 -0
  211. package/src/db/lint-runs.ts +47 -0
  212. package/src/db/migrate.ts +126 -0
  213. package/src/db/migrations/0001_run_kind.sql +25 -0
  214. package/src/db/migrations/sql.d.ts +4 -0
  215. package/src/db/queries/collections.ts +133 -0
  216. package/src/db/queries/coverage.ts +9 -0
  217. package/src/db/queries/dashboard.ts +59 -0
  218. package/src/db/queries/results.ts +128 -0
  219. package/src/db/queries/runs.ts +235 -0
  220. package/src/db/queries/sessions.ts +42 -0
  221. package/src/db/queries/settings.ts +28 -0
  222. package/src/db/queries/types.ts +172 -0
  223. package/src/db/queries.ts +72 -802
  224. package/src/db/schema.ts +178 -50
  225. package/src/cli/commands/export.ts +0 -144
  226. package/src/cli/commands/guide.ts +0 -127
  227. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  228. package/src/cli/commands/probe-methods.ts +0 -108
  229. package/src/cli/commands/probe-validation.ts +0 -124
  230. package/src/cli/commands/serve.ts +0 -114
  231. package/src/cli/commands/sync.ts +0 -268
  232. package/src/cli/commands/update.ts +0 -189
  233. package/src/cli/commands/validate.ts +0 -34
  234. package/src/core/diagnostics/render-md.ts +0 -112
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -19
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Coverage reasons engine — pure function: spec endpoints + run results +
3
+ * workspace context → matrix cells with explicit reason codes.
4
+ *
5
+ * The matrix has rows per endpoint (METHOD path) and three status-class
6
+ * columns (`2xx`, `4xx`, `5xx`). Each cell carries:
7
+ * - status: covered | partial | uncovered
8
+ * - reasons: zero or more codes that explain the cell state
9
+ * - results: stored step results that contributed to this cell
10
+ *
11
+ * "Default" branch is intentionally omitted — extractEndpoints already
12
+ * skips OpenAPI's "default" status code, so making a column for it would
13
+ * be permanently empty and noisy.
14
+ *
15
+ * The function is intentionally I/O-free: callers (server, exporter, CLI)
16
+ * load endpoints/results/fixtures separately and feed them in. This keeps
17
+ * the engine trivial to unit-test.
18
+ */
19
+ import type { EndpointInfo } from "../generator/types.ts";
20
+ import type { StoredStepResult } from "../../db/queries.ts";
21
+
22
+ export type StatusClass = "2xx" | "4xx" | "5xx";
23
+ const STATUS_CLASSES: StatusClass[] = ["2xx", "4xx", "5xx"];
24
+
25
+ export type ReasonCode =
26
+ | "covered"
27
+ | "partial-failed"
28
+ | "not-generated"
29
+ | "no-spec"
30
+ | "deprecated"
31
+ | "no-fixtures"
32
+ | "ephemeral-only"
33
+ | "auth-scope-mismatch"
34
+ | "tag-filtered";
35
+
36
+ export interface CellResultRef {
37
+ resultId: number;
38
+ runId: number;
39
+ status: string;
40
+ responseStatus: number | null;
41
+ failureClass: string | null;
42
+ testName: string;
43
+ suiteFile: string | null;
44
+ }
45
+
46
+ export interface MatrixCell {
47
+ status: "covered" | "partial" | "uncovered";
48
+ reasons: ReasonCode[];
49
+ results: CellResultRef[];
50
+ }
51
+
52
+ export interface MatrixRow {
53
+ endpoint: string;
54
+ method: string;
55
+ path: string;
56
+ tags: string[];
57
+ deprecated: boolean;
58
+ security: string[];
59
+ declaredStatuses: number[];
60
+ cells: Record<StatusClass, MatrixCell>;
61
+ }
62
+
63
+ export interface BuildMatrixInput {
64
+ endpoints: EndpointInfo[];
65
+ results: StoredStepResult[];
66
+ fixturesAffected: Map<string, { name: string; required: boolean; source: string }[]>;
67
+ envVars: Set<string>;
68
+ ephemeralEndpoints: Set<string>;
69
+ tagFilter: string[];
70
+ profile: "safe" | "full";
71
+ }
72
+
73
+ export interface MatrixTotals {
74
+ endpoints: number;
75
+ cells: number;
76
+ covered: number;
77
+ partial: number;
78
+ uncovered: number;
79
+ byReason: Record<ReasonCode, number>;
80
+ }
81
+
82
+ export interface CoverageMatrix {
83
+ rows: MatrixRow[];
84
+ totals: MatrixTotals;
85
+ }
86
+
87
+ function endpointKey(method: string, path: string): string {
88
+ return `${method.toUpperCase()} ${path}`;
89
+ }
90
+
91
+ function classifyStatus(code: number): StatusClass | null {
92
+ if (code >= 200 && code < 300) return "2xx";
93
+ if (code >= 400 && code < 500) return "4xx";
94
+ if (code >= 500 && code < 600) return "5xx";
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * Build a regex that matches a concrete URL path against an OpenAPI path
100
+ * template. `/pets/{id}` becomes `^/pets/[^/]+$`.
101
+ */
102
+ function specPathToRegex(specPath: string): RegExp {
103
+ const pattern = specPath.replace(/\{[^}]+\}/g, "[^/]+");
104
+ return new RegExp(`^${pattern}$`);
105
+ }
106
+
107
+ function extractPathname(url: string | null): string | null {
108
+ if (!url) return null;
109
+ try {
110
+ const u = new URL(url);
111
+ return u.pathname;
112
+ } catch {
113
+ if (url.startsWith("/")) {
114
+ const q = url.indexOf("?");
115
+ return q === -1 ? url : url.slice(0, q);
116
+ }
117
+ return null;
118
+ }
119
+ }
120
+
121
+ function matchResultToEndpoint(
122
+ result: StoredStepResult,
123
+ endpointsByKey: Map<string, EndpointInfo>,
124
+ endpointsByMethod: Map<string, { ep: EndpointInfo; rx: RegExp }[]>,
125
+ ): EndpointInfo | null {
126
+ const provEp = (result.provenance as Record<string, unknown> | null)?.endpoint;
127
+ if (typeof provEp === "string") {
128
+ const sp = provEp.indexOf(" ");
129
+ const normalised = sp === -1
130
+ ? provEp
131
+ : `${provEp.slice(0, sp).toUpperCase()}${provEp.slice(sp)}`;
132
+ const direct = endpointsByKey.get(normalised);
133
+ if (direct) return direct;
134
+ }
135
+ const method = result.request_method?.toUpperCase();
136
+ if (!method) return null;
137
+ const candidates = endpointsByMethod.get(method);
138
+ if (!candidates) return null;
139
+ const pathname = extractPathname(result.request_url);
140
+ if (!pathname) return null;
141
+ for (const c of candidates) if (c.rx.test(pathname)) return c.ep;
142
+ return null;
143
+ }
144
+
145
+ function pathParamNames(path: string): string[] {
146
+ return [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]!);
147
+ }
148
+
149
+ function hasMissingPathFixtures(
150
+ ep: EndpointInfo,
151
+ fixturesAffected: Map<string, { name: string; required: boolean; source: string }[]>,
152
+ envVars: Set<string>,
153
+ ): boolean {
154
+ const params = pathParamNames(ep.path);
155
+ if (params.length === 0) return false;
156
+ const label = endpointKey(ep.method, ep.path);
157
+ const declared = fixturesAffected.get(label) ?? [];
158
+ // Manifest entry per param? Required + missing = no-fixtures.
159
+ const required = declared.filter((f) => f.source === "path" && f.required);
160
+ for (const f of required) if (!envVars.has(f.name)) return true;
161
+ // Manifest may not enumerate every param when it was generated before this
162
+ // endpoint existed, so also require: every {param} must map to an env var.
163
+ for (const p of params) {
164
+ if (envVars.has(p)) continue;
165
+ const declaredForP = declared.find((f) => f.name === p);
166
+ if (declaredForP && envVars.has(declaredForP.name)) continue;
167
+ return true;
168
+ }
169
+ return false;
170
+ }
171
+
172
+ function hasMissingAuthFixtures(ep: EndpointInfo, envVars: Set<string>): boolean {
173
+ if (ep.security.length === 0) return false;
174
+ // Convention: a security scheme name maps to one of `<name>`, `<name>_token`,
175
+ // or the lowercased `<name>_token` env var. The endpoint is satisfied if
176
+ // *any* of its required schemes has a configured token.
177
+ for (const scheme of ep.security) {
178
+ const variants = [scheme, `${scheme}_token`, `${scheme.toLowerCase()}_token`, "auth_token"];
179
+ if (variants.some((v) => envVars.has(v))) return false;
180
+ }
181
+ return true;
182
+ }
183
+
184
+ function declaredStatusClasses(ep: EndpointInfo): Set<StatusClass> {
185
+ const out = new Set<StatusClass>();
186
+ for (const r of ep.responses) {
187
+ const cls = classifyStatus(r.statusCode);
188
+ if (cls) out.add(cls);
189
+ }
190
+ return out;
191
+ }
192
+
193
+ export function buildCoverageMatrix(input: BuildMatrixInput): CoverageMatrix {
194
+ const endpointsByKey = new Map<string, EndpointInfo>();
195
+ const endpointsByMethod = new Map<string, { ep: EndpointInfo; rx: RegExp }[]>();
196
+ for (const ep of input.endpoints) {
197
+ endpointsByKey.set(endpointKey(ep.method, ep.path), ep);
198
+ const method = ep.method.toUpperCase();
199
+ const list = endpointsByMethod.get(method) ?? [];
200
+ list.push({ ep, rx: specPathToRegex(ep.path) });
201
+ endpointsByMethod.set(method, list);
202
+ }
203
+
204
+ // Bucket results: key = "METHOD path" → statusClass → list of refs.
205
+ const buckets = new Map<string, Record<StatusClass, CellResultRef[]>>();
206
+ for (const r of input.results) {
207
+ const ep = matchResultToEndpoint(r, endpointsByKey, endpointsByMethod);
208
+ if (!ep) continue;
209
+ const key = endpointKey(ep.method, ep.path);
210
+ let bucket = buckets.get(key);
211
+ if (!bucket) {
212
+ bucket = { "2xx": [], "4xx": [], "5xx": [] };
213
+ buckets.set(key, bucket);
214
+ }
215
+ if (r.response_status == null) continue;
216
+ const cls = classifyStatus(r.response_status);
217
+ if (!cls) continue;
218
+ bucket[cls].push({
219
+ resultId: r.id,
220
+ runId: r.run_id,
221
+ status: r.status,
222
+ responseStatus: r.response_status,
223
+ failureClass: r.failure_class,
224
+ testName: r.test_name,
225
+ suiteFile: r.suite_file,
226
+ });
227
+ }
228
+
229
+ const totals: MatrixTotals = {
230
+ endpoints: input.endpoints.length,
231
+ cells: 0,
232
+ covered: 0,
233
+ partial: 0,
234
+ uncovered: 0,
235
+ byReason: {
236
+ "covered": 0, "partial-failed": 0, "not-generated": 0, "no-spec": 0,
237
+ "deprecated": 0, "no-fixtures": 0, "ephemeral-only": 0,
238
+ "auth-scope-mismatch": 0, "tag-filtered": 0,
239
+ },
240
+ };
241
+
242
+ const filterTags = new Set(input.tagFilter);
243
+ const rows: MatrixRow[] = input.endpoints.map((ep) => {
244
+ const key = endpointKey(ep.method, ep.path);
245
+ const bucket = buckets.get(key) ?? { "2xx": [], "4xx": [], "5xx": [] };
246
+ const declared = declaredStatusClasses(ep);
247
+ const tagFiltered = filterTags.size > 0 && !ep.tags.some((t) => filterTags.has(t));
248
+ const cells: Record<StatusClass, MatrixCell> = { "2xx": null!, "4xx": null!, "5xx": null!};
249
+
250
+ for (const cls of STATUS_CLASSES) {
251
+ const refs = bucket[cls];
252
+ const passing = refs.some((r) => r.status === "pass");
253
+ const failing = refs.some((r) => r.status !== "pass");
254
+ const reasons: ReasonCode[] = [];
255
+ let cellStatus: MatrixCell["status"];
256
+ if (passing && !failing) {
257
+ cellStatus = "covered";
258
+ reasons.push("covered");
259
+ } else if (passing && failing) {
260
+ cellStatus = "covered";
261
+ reasons.push("covered", "partial-failed");
262
+ } else if (failing) {
263
+ cellStatus = "partial";
264
+ reasons.push("partial-failed");
265
+ } else {
266
+ cellStatus = "uncovered";
267
+ if (!declared.has(cls)) reasons.push("no-spec");
268
+ if (ep.deprecated) reasons.push("deprecated");
269
+ if (input.ephemeralEndpoints.has(key) && input.profile === "safe") reasons.push("ephemeral-only");
270
+ if (tagFiltered) reasons.push("tag-filtered");
271
+ if (hasMissingPathFixtures(ep, input.fixturesAffected, input.envVars)) reasons.push("no-fixtures");
272
+ if (hasMissingAuthFixtures(ep, input.envVars)) reasons.push("auth-scope-mismatch");
273
+ if (reasons.length === 0) reasons.push("not-generated");
274
+ }
275
+ // Always tag deprecated even on covered cells — it's an awareness flag.
276
+ if (ep.deprecated && !reasons.includes("deprecated")) reasons.push("deprecated");
277
+
278
+ cells[cls] = { status: cellStatus, reasons, results: refs };
279
+
280
+ totals.cells += 1;
281
+ if (cellStatus === "covered") totals.covered += 1;
282
+ else if (cellStatus === "partial") totals.partial += 1;
283
+ else totals.uncovered += 1;
284
+ for (const code of reasons) totals.byReason[code] += 1;
285
+ }
286
+
287
+ return {
288
+ endpoint: key,
289
+ method: ep.method.toUpperCase(),
290
+ path: ep.path,
291
+ tags: ep.tags,
292
+ deprecated: !!ep.deprecated,
293
+ security: ep.security,
294
+ declaredStatuses: ep.responses.map((r) => r.statusCode).sort((a, b) => a - b),
295
+ cells,
296
+ };
297
+ });
298
+
299
+ return { rows, totals };
300
+ }
@@ -1,10 +1,11 @@
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 { statusHint, classifyFailure, envHint, envCategory, schemaHint, computeSharedEnvIssue, clusterEnvIssues, buildEnvIssue, recommendedActionForGenerated, isGeneratedTest, softDeleteHint, type RecommendedAction, type EnvIssue } from "./failure-hints.ts";
5
+ import { buildSuggestedFixes, type SuggestedFix } from "./suggested-fixes.ts";
6
+ import { AUTH_PATH_RE } from "../runner/auth-path.ts";
6
7
 
7
- export function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean): string | undefined {
8
+ function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean): string | undefined {
8
9
  if (!raw) return undefined;
9
10
  if (verbose || raw.length < 500) return raw;
10
11
  const lines = raw.split(/\r?\n/);
@@ -24,7 +25,7 @@ export function truncateErrorMessage(raw: string | null | undefined, verbose?: b
24
25
  return msgLines.join("\n");
25
26
  }
26
27
 
27
- export function parseBodySafe(raw: string | null | undefined): unknown {
28
+ function parseBodySafe(raw: string | null | undefined): unknown {
28
29
  if (!raw) return undefined;
29
30
  const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "\u2026[truncated]" : raw;
30
31
  try {
@@ -40,7 +41,33 @@ const USEFUL_HEADERS = new Set([
40
41
  ]);
41
42
  const USEFUL_PREFIXES = ["x-", "ratelimit"];
42
43
 
43
- export function filterHeaders(raw: string | null | undefined): Record<string, string> | undefined {
44
+ /** ARV-103 (F8): true when at least one assertion on the failing step is
45
+ * a schema-validation kind. `--validate-schema` annotates each violated
46
+ * field with `kind: "schema"` (set in src/core/runner/schema-validator.ts).
47
+ * The assertions column is stored as JSON in SQLite; parse defensively. */
48
+ function hasSchemaAssertion(raw: string | unknown[] | null | undefined): boolean {
49
+ if (raw === null || raw === undefined) return false;
50
+ let arr: unknown[];
51
+ if (Array.isArray(raw)) {
52
+ arr = raw;
53
+ } else if (typeof raw === "string") {
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ if (!Array.isArray(parsed)) return false;
57
+ arr = parsed;
58
+ } catch {
59
+ return false;
60
+ }
61
+ } else {
62
+ return false;
63
+ }
64
+ for (const a of arr) {
65
+ if (a && typeof a === "object" && (a as { kind?: unknown }).kind === "schema") return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ function filterHeaders(raw: string | null | undefined): Record<string, string> | undefined {
44
71
  if (!raw) return undefined;
45
72
  try {
46
73
  const h = JSON.parse(raw) as Record<string, string>;
@@ -145,9 +172,13 @@ export interface DiagnoseResult {
145
172
  network_errors: number;
146
173
  };
147
174
  agent_directive?: string;
148
- env_issue?: string;
175
+ env_issue?: EnvIssue;
149
176
  auth_hint?: string;
150
177
  cascade_skips?: CascadeSkipGroup[];
178
+ /** TASK-29: actionable suggestions populated from 404 placeholder
179
+ * detection + .env.yaml unfilled-key audit. Empty / undefined when
180
+ * nothing actionable was found. */
181
+ suggested_fixes?: SuggestedFix[];
151
182
  failures: Array<{
152
183
  suite_name: string;
153
184
  test_name: string;
@@ -165,8 +196,23 @@ export interface DiagnoseResult {
165
196
  response_headers?: Record<string, string>;
166
197
  assertions: unknown;
167
198
  duration_ms: number | null;
199
+ /** ARV-159: when this entry is the representative of a collapsed group
200
+ * (status|failure_type signature), the total size of that group. Lets
201
+ * consumers reading `.data.failures[]` see "this signature stands for
202
+ * N underlying tests" without cross-referencing `.grouped_failures[]`.
203
+ * Omitted when no collapsing occurred (failures ≤ 5 or
204
+ * --verbose). */
205
+ group_count?: number;
168
206
  }>;
169
207
  grouped_failures?: FailureGroup[];
208
+ /** ARV-101 (F6): top-level aggregation keyed by `recommended_action`
209
+ * enum so triage agents (zond-triage skill) can route on the canonical
210
+ * action without re-folding `failures[].recommended_action` through
211
+ * `jq | group_by`. Built from the *full* failure set (not the compact
212
+ * subset), so counts match `.summary.failed`. Each bucket carries
213
+ * total count + a small examples list (`<suite>/<test>`). Empty when
214
+ * there are no failures. */
215
+ by_recommended_action?: Record<string, { count: number; examples: string[] }>;
170
216
  }
171
217
 
172
218
  export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, maxExamples?: number): DiagnoseResult {
@@ -191,7 +237,16 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
191
237
  softDeleteHint(r.response_status, r.request_method, parsedBody) ??
192
238
  statusHint(r.response_status);
193
239
  const failure_type = classifyFailure(r.status, r.response_status);
194
- const rec_action = recommendedAction(failure_type, r.response_status);
240
+ // ARV-42: generator-emitted suites should not route to fix_test_logic —
241
+ // editing the YAML gets clobbered on the next `zond audit`.
242
+ const generated = isGeneratedTest(r.provenance, r.suite_file);
243
+ // ARV-103 (F8): walk the assertions array to detect a schema-kind
244
+ // failure (--validate-schema annotates each assertion with its kind).
245
+ // When present, propagate the flag so the classifier routes to
246
+ // report_backend_bug — schema violations are real contract bugs, not
247
+ // test-logic mistakes.
248
+ const schema_violation = hasSchemaAssertion(r.assertions);
249
+ const rec_action = recommendedActionForGenerated(failure_type, r.response_status, generated, schema_violation);
195
250
  const sHint = schemaHint(failure_type, r.response_status);
196
251
  return {
197
252
  suite_name: r.suite_name,
@@ -213,7 +268,56 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
213
268
  };
214
269
  });
215
270
 
216
- const sharedEnvHint = computeSharedEnvIssue(failures, envFilePath);
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
+ }
217
321
 
218
322
  let apiErrors = 0, assertionFailures = 0, networkErrors = 0;
219
323
  let authFailureCount = 0;
@@ -274,6 +378,37 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
274
378
  ? { grouped_failures: undefined, compactFailures: failures }
275
379
  : groupFailures(failures, maxExamples);
276
380
 
381
+ // TASK-29: surface placeholder path-params + unfilled .env.yaml keys.
382
+ const suggestedFixes = buildSuggestedFixes({
383
+ failures: failures.map(f => ({
384
+ response_status: f.response_status,
385
+ request_url: f.request_url,
386
+ suite_name: f.suite_name,
387
+ test_name: f.test_name,
388
+ })),
389
+ envFilePath,
390
+ });
391
+
392
+ // ARV-101 (F6): aggregate failures by recommended_action enum so triage
393
+ // agents read .data.by_recommended_action.fix_env.count instead of
394
+ // re-folding failures[].recommended_action through `jq | group_by`. Built
395
+ // from the full failure set (not compactFailures) so counts match
396
+ // .summary.failed. Bounded examples list (5) keeps payload small while
397
+ // still pointing at concrete suites the agent can open.
398
+ const by_recommended_action: Record<string, { count: number; examples: string[] }> = {};
399
+ for (const f of failures) {
400
+ const key = f.recommended_action;
401
+ let bucket = by_recommended_action[key];
402
+ if (!bucket) {
403
+ bucket = { count: 0, examples: [] };
404
+ by_recommended_action[key] = bucket;
405
+ }
406
+ bucket.count += 1;
407
+ if (bucket.examples.length < 5) {
408
+ bucket.examples.push(`${f.suite_name}/${f.test_name}`);
409
+ }
410
+ }
411
+
277
412
  return {
278
413
  run: {
279
414
  id: diagRun.id,
@@ -290,15 +425,17 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, m
290
425
  network_errors: networkErrors,
291
426
  },
292
427
  ...(agent_directive ? { agent_directive } : {}),
293
- ...(sharedEnvHint ? { env_issue: sharedEnvHint } : {}),
428
+ ...(env_issue ? { env_issue } : {}),
294
429
  ...(auth_hint ? { auth_hint } : {}),
295
430
  ...(cascade_skips ? { cascade_skips } : {}),
431
+ ...(suggestedFixes.length > 0 ? { suggested_fixes: suggestedFixes } : {}),
296
432
  failures: compactFailures,
297
433
  ...(grouped_failures ? { grouped_failures } : {}),
434
+ ...(failures.length > 0 ? { by_recommended_action } : {}),
298
435
  };
299
436
  }
300
437
 
301
- type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction; hint?: string; response_status: number | null };
438
+ type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction; hint?: string; response_status: number | null; group_count?: number };
302
439
 
303
440
  /** Group similar failures for compact output. Exported for testing. */
304
441
  export function groupFailures<T extends FailureItem>(failures: T[], maxExamples = 2): { grouped_failures?: FailureGroup[]; compactFailures: T[] } {
@@ -352,7 +489,10 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
352
489
  if (isApiError) {
353
490
  compactFailures.push(...group.items);
354
491
  } else {
355
- compactFailures.push(group.items[0]!);
492
+ // ARV-159: tag the representative with the group size so
493
+ // `.data.failures[]` carries the multiplier inline.
494
+ const rep = { ...group.items[0]!, group_count: group.items.length };
495
+ compactFailures.push(rep as T);
356
496
  }
357
497
  }
358
498
 
@@ -0,0 +1,120 @@
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. Negative-probe family — distinguish "API accepted bad input" (likely_bug)
95
+ // from "API rejected with a different 4xx" (quirk).
96
+ if (generator === "negative-probe" || generator === "method-probe") {
97
+ const statusFail = failed.find((a) => a.field === "status");
98
+ if (statusFail && typeof respStatus === "number") {
99
+ const expected = expectedStatusList(statusFail);
100
+ const allExpected4xx = expected?.every((s) => s >= 400 && s < 500) ?? false;
101
+ if (allExpected4xx) {
102
+ if (respStatus >= 200 && respStatus < 300) {
103
+ return {
104
+ failure_class: "likely_bug",
105
+ failure_class_reason: `Negative probe expected 4xx, got ${respStatus} — API accepts invalid input`,
106
+ };
107
+ }
108
+ if (respStatus >= 400 && respStatus < 500) {
109
+ return {
110
+ failure_class: "quirk",
111
+ failure_class_reason: `Negative probe expected ${expected!.join("/")}, got ${respStatus} — different 4xx code`,
112
+ };
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ // Default: leave unclassified. UI / CLI render as "unclassified".
119
+ return null;
120
+ }