@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,9 +1,172 @@
1
- import { resolve, join } from "path";
2
- import { mkdirSync, writeFileSync, existsSync, readFileSync } from "fs";
1
+ import { resolve, join, relative } from "path";
2
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "fs";
3
3
  import { getDb } from "../db/schema.ts";
4
4
  import { createCollection, deleteCollection, findCollectionByNameOrId, normalizePath } from "../db/queries.ts";
5
- import { readOpenApiSpec, extractEndpoints } from "./generator/index.ts";
5
+ import {
6
+ readOpenApiSpec,
7
+ extractEndpoints,
8
+ extractSecuritySchemes,
9
+ buildCatalog,
10
+ serializeCatalog,
11
+ buildApiResourceMap,
12
+ serializeApiResourceMap,
13
+ buildApiFixtureManifest,
14
+ serializeApiFixtureManifest,
15
+ } from "./generator/index.ts";
16
+ import { decycleSchema } from "./generator/schema-utils.ts";
17
+ import { mergeOpenApiDocs } from "./spec/merge-specs.ts";
18
+ import { schemeVarName } from "./generator/suite-generator.ts";
19
+ import type { SecuritySchemeInfo } from "./generator/types.ts";
20
+ import { hashSpec } from "./meta/meta-store.ts";
6
21
  import { findWorkspaceRoot } from "./workspace/root.ts";
22
+ import { recordGeneratedFiles, type RecordInput } from "./workspace/manifest.ts";
23
+ import { CANONICAL_IDENTITY_KEYS } from "./identity/identity-file.ts";
24
+
25
+ /** Filename of the dereferenced spec snapshot inside `apis/<name>/`. */
26
+ export const SPEC_SNAPSHOT_FILENAME = "spec.json";
27
+
28
+ interface WriteArtifactsParams {
29
+ /** Dereferenced OpenAPI document. */
30
+ doc: unknown;
31
+ /** Absolute path to apis/<name>/. */
32
+ baseDir: string;
33
+ /** Collection name (goes into the catalog header). */
34
+ apiName: string;
35
+ /** Resolved server URL or "". */
36
+ baseUrl: string;
37
+ /** Absolute workspace root, used to compute the relative specSource. */
38
+ workspaceRoot: string;
39
+ /** Caller label for manifest entries (defaults to "zond add api"). */
40
+ by?: string;
41
+ }
42
+
43
+ /**
44
+ * Snapshot the dereferenced spec into `apis/<name>/spec.json` and emit the
45
+ * three derived artifacts (`.api-catalog.yaml`, `.api-resources.yaml`,
46
+ * `.api-fixtures.yaml`). Pure side-effect; safe to call from `setupApi` at
47
+ * register time and from `refreshApi` for re-snapshot.
48
+ */
49
+ export function writeArtifactsFromDoc(params: WriteArtifactsParams): void {
50
+ const { doc, baseDir, apiName, baseUrl, workspaceRoot, by = "zond add api" } = params;
51
+ const localSpecAbsPath = join(baseDir, SPEC_SNAPSHOT_FILENAME);
52
+ // Pass through decycleSchema first — large specs (Stripe, GitHub) contain
53
+ // mutually-recursive `$ref` chains that resolve to true object cycles after
54
+ // dereference, and raw JSON.stringify crashes on those with "cannot
55
+ // serialize cyclic structures" (ARV-145). decycleSchema collapses the
56
+ // second visit to `{ "x-circular": true }` (vendor-extension sentinel —
57
+ // NOT `$ref`, otherwise the parser tries to resolve "[Circular]" as a
58
+ // file path when re-reading spec.json, ARV-146) so the on-disk snapshot
59
+ // is self-contained, parser-safe JSON.
60
+ let serialized: string;
61
+ try {
62
+ serialized = JSON.stringify(decycleSchema(doc), null, 2);
63
+ } catch (err) {
64
+ const m = (err as Error).message;
65
+ throw new Error(
66
+ `spec_serialize_failed: could not serialize dereferenced spec for '${apiName}' — ${m}. ` +
67
+ `This usually means the spec contains a structure decycleSchema could not collapse; please open an issue with the spec URL.`,
68
+ );
69
+ }
70
+ writeFileSync(localSpecAbsPath, serialized + "\n", "utf-8");
71
+
72
+ const endpoints = extractEndpoints(doc as any);
73
+ const securitySchemes = extractSecuritySchemes(doc as any);
74
+ // Hash the on-disk file bytes — this is what `zond doctor` re-hashes when
75
+ // checking artifact freshness (TASK-215). Both sides now read the decycled
76
+ // form: setup-api writes it here, doctor re-reads the same file.
77
+ const specHash = hashSpec(readFileSync(localSpecAbsPath, "utf-8"));
78
+ const localSpecRelPath = relative(workspaceRoot, localSpecAbsPath).replace(/\\/g, "/");
79
+
80
+ const catalog = buildCatalog({
81
+ endpoints,
82
+ securitySchemes,
83
+ specSource: localSpecRelPath,
84
+ specHash,
85
+ apiName,
86
+ apiVersion: (doc as any).info?.version,
87
+ baseUrl,
88
+ });
89
+ const catalogPath = join(baseDir, ".api-catalog.yaml");
90
+ writeFileSync(catalogPath, serializeCatalog(catalog), "utf-8");
91
+
92
+ const resources = buildApiResourceMap({ endpoints, specHash });
93
+ const resourcesPath = join(baseDir, ".api-resources.yaml");
94
+ writeFileSync(resourcesPath, serializeApiResourceMap(resources), "utf-8");
95
+
96
+ const fixtures = buildApiFixtureManifest({
97
+ endpoints,
98
+ securitySchemes,
99
+ baseUrl: baseUrl || undefined,
100
+ specHash,
101
+ resourceMap: resources,
102
+ });
103
+ const fixturesPath = join(baseDir, ".api-fixtures.yaml");
104
+ writeFileSync(fixturesPath, serializeApiFixtureManifest(fixtures), "utf-8");
105
+
106
+ // Record artifacts in .zond/manifest.json (TASK-156).
107
+ try {
108
+ const entries: RecordInput[] = [
109
+ { path: localSpecAbsPath, by, api: apiName, category: "spec" },
110
+ { path: catalogPath, by, api: apiName, category: "catalog" },
111
+ { path: resourcesPath, by, api: apiName, category: "resources" },
112
+ { path: fixturesPath, by, api: apiName, category: "fixtures" },
113
+ ];
114
+ recordGeneratedFiles(workspaceRoot, entries);
115
+ } catch {
116
+ // best-effort
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Resolve a `collections.openapi_spec` value to a concrete file path the
122
+ * caller can read. Throws on a legacy / broken workspace so the user gets
123
+ * a single clear instruction instead of a downstream ENOENT.
124
+ *
125
+ * Resolution order:
126
+ * 1. URL (http/https) — return as-is.
127
+ * 2. Workspace-relative path (e.g. `apis/<name>/spec.json`) that exists.
128
+ * 3. Absolute filesystem path that exists. Treated as legacy: the spec
129
+ * is outside the workspace and not snapshotted into `apis/<name>/`.
130
+ * We let it through, but `assertLocalSpec` (used by run/report/doctor)
131
+ * will reject it.
132
+ * 4. Otherwise — throw a "legacy / stale workspace" error pointing at
133
+ * `zond refresh-api`.
134
+ */
135
+ export function resolveCollectionSpec(specRef: string): string {
136
+ if (/^https?:\/\//i.test(specRef)) return specRef;
137
+ const root = findWorkspaceRoot().root;
138
+ const local = resolve(root, specRef);
139
+ if (existsSync(local)) return local;
140
+ if (specRef.startsWith("/") && existsSync(specRef)) return specRef;
141
+ throw new Error(
142
+ `Spec for this API is missing at ${local}` +
143
+ (specRef.startsWith("/") ? ` (DB recorded an external path: ${specRef})` : "") +
144
+ `. The workspace looks legacy or stale — run \`zond refresh-api <name> [--spec <path|url>]\` to re-snapshot.`,
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Strict variant for code paths that must read the workspace-local
150
+ * snapshot (run/report/doctor). Returns the local absolute path or
151
+ * throws — never returns an external URL or path.
152
+ */
153
+ export function assertLocalSpec(specRef: string, apiName: string): string {
154
+ if (/^https?:\/\//i.test(specRef)) {
155
+ throw new Error(
156
+ `API '${apiName}' has a remote spec recorded (${specRef}) but no local snapshot. ` +
157
+ `Run \`zond refresh-api ${apiName}\` to materialise apis/${apiName}/${SPEC_SNAPSHOT_FILENAME}.`,
158
+ );
159
+ }
160
+ const root = findWorkspaceRoot().root;
161
+ const local = resolve(root, specRef);
162
+ if (!existsSync(local)) {
163
+ throw new Error(
164
+ `Local spec missing for API '${apiName}' (expected ${local}). ` +
165
+ `Run \`zond refresh-api ${apiName}\` to regenerate it.`,
166
+ );
167
+ }
168
+ return local;
169
+ }
7
170
 
8
171
  function toYaml(vars: Record<string, string>): string {
9
172
  const lines: string[] = [];
@@ -17,11 +180,17 @@ function toYaml(vars: Record<string, string>): string {
17
180
  export interface SetupApiOptions {
18
181
  name?: string;
19
182
  spec?: string;
183
+ /** ARV-375: additional specs to union into one merged document. When
184
+ * set (length > 1), takes precedence over `spec`; the specs are
185
+ * dereferenced individually then merged deterministically (last-wins on
186
+ * path/component collision, collisions surfaced as warnings). */
187
+ specs?: string[];
20
188
  dir?: string;
21
189
  envVars?: Record<string, string>;
22
190
  dbPath?: string;
23
191
  force?: boolean;
24
192
  insecure?: boolean;
193
+ caPath?: string;
25
194
  }
26
195
 
27
196
  export interface SetupApiResult {
@@ -32,11 +201,40 @@ export interface SetupApiResult {
32
201
  baseUrl: string;
33
202
  specEndpoints: number;
34
203
  pathParams?: Record<string, string>;
204
+ /** Auth-related env-var names auto-seeded as `@secret:<name>` (TASK-209). */
205
+ authVars?: string[];
35
206
  warnings?: string[];
36
207
  }
37
208
 
209
+ /**
210
+ * Walk the security schemes and derive the env-var names that
211
+ * `@readme/openapi-parser`-derived suites/probes will reference for auth
212
+ * tokens. Mirrors `getAuthHeaders` in src/core/probe/shared.ts:
213
+ * - HTTP bearer/basic/empty-scheme → schemeVarName(...) (default "auth_token")
214
+ * - apiKey in header named "Authorization" → schemeVarName(...)
215
+ * - apiKey in header (other name) → "api_key"
216
+ */
217
+ function deriveAuthVarNames(schemes: SecuritySchemeInfo[]): string[] {
218
+ const vars = new Set<string>();
219
+ for (const s of schemes) {
220
+ if (s.type === "http" && (s.scheme === "bearer" || s.scheme === "basic" || !s.scheme)) {
221
+ vars.add(schemeVarName(s, schemes));
222
+ } else if (s.type === "apiKey" && s.in === "header" && s.apiKeyName) {
223
+ if (s.apiKeyName === "Authorization") vars.add(schemeVarName(s, schemes));
224
+ else vars.add("api_key");
225
+ }
226
+ }
227
+ return [...vars];
228
+ }
229
+
38
230
  export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult> {
39
- const { spec, dbPath } = options;
231
+ const { dbPath } = options;
232
+ // ARV-375: `--spec` may be repeated. `specs` (from the variadic CLI
233
+ // collector) wins; fall back to the single `spec` for back-compat.
234
+ const specList = options.specs && options.specs.length > 0
235
+ ? options.specs
236
+ : (options.spec ? [options.spec] : []);
237
+ const spec = specList[0];
40
238
 
41
239
  getDb(dbPath);
42
240
 
@@ -47,11 +245,72 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
47
245
  const pathParams = new Map<string, string>();
48
246
  const warnings: string[] = [];
49
247
  let specTitle: string | undefined;
50
- if (spec) {
51
- const doc = await readOpenApiSpec(spec, { insecure: options.insecure });
52
- openapiSpec = spec;
248
+ // The dereferenced doc is captured here so we can copy it into the
249
+ // workspace after the target dir is computed (below). We snapshot the
250
+ // *dereferenced* form so all consumers (probe-*, generate, describe) read
251
+ // a self-contained file — no external $ref resolution at runtime.
252
+ let dereferencedDoc: unknown = null;
253
+ let authVarNames: string[] = [];
254
+ if (specList.length > 0) {
255
+ // Read + validate each spec independently. dereference() happily
256
+ // round-trips arbitrary JSON (e.g. a marketing-site landing payload),
257
+ // so without this guard `zond add api foo --spec https://example.com`
258
+ // silently registers a 0-endpoint API.
259
+ const loaded: { source: string; doc: any }[] = [];
260
+ for (const s of specList) {
261
+ const d = await readOpenApiSpec(s, { insecure: options.insecure, caPath: options.caPath });
262
+ const dAny = d as any;
263
+ if (typeof dAny?.openapi !== "string" && typeof dAny?.swagger !== "string") {
264
+ throw new Error(
265
+ `Spec at ${s} is not an OpenAPI/Swagger document — missing top-level 'openapi' (3.x) or 'swagger' (2.x) field. Check the URL points to the JSON spec, not the API root.`,
266
+ );
267
+ }
268
+ loaded.push({ source: s, doc: d });
269
+ }
270
+
271
+ let doc: any;
272
+ if (loaded.length === 1) {
273
+ doc = loaded[0]!.doc;
274
+ openapiSpec = spec!;
275
+ } else {
276
+ // ARV-375: deterministic union of N specs into one audit target.
277
+ const { merged, summary } = mergeOpenApiDocs(loaded);
278
+ doc = merged;
279
+ openapiSpec = null; // no single source path — rely on the local snapshot
280
+ const per = summary.sources.map((s) => `${s.paths} from ${s.source}`).join(", ");
281
+ warnings.push(`Merged ${summary.sources.length} specs → ${summary.totalPaths} paths (${per}).`);
282
+ if (summary.pathCollisions.length > 0) {
283
+ warnings.push(
284
+ `${summary.pathCollisions.length} path collision(s) — later spec wins: ${summary.pathCollisions.join(", ")}.`,
285
+ );
286
+ }
287
+ if (summary.schemaConflicts.length > 0) {
288
+ warnings.push(
289
+ `${summary.schemaConflicts.length} component collision(s) with differing shapes (later wins, verify): ${summary.schemaConflicts.join(", ")}.`,
290
+ );
291
+ }
292
+ }
293
+ dereferencedDoc = doc;
294
+ const docAny = doc as any;
53
295
  if ((doc as any).servers?.[0]?.url) {
54
296
  baseUrl = (doc as any).servers[0].url;
297
+ // Resolve OpenAPI server variables (e.g. {region}) using their declared defaults.
298
+ // Without this, the raw placeholder ends up in .env.yaml and causes cryptic TLS
299
+ // errors because the hostname literally contains "{region}".
300
+ const serverVars = (doc as any).servers[0].variables as
301
+ Record<string, { default?: string }> | undefined;
302
+ if (serverVars && baseUrl.includes("{")) {
303
+ baseUrl = baseUrl.replace(/\{([^}]+)\}/g, (_: string, name: string) =>
304
+ serverVars[name]?.default ?? `{${name}}`
305
+ );
306
+ }
307
+ // Warn if any placeholder remains unresolved (spec didn't provide a default).
308
+ const unresolved = [...baseUrl.matchAll(/\{([^}]+)\}/g)].map(m => m[1]);
309
+ if (unresolved.length > 0) {
310
+ warnings.push(
311
+ `base_url contains unresolved server variable${unresolved.length === 1 ? "" : "s"}: ${unresolved.map(v => `{${v}}`).join(", ")}. Edit .env.yaml and replace with a concrete value.`,
312
+ );
313
+ }
55
314
  }
56
315
  if (baseUrl && !baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
57
316
  warnings.push(`Spec server URL "${baseUrl}" is relative — requests will fail without a host. Override with envVars: {"base_url": "https://your-host${baseUrl}"}`);
@@ -59,16 +318,28 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
59
318
  specTitle = (doc as any).info?.title;
60
319
  const endpoints = extractEndpoints(doc);
61
320
  endpointCount = endpoints.length;
321
+ authVarNames = deriveAuthVarNames(extractSecuritySchemes(doc));
322
+
323
+ if (endpointCount === 0) {
324
+ const hasPaths = docAny?.paths && typeof docAny.paths === "object" && Object.keys(docAny.paths).length > 0;
325
+ warnings.push(
326
+ hasPaths
327
+ ? `Spec declares paths but no operations were extracted — every method may be filtered out (deprecated, unsupported method, etc.). Verify with \`zond catalog --api <name>\`.`
328
+ : `Spec contains 0 endpoints — 'paths' field is empty or missing. generate/probe/checks will produce nothing until the spec is fixed or replaced.`,
329
+ );
330
+ }
62
331
 
63
- // Collect unique path parameters with default values
332
+ // Collect unique path parameters. The default is empty string so that
333
+ // generated `skip_if: "{{<id>}} =="` checks auto-skip until the user
334
+ // fills the value in .env.yaml (TASK-210). Spec-provided examples are
335
+ // kept verbatim so they are still useful as concrete fixtures.
64
336
  for (const ep of endpoints) {
65
337
  for (const param of (ep.parameters ?? []).filter(p => p.in === "path")) {
66
338
  if (pathParams.has(param.name)) continue;
67
339
  const schema = param.schema as any;
68
340
  if (param.example !== undefined) pathParams.set(param.name, String(param.example));
69
341
  else if (schema?.example !== undefined) pathParams.set(param.name, String(schema.example));
70
- else if (schema?.type === "integer" || schema?.type === "number") pathParams.set(param.name, "1");
71
- else pathParams.set(param.name, "example");
342
+ else pathParams.set(param.name, "");
72
343
  }
73
344
  }
74
345
  }
@@ -96,9 +367,63 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
96
367
  : resolve(findWorkspaceRoot().root, `apis/${dirName}/`);
97
368
  const testPath = join(baseDir, "tests");
98
369
 
370
+ // Track whether we created baseDir from scratch so we can clean it up on
371
+ // failure — without this, a crash mid-setup (e.g. JSON.stringify on a
372
+ // cyclic spec, ARV-145) leaves apis/<slug>/tests/ behind and confuses the
373
+ // next `zond add api` invocation.
374
+ const baseDirPreExisted = existsSync(baseDir);
375
+
99
376
  // Create directories
100
377
  mkdirSync(testPath, { recursive: true });
101
378
 
379
+ try {
380
+ return await finalizeSetup({
381
+ name,
382
+ baseDir,
383
+ testPath,
384
+ baseUrl,
385
+ pathParams,
386
+ authVarNames,
387
+ envVarsOverride: options.envVars,
388
+ spec,
389
+ dereferencedDoc,
390
+ openapiSpec,
391
+ endpointCount,
392
+ warnings,
393
+ });
394
+ } catch (err) {
395
+ // Roll back partial filesystem state (apis/<slug>/tests/, spec.json, etc.)
396
+ // when we created the dir from scratch. Without this, the next
397
+ // `zond add api <same-name>` would still find a stale dir and demand
398
+ // --force, even though no collection was actually registered. ARV-145.
399
+ if (!baseDirPreExisted) {
400
+ try { rmSync(baseDir, { recursive: true, force: true }); } catch { /* best-effort */ }
401
+ }
402
+ throw err;
403
+ }
404
+ }
405
+
406
+ interface FinalizeSetupParams {
407
+ name: string;
408
+ baseDir: string;
409
+ testPath: string;
410
+ baseUrl: string;
411
+ pathParams: Map<string, string>;
412
+ authVarNames: string[];
413
+ envVarsOverride?: Record<string, string>;
414
+ spec?: string;
415
+ dereferencedDoc: unknown;
416
+ openapiSpec: string | null;
417
+ endpointCount: number;
418
+ warnings: string[];
419
+ }
420
+
421
+ async function finalizeSetup(p: FinalizeSetupParams): Promise<SetupApiResult> {
422
+ const {
423
+ name, baseDir, testPath, baseUrl, pathParams, authVarNames,
424
+ envVarsOverride, spec, dereferencedDoc, openapiSpec, endpointCount, warnings,
425
+ } = p;
426
+
102
427
  // Build environment variables
103
428
  const envVars: Record<string, string> = {};
104
429
  if (baseUrl) envVars.base_url = baseUrl;
@@ -106,8 +431,33 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
106
431
  for (const [k, v] of pathParams) {
107
432
  if (!(k in envVars)) envVars[k] = v;
108
433
  }
109
- if (options.envVars) {
110
- Object.assign(envVars, options.envVars);
434
+ // Auto-wire auth env-vars to .secrets.yaml so generated suites and probes
435
+ // resolve `{{auth_token}}` (etc.) without manual editing of .env.yaml
436
+ // (TASK-209). The matching `<var>: ""` placeholder is seeded into
437
+ // .secrets.yaml below — the user only fills the secret value.
438
+ for (const v of authVarNames) {
439
+ if (!(v in envVars)) envVars[v] = `@secret:${v}`;
440
+ }
441
+ // ARV-201 (R10/F2): when the spec declares no `components.securitySchemes`
442
+ // (GitHub publishes its OpenAPI this way), `deriveAuthVarNames` returns []
443
+ // and the loop above is a no-op — yet `zond request --api <name>` knows
444
+ // to attach `Authorization: Bearer <auth_token>` if the env carries an
445
+ // `auth_token`. Mirror the `.secrets.yaml` fallback (which already seeds
446
+ // `auth_token: ""` when authVarNames is empty) into `.env.yaml` so users
447
+ // do not need to hand-add `auth_token: "@secret:auth_token"` just to
448
+ // surface the Bearer header on bare specs.
449
+ if (authVarNames.length === 0 && !("auth_token" in envVars)) {
450
+ envVars.auth_token = "@secret:auth_token";
451
+ }
452
+ if (envVarsOverride) {
453
+ Object.assign(envVars, envVarsOverride);
454
+ }
455
+
456
+ // Spec-less registration is allowed, but we need a base_url from somewhere
457
+ // (server URL extracted from the spec, or envVars.base_url passed in by the
458
+ // caller). Without it the API is useless — `zond run` can't resolve {{base_url}}.
459
+ if (!spec && !envVars.base_url) {
460
+ throw new Error("setupApi requires --spec or envVars.base_url to register an API");
111
461
  }
112
462
 
113
463
  // Write .env.yaml in base_dir
@@ -116,26 +466,112 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
116
466
  writeFileSync(envFilePath, toYaml(envVars) + "\n", "utf-8");
117
467
  }
118
468
 
119
- // Create/update .gitignore to exclude env files
469
+ // Create/update .gitignore to exclude env / secret files
120
470
  const gitignorePath = join(baseDir, ".gitignore");
121
- const gitignoreContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
471
+ let gitignoreContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
472
+ let gitignoreDirty = false;
122
473
  if (!gitignoreContent.includes(".env*.yaml")) {
123
- writeFileSync(
124
- gitignorePath,
125
- gitignoreContent + (gitignoreContent.endsWith("\n") || !gitignoreContent ? "" : "\n") + ".env*.yaml\n",
126
- "utf-8",
127
- );
474
+ gitignoreContent +=
475
+ (gitignoreContent.endsWith("\n") || !gitignoreContent ? "" : "\n") + ".env*.yaml\n";
476
+ gitignoreDirty = true;
477
+ }
478
+ // TASK-170 (m-10): keep `.secrets.yaml` git-invisible. Older `.env*.yaml`
479
+ // pattern matched it accidentally; pin it explicitly so a future glob
480
+ // narrowing can't regress.
481
+ if (!gitignoreContent.includes(".secrets.yaml")) {
482
+ gitignoreContent += ".secrets.yaml\n";
483
+ gitignoreDirty = true;
484
+ }
485
+ // TASK-174 (m-10): identity values are not secrets but they identify
486
+ // the user's account; keep them out of git too.
487
+ if (!gitignoreContent.includes(".identity.yaml")) {
488
+ gitignoreContent += ".identity.yaml\n";
489
+ gitignoreDirty = true;
490
+ }
491
+ if (gitignoreDirty) {
492
+ writeFileSync(gitignorePath, gitignoreContent, "utf-8");
493
+ }
494
+
495
+ // Seed `.secrets.yaml` placeholder once. The file lives gitignored
496
+ // alongside `.env.yaml`; values placed here are auto-registered with
497
+ // the SecretRegistry on load and never appear in artifacts.
498
+ const secretsPath = join(baseDir, ".secrets.yaml");
499
+ if (!existsSync(secretsPath)) {
500
+ const seedKeys = authVarNames.length > 0 ? authVarNames : ["auth_token"];
501
+ const lines = [
502
+ "# .secrets.yaml — gitignored, holds raw secret values.",
503
+ "# Reference these in .env.yaml as @secret:<key>.",
504
+ "# Values here are auto-registered for redaction in DB writes,",
505
+ "# HTML/JSON/JUnit reports, case-studies, and probe digests.",
506
+ ];
507
+ for (const k of seedKeys) lines.push(`${k}: "" # required for live probes`);
508
+ lines.push("");
509
+ writeFileSync(secretsPath, lines.join("\n"), "utf-8");
510
+ }
511
+
512
+ // TASK-174 (m-10): seed `.identity.yaml` with placeholders for any
513
+ // canonical identity-keys that appear as path-params in the spec. The
514
+ // file is gitignored — values are visible locally for triage and
515
+ // hidden from outbound shares only when --redact-identity is set.
516
+ const identityKeys = [...pathParams.keys()].filter((k) =>
517
+ CANONICAL_IDENTITY_KEYS.has(k),
518
+ );
519
+ if (identityKeys.length > 0) {
520
+ const identityPath = join(baseDir, ".identity.yaml");
521
+ if (!existsSync(identityPath)) {
522
+ const lines = [
523
+ "# .identity.yaml — gitignored, holds non-secret-but-identifying values.",
524
+ "# Reference these in .env.yaml as @identity:<key>.",
525
+ "# Values are visible locally and in case-study drafts; pass",
526
+ "# --redact-identity (TASK-173) to swap them for placeholders when",
527
+ "# sharing reports outbound.",
528
+ ];
529
+ for (const k of identityKeys.sort()) {
530
+ lines.push(`${k}: "" # fill with your ${k}`);
531
+ }
532
+ lines.push("");
533
+ writeFileSync(identityPath, lines.join("\n"), "utf-8");
534
+ }
535
+ }
536
+
537
+ const workspaceRoot = findWorkspaceRoot().root;
538
+
539
+ // Snapshot the dereferenced spec into apis/<name>/spec.json so all later
540
+ // commands (catalog, describe, generate, probe-*) read a self-contained
541
+ // local file. The spec lives inside the workspace and is git-trackable;
542
+ // an external --spec path is only consulted at register/refresh time.
543
+ let localSpecAbsPath: string | null = null;
544
+ if (dereferencedDoc) {
545
+ localSpecAbsPath = join(baseDir, SPEC_SNAPSHOT_FILENAME);
546
+ writeArtifactsFromDoc({
547
+ doc: dereferencedDoc,
548
+ baseDir,
549
+ apiName: name,
550
+ baseUrl,
551
+ workspaceRoot,
552
+ });
128
553
  }
129
554
 
130
555
  const normalizedTestPath = normalizePath(testPath);
131
556
  const normalizedBaseDir = normalizePath(baseDir);
132
557
 
558
+ // Persist the workspace-relative path to the local snapshot in
559
+ // collections.openapi_spec so we don't rely on the user's external path
560
+ // sticking around. Falls back to the external path only when the snapshot
561
+ // could not be created (no spec given to setupApi).
562
+ // Don't run normalizePath on the relative form — it calls resolve() and
563
+ // would re-absolutize the path. Posix-style separators are enough for
564
+ // SQLite + Windows compat.
565
+ const dbSpecPath = localSpecAbsPath
566
+ ? relative(workspaceRoot, localSpecAbsPath).replace(/\\/g, "/")
567
+ : (openapiSpec ?? undefined);
568
+
133
569
  // Create collection in DB
134
570
  const collectionId = createCollection({
135
571
  name,
136
572
  base_dir: normalizedBaseDir,
137
573
  test_path: normalizedTestPath,
138
- openapi_spec: openapiSpec ?? undefined,
574
+ openapi_spec: dbSpecPath,
139
575
  });
140
576
 
141
577
  const pathParamsObj = pathParams.size > 0 ? Object.fromEntries(pathParams) : undefined;
@@ -148,6 +584,7 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
148
584
  baseUrl,
149
585
  specEndpoints: endpointCount,
150
586
  ...(pathParamsObj ? { pathParams: pathParamsObj } : {}),
587
+ ...(authVarNames.length > 0 ? { authVars: authVarNames } : {}),
151
588
  ...(warnings.length > 0 ? { warnings } : {}),
152
589
  };
153
590
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Finding category taxonomy (ARV-251, m-21 pivot).
3
+ *
4
+ * Four categories replace the old SARIF-internal trio
5
+ * (conformance/security/data-rejection/other). Each finding belongs to
6
+ * exactly one category. Categories drive the per-section roll-up in
7
+ * reports — a small team sees `0 security, 12 reliability, 40 contract,
8
+ * 200 hygiene` and knows where to start, instead of one flat HIGH/LOW
9
+ * pile.
10
+ *
11
+ * Definitions:
12
+ * - `security`: exploit / auth / data-exposure / injection signals.
13
+ * IDOR, mass-assignment with persistence, missing-auth, open CORS,
14
+ * reflected XSS / CRLF in dangerous context.
15
+ * - `reliability`: server crashes / 5xx on valid input / rate-limit
16
+ * absent / timeouts. Not security per se but production-impact.
17
+ * - `contract`: spec ↔ runtime drift. Schema mismatch, wrong status
18
+ * codes, content-type negotiation failures, data-rejection contract
19
+ * violations, missing required headers.
20
+ * - `hygiene`: static spec-lint, accept-without-impact, framework-level
21
+ * "could be intentional" signals, naming/style. Bulk volume lives here.
22
+ */
23
+
24
+ export type Category = "security" | "reliability" | "contract" | "hygiene";
25
+
26
+ export const CATEGORY_ORDER: readonly Category[] = [
27
+ "security",
28
+ "reliability",
29
+ "contract",
30
+ "hygiene",
31
+ ] as const;
32
+
33
+ /**
34
+ * Category lookup by check-id / probe-class-id. Adding a new
35
+ * finding-producer requires extending this map — the SARIF + reporter
36
+ * tests assert full coverage so a missing entry fails loudly rather
37
+ * than silently routing to a fallback.
38
+ */
39
+ export const CATEGORY_BY_ID: Record<string, Category> = {
40
+ // ── reliability ──────────────────────────────────────────────
41
+ // 5xx on valid input is a server crash, not a security issue.
42
+ not_a_server_error: "reliability",
43
+
44
+ // ── contract ─────────────────────────────────────────────────
45
+ // Spec-conformance checks. Server behaviour drifts from declared
46
+ // contract — fix-worthy, but rarely security per se.
47
+ status_code_conformance: "contract",
48
+ content_type_conformance: "contract",
49
+ response_headers_conformance: "contract",
50
+ response_schema_conformance: "contract",
51
+ missing_required_header: "contract",
52
+ unsupported_method: "contract",
53
+ // Data-rejection: server should reject malformed bodies per spec.
54
+ // Falls under contract (spec said "reject", server accepted).
55
+ negative_data_rejection: "contract",
56
+ positive_data_acceptance: "contract",
57
+ // m-20 state-aware probes — cross-resource contract invariants.
58
+ cross_call_references: "contract",
59
+ idempotency_replay: "contract",
60
+ pagination_invariants: "contract",
61
+ lifecycle_transitions: "contract",
62
+ // ARV-256 (m-21) — small-team value-add. Rate-limit absence is a
63
+ // production reliability concern, not a security exploit.
64
+ rate_limit_headers_absent: "reliability",
65
+ open_cors_on_sensitive: "security",
66
+
67
+ // ── security ─────────────────────────────────────────────────
68
+ ignored_auth: "security",
69
+ use_after_free: "security",
70
+ ensure_resource_availability: "security",
71
+ // Probe classes
72
+ "mass-assignment": "security",
73
+ ssrf: "security",
74
+ crlf: "security",
75
+ xss: "security",
76
+ sqli: "security",
77
+ "open-redirect": "security",
78
+ "path-traversal": "security",
79
+ webhooks: "security",
80
+ };
81
+
82
+ /**
83
+ * Map check-id / probe-class-id to category. Falls back to "hygiene"
84
+ * for unknown ids — the SARIF reporter & tests assert this fallback
85
+ * never triggers for registered checks/probes.
86
+ */
87
+ export function categoryFor(id: string): Category {
88
+ return CATEGORY_BY_ID[id] ?? "hygiene";
89
+ }
90
+
91
+ export function emptyCategoryBuckets(): Record<Category, number> {
92
+ return { security: 0, reliability: 0, contract: 0, hygiene: 0 };
93
+ }
94
+