@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
@@ -1,9 +1,171 @@
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 { schemeVarName } from "./generator/suite-generator.ts";
18
+ import type { SecuritySchemeInfo } from "./generator/types.ts";
19
+ import { hashSpec } from "./meta/meta-store.ts";
6
20
  import { findWorkspaceRoot } from "./workspace/root.ts";
21
+ import { recordGeneratedFiles, type RecordInput } from "./workspace/manifest.ts";
22
+ import { CANONICAL_IDENTITY_KEYS } from "./identity/identity-file.ts";
23
+
24
+ /** Filename of the dereferenced spec snapshot inside `apis/<name>/`. */
25
+ export const SPEC_SNAPSHOT_FILENAME = "spec.json";
26
+
27
+ interface WriteArtifactsParams {
28
+ /** Dereferenced OpenAPI document. */
29
+ doc: unknown;
30
+ /** Absolute path to apis/<name>/. */
31
+ baseDir: string;
32
+ /** Collection name (goes into the catalog header). */
33
+ apiName: string;
34
+ /** Resolved server URL or "". */
35
+ baseUrl: string;
36
+ /** Absolute workspace root, used to compute the relative specSource. */
37
+ workspaceRoot: string;
38
+ /** Caller label for manifest entries (defaults to "zond add api"). */
39
+ by?: string;
40
+ }
41
+
42
+ /**
43
+ * Snapshot the dereferenced spec into `apis/<name>/spec.json` and emit the
44
+ * three derived artifacts (`.api-catalog.yaml`, `.api-resources.yaml`,
45
+ * `.api-fixtures.yaml`). Pure side-effect; safe to call from `setupApi` at
46
+ * register time and from `refreshApi` for re-snapshot.
47
+ */
48
+ export function writeArtifactsFromDoc(params: WriteArtifactsParams): void {
49
+ const { doc, baseDir, apiName, baseUrl, workspaceRoot, by = "zond add api" } = params;
50
+ const localSpecAbsPath = join(baseDir, SPEC_SNAPSHOT_FILENAME);
51
+ // Pass through decycleSchema first — large specs (Stripe, GitHub) contain
52
+ // mutually-recursive `$ref` chains that resolve to true object cycles after
53
+ // dereference, and raw JSON.stringify crashes on those with "cannot
54
+ // serialize cyclic structures" (ARV-145). decycleSchema collapses the
55
+ // second visit to `{ "x-circular": true }` (vendor-extension sentinel —
56
+ // NOT `$ref`, otherwise the parser tries to resolve "[Circular]" as a
57
+ // file path when re-reading spec.json, ARV-146) so the on-disk snapshot
58
+ // is self-contained, parser-safe JSON.
59
+ let serialized: string;
60
+ try {
61
+ serialized = JSON.stringify(decycleSchema(doc), null, 2);
62
+ } catch (err) {
63
+ const m = (err as Error).message;
64
+ throw new Error(
65
+ `spec_serialize_failed: could not serialize dereferenced spec for '${apiName}' — ${m}. ` +
66
+ `This usually means the spec contains a structure decycleSchema could not collapse; please open an issue with the spec URL.`,
67
+ );
68
+ }
69
+ writeFileSync(localSpecAbsPath, serialized + "\n", "utf-8");
70
+
71
+ const endpoints = extractEndpoints(doc as any);
72
+ const securitySchemes = extractSecuritySchemes(doc as any);
73
+ // Hash the on-disk file bytes — this is what `zond doctor` re-hashes when
74
+ // checking artifact freshness (TASK-215). Both sides now read the decycled
75
+ // form: setup-api writes it here, doctor re-reads the same file.
76
+ const specHash = hashSpec(readFileSync(localSpecAbsPath, "utf-8"));
77
+ const localSpecRelPath = relative(workspaceRoot, localSpecAbsPath).replace(/\\/g, "/");
78
+
79
+ const catalog = buildCatalog({
80
+ endpoints,
81
+ securitySchemes,
82
+ specSource: localSpecRelPath,
83
+ specHash,
84
+ apiName,
85
+ apiVersion: (doc as any).info?.version,
86
+ baseUrl,
87
+ });
88
+ const catalogPath = join(baseDir, ".api-catalog.yaml");
89
+ writeFileSync(catalogPath, serializeCatalog(catalog), "utf-8");
90
+
91
+ const resources = buildApiResourceMap({ endpoints, specHash });
92
+ const resourcesPath = join(baseDir, ".api-resources.yaml");
93
+ writeFileSync(resourcesPath, serializeApiResourceMap(resources), "utf-8");
94
+
95
+ const fixtures = buildApiFixtureManifest({
96
+ endpoints,
97
+ securitySchemes,
98
+ baseUrl: baseUrl || undefined,
99
+ specHash,
100
+ resourceMap: resources,
101
+ });
102
+ const fixturesPath = join(baseDir, ".api-fixtures.yaml");
103
+ writeFileSync(fixturesPath, serializeApiFixtureManifest(fixtures), "utf-8");
104
+
105
+ // Record artifacts in .zond/manifest.json (TASK-156).
106
+ try {
107
+ const entries: RecordInput[] = [
108
+ { path: localSpecAbsPath, by, api: apiName, category: "spec" },
109
+ { path: catalogPath, by, api: apiName, category: "catalog" },
110
+ { path: resourcesPath, by, api: apiName, category: "resources" },
111
+ { path: fixturesPath, by, api: apiName, category: "fixtures" },
112
+ ];
113
+ recordGeneratedFiles(workspaceRoot, entries);
114
+ } catch {
115
+ // best-effort
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Resolve a `collections.openapi_spec` value to a concrete file path the
121
+ * caller can read. Throws on a legacy / broken workspace so the user gets
122
+ * a single clear instruction instead of a downstream ENOENT.
123
+ *
124
+ * Resolution order:
125
+ * 1. URL (http/https) — return as-is.
126
+ * 2. Workspace-relative path (e.g. `apis/<name>/spec.json`) that exists.
127
+ * 3. Absolute filesystem path that exists. Treated as legacy: the spec
128
+ * is outside the workspace and not snapshotted into `apis/<name>/`.
129
+ * We let it through, but `assertLocalSpec` (used by run/report/doctor)
130
+ * will reject it.
131
+ * 4. Otherwise — throw a "legacy / stale workspace" error pointing at
132
+ * `zond refresh-api`.
133
+ */
134
+ export function resolveCollectionSpec(specRef: string): string {
135
+ if (/^https?:\/\//i.test(specRef)) return specRef;
136
+ const root = findWorkspaceRoot().root;
137
+ const local = resolve(root, specRef);
138
+ if (existsSync(local)) return local;
139
+ if (specRef.startsWith("/") && existsSync(specRef)) return specRef;
140
+ throw new Error(
141
+ `Spec for this API is missing at ${local}` +
142
+ (specRef.startsWith("/") ? ` (DB recorded an external path: ${specRef})` : "") +
143
+ `. The workspace looks legacy or stale — run \`zond refresh-api <name> [--spec <path|url>]\` to re-snapshot.`,
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Strict variant for code paths that must read the workspace-local
149
+ * snapshot (run/report/doctor). Returns the local absolute path or
150
+ * throws — never returns an external URL or path.
151
+ */
152
+ export function assertLocalSpec(specRef: string, apiName: string): string {
153
+ if (/^https?:\/\//i.test(specRef)) {
154
+ throw new Error(
155
+ `API '${apiName}' has a remote spec recorded (${specRef}) but no local snapshot. ` +
156
+ `Run \`zond refresh-api ${apiName}\` to materialise apis/${apiName}/${SPEC_SNAPSHOT_FILENAME}.`,
157
+ );
158
+ }
159
+ const root = findWorkspaceRoot().root;
160
+ const local = resolve(root, specRef);
161
+ if (!existsSync(local)) {
162
+ throw new Error(
163
+ `Local spec missing for API '${apiName}' (expected ${local}). ` +
164
+ `Run \`zond refresh-api ${apiName}\` to regenerate it.`,
165
+ );
166
+ }
167
+ return local;
168
+ }
7
169
 
8
170
  function toYaml(vars: Record<string, string>): string {
9
171
  const lines: string[] = [];
@@ -32,9 +194,32 @@ export interface SetupApiResult {
32
194
  baseUrl: string;
33
195
  specEndpoints: number;
34
196
  pathParams?: Record<string, string>;
197
+ /** Auth-related env-var names auto-seeded as `@secret:<name>` (TASK-209). */
198
+ authVars?: string[];
35
199
  warnings?: string[];
36
200
  }
37
201
 
202
+ /**
203
+ * Walk the security schemes and derive the env-var names that
204
+ * `@readme/openapi-parser`-derived suites/probes will reference for auth
205
+ * tokens. Mirrors `getAuthHeaders` in src/core/probe/shared.ts:
206
+ * - HTTP bearer/basic/empty-scheme → schemeVarName(...) (default "auth_token")
207
+ * - apiKey in header named "Authorization" → schemeVarName(...)
208
+ * - apiKey in header (other name) → "api_key"
209
+ */
210
+ function deriveAuthVarNames(schemes: SecuritySchemeInfo[]): string[] {
211
+ const vars = new Set<string>();
212
+ for (const s of schemes) {
213
+ if (s.type === "http" && (s.scheme === "bearer" || s.scheme === "basic" || !s.scheme)) {
214
+ vars.add(schemeVarName(s, schemes));
215
+ } else if (s.type === "apiKey" && s.in === "header" && s.apiKeyName) {
216
+ if (s.apiKeyName === "Authorization") vars.add(schemeVarName(s, schemes));
217
+ else vars.add("api_key");
218
+ }
219
+ }
220
+ return [...vars];
221
+ }
222
+
38
223
  export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult> {
39
224
  const { spec, dbPath } = options;
40
225
 
@@ -47,11 +232,47 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
47
232
  const pathParams = new Map<string, string>();
48
233
  const warnings: string[] = [];
49
234
  let specTitle: string | undefined;
235
+ // The dereferenced doc is captured here so we can copy it into the
236
+ // workspace after the target dir is computed (below). We snapshot the
237
+ // *dereferenced* form so all consumers (probe-*, generate, describe) read
238
+ // a self-contained file — no external $ref resolution at runtime.
239
+ let dereferencedDoc: unknown = null;
240
+ let authVarNames: string[] = [];
50
241
  if (spec) {
51
242
  const doc = await readOpenApiSpec(spec, { insecure: options.insecure });
243
+ // Validate the document looks like OpenAPI/Swagger before we snapshot it.
244
+ // dereference() happily round-trips arbitrary JSON (e.g. a marketing-site
245
+ // landing payload), so without this guard `zond add api foo --spec
246
+ // https://example.com` silently registers a 0-endpoint API.
247
+ const docAny = doc as any;
248
+ const hasOpenApiField = typeof docAny?.openapi === "string";
249
+ const hasSwaggerField = typeof docAny?.swagger === "string";
250
+ if (!hasOpenApiField && !hasSwaggerField) {
251
+ throw new Error(
252
+ `Spec at ${spec} 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.`,
253
+ );
254
+ }
255
+ dereferencedDoc = doc;
52
256
  openapiSpec = spec;
53
257
  if ((doc as any).servers?.[0]?.url) {
54
258
  baseUrl = (doc as any).servers[0].url;
259
+ // Resolve OpenAPI server variables (e.g. {region}) using their declared defaults.
260
+ // Without this, the raw placeholder ends up in .env.yaml and causes cryptic TLS
261
+ // errors because the hostname literally contains "{region}".
262
+ const serverVars = (doc as any).servers[0].variables as
263
+ Record<string, { default?: string }> | undefined;
264
+ if (serverVars && baseUrl.includes("{")) {
265
+ baseUrl = baseUrl.replace(/\{([^}]+)\}/g, (_: string, name: string) =>
266
+ serverVars[name]?.default ?? `{${name}}`
267
+ );
268
+ }
269
+ // Warn if any placeholder remains unresolved (spec didn't provide a default).
270
+ const unresolved = [...baseUrl.matchAll(/\{([^}]+)\}/g)].map(m => m[1]);
271
+ if (unresolved.length > 0) {
272
+ warnings.push(
273
+ `base_url contains unresolved server variable${unresolved.length === 1 ? "" : "s"}: ${unresolved.map(v => `{${v}}`).join(", ")}. Edit .env.yaml and replace with a concrete value.`,
274
+ );
275
+ }
55
276
  }
56
277
  if (baseUrl && !baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
57
278
  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 +280,28 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
59
280
  specTitle = (doc as any).info?.title;
60
281
  const endpoints = extractEndpoints(doc);
61
282
  endpointCount = endpoints.length;
283
+ authVarNames = deriveAuthVarNames(extractSecuritySchemes(doc));
62
284
 
63
- // Collect unique path parameters with default values
285
+ if (endpointCount === 0) {
286
+ const hasPaths = docAny?.paths && typeof docAny.paths === "object" && Object.keys(docAny.paths).length > 0;
287
+ warnings.push(
288
+ hasPaths
289
+ ? `Spec declares paths but no operations were extracted — every method may be filtered out (deprecated, unsupported method, etc.). Verify with \`zond catalog --api <name>\`.`
290
+ : `Spec contains 0 endpoints — 'paths' field is empty or missing. generate/probe/checks will produce nothing until the spec is fixed or replaced.`,
291
+ );
292
+ }
293
+
294
+ // Collect unique path parameters. The default is empty string so that
295
+ // generated `skip_if: "{{<id>}} =="` checks auto-skip until the user
296
+ // fills the value in .env.yaml (TASK-210). Spec-provided examples are
297
+ // kept verbatim so they are still useful as concrete fixtures.
64
298
  for (const ep of endpoints) {
65
299
  for (const param of (ep.parameters ?? []).filter(p => p.in === "path")) {
66
300
  if (pathParams.has(param.name)) continue;
67
301
  const schema = param.schema as any;
68
302
  if (param.example !== undefined) pathParams.set(param.name, String(param.example));
69
303
  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");
304
+ else pathParams.set(param.name, "");
72
305
  }
73
306
  }
74
307
  }
@@ -96,9 +329,63 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
96
329
  : resolve(findWorkspaceRoot().root, `apis/${dirName}/`);
97
330
  const testPath = join(baseDir, "tests");
98
331
 
332
+ // Track whether we created baseDir from scratch so we can clean it up on
333
+ // failure — without this, a crash mid-setup (e.g. JSON.stringify on a
334
+ // cyclic spec, ARV-145) leaves apis/<slug>/tests/ behind and confuses the
335
+ // next `zond add api` invocation.
336
+ const baseDirPreExisted = existsSync(baseDir);
337
+
99
338
  // Create directories
100
339
  mkdirSync(testPath, { recursive: true });
101
340
 
341
+ try {
342
+ return await finalizeSetup({
343
+ name,
344
+ baseDir,
345
+ testPath,
346
+ baseUrl,
347
+ pathParams,
348
+ authVarNames,
349
+ envVarsOverride: options.envVars,
350
+ spec,
351
+ dereferencedDoc,
352
+ openapiSpec,
353
+ endpointCount,
354
+ warnings,
355
+ });
356
+ } catch (err) {
357
+ // Roll back partial filesystem state (apis/<slug>/tests/, spec.json, etc.)
358
+ // when we created the dir from scratch. Without this, the next
359
+ // `zond add api <same-name>` would still find a stale dir and demand
360
+ // --force, even though no collection was actually registered. ARV-145.
361
+ if (!baseDirPreExisted) {
362
+ try { rmSync(baseDir, { recursive: true, force: true }); } catch { /* best-effort */ }
363
+ }
364
+ throw err;
365
+ }
366
+ }
367
+
368
+ interface FinalizeSetupParams {
369
+ name: string;
370
+ baseDir: string;
371
+ testPath: string;
372
+ baseUrl: string;
373
+ pathParams: Map<string, string>;
374
+ authVarNames: string[];
375
+ envVarsOverride?: Record<string, string>;
376
+ spec?: string;
377
+ dereferencedDoc: unknown;
378
+ openapiSpec: string | null;
379
+ endpointCount: number;
380
+ warnings: string[];
381
+ }
382
+
383
+ async function finalizeSetup(p: FinalizeSetupParams): Promise<SetupApiResult> {
384
+ const {
385
+ name, baseDir, testPath, baseUrl, pathParams, authVarNames,
386
+ envVarsOverride, spec, dereferencedDoc, openapiSpec, endpointCount, warnings,
387
+ } = p;
388
+
102
389
  // Build environment variables
103
390
  const envVars: Record<string, string> = {};
104
391
  if (baseUrl) envVars.base_url = baseUrl;
@@ -106,8 +393,33 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
106
393
  for (const [k, v] of pathParams) {
107
394
  if (!(k in envVars)) envVars[k] = v;
108
395
  }
109
- if (options.envVars) {
110
- Object.assign(envVars, options.envVars);
396
+ // Auto-wire auth env-vars to .secrets.yaml so generated suites and probes
397
+ // resolve `{{auth_token}}` (etc.) without manual editing of .env.yaml
398
+ // (TASK-209). The matching `<var>: ""` placeholder is seeded into
399
+ // .secrets.yaml below — the user only fills the secret value.
400
+ for (const v of authVarNames) {
401
+ if (!(v in envVars)) envVars[v] = `@secret:${v}`;
402
+ }
403
+ // ARV-201 (R10/F2): when the spec declares no `components.securitySchemes`
404
+ // (GitHub publishes its OpenAPI this way), `deriveAuthVarNames` returns []
405
+ // and the loop above is a no-op — yet `zond request --api <name>` knows
406
+ // to attach `Authorization: Bearer <auth_token>` if the env carries an
407
+ // `auth_token`. Mirror the `.secrets.yaml` fallback (which already seeds
408
+ // `auth_token: ""` when authVarNames is empty) into `.env.yaml` so users
409
+ // do not need to hand-add `auth_token: "@secret:auth_token"` just to
410
+ // surface the Bearer header on bare specs.
411
+ if (authVarNames.length === 0 && !("auth_token" in envVars)) {
412
+ envVars.auth_token = "@secret:auth_token";
413
+ }
414
+ if (envVarsOverride) {
415
+ Object.assign(envVars, envVarsOverride);
416
+ }
417
+
418
+ // Spec-less registration is allowed, but we need a base_url from somewhere
419
+ // (server URL extracted from the spec, or envVars.base_url passed in by the
420
+ // caller). Without it the API is useless — `zond run` can't resolve {{base_url}}.
421
+ if (!spec && !envVars.base_url) {
422
+ throw new Error("setupApi requires --spec or envVars.base_url to register an API");
111
423
  }
112
424
 
113
425
  // Write .env.yaml in base_dir
@@ -116,26 +428,112 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
116
428
  writeFileSync(envFilePath, toYaml(envVars) + "\n", "utf-8");
117
429
  }
118
430
 
119
- // Create/update .gitignore to exclude env files
431
+ // Create/update .gitignore to exclude env / secret files
120
432
  const gitignorePath = join(baseDir, ".gitignore");
121
- const gitignoreContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
433
+ let gitignoreContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
434
+ let gitignoreDirty = false;
122
435
  if (!gitignoreContent.includes(".env*.yaml")) {
123
- writeFileSync(
124
- gitignorePath,
125
- gitignoreContent + (gitignoreContent.endsWith("\n") || !gitignoreContent ? "" : "\n") + ".env*.yaml\n",
126
- "utf-8",
127
- );
436
+ gitignoreContent +=
437
+ (gitignoreContent.endsWith("\n") || !gitignoreContent ? "" : "\n") + ".env*.yaml\n";
438
+ gitignoreDirty = true;
439
+ }
440
+ // TASK-170 (m-10): keep `.secrets.yaml` git-invisible. Older `.env*.yaml`
441
+ // pattern matched it accidentally; pin it explicitly so a future glob
442
+ // narrowing can't regress.
443
+ if (!gitignoreContent.includes(".secrets.yaml")) {
444
+ gitignoreContent += ".secrets.yaml\n";
445
+ gitignoreDirty = true;
446
+ }
447
+ // TASK-174 (m-10): identity values are not secrets but they identify
448
+ // the user's account; keep them out of git too.
449
+ if (!gitignoreContent.includes(".identity.yaml")) {
450
+ gitignoreContent += ".identity.yaml\n";
451
+ gitignoreDirty = true;
452
+ }
453
+ if (gitignoreDirty) {
454
+ writeFileSync(gitignorePath, gitignoreContent, "utf-8");
455
+ }
456
+
457
+ // Seed `.secrets.yaml` placeholder once. The file lives gitignored
458
+ // alongside `.env.yaml`; values placed here are auto-registered with
459
+ // the SecretRegistry on load and never appear in artifacts.
460
+ const secretsPath = join(baseDir, ".secrets.yaml");
461
+ if (!existsSync(secretsPath)) {
462
+ const seedKeys = authVarNames.length > 0 ? authVarNames : ["auth_token"];
463
+ const lines = [
464
+ "# .secrets.yaml — gitignored, holds raw secret values.",
465
+ "# Reference these in .env.yaml as @secret:<key>.",
466
+ "# Values here are auto-registered for redaction in DB writes,",
467
+ "# HTML/JSON/JUnit reports, case-studies, and probe digests.",
468
+ ];
469
+ for (const k of seedKeys) lines.push(`${k}: "" # required for live probes`);
470
+ lines.push("");
471
+ writeFileSync(secretsPath, lines.join("\n"), "utf-8");
472
+ }
473
+
474
+ // TASK-174 (m-10): seed `.identity.yaml` with placeholders for any
475
+ // canonical identity-keys that appear as path-params in the spec. The
476
+ // file is gitignored — values are visible locally for triage and
477
+ // hidden from outbound shares only when --redact-identity is set.
478
+ const identityKeys = [...pathParams.keys()].filter((k) =>
479
+ CANONICAL_IDENTITY_KEYS.has(k),
480
+ );
481
+ if (identityKeys.length > 0) {
482
+ const identityPath = join(baseDir, ".identity.yaml");
483
+ if (!existsSync(identityPath)) {
484
+ const lines = [
485
+ "# .identity.yaml — gitignored, holds non-secret-but-identifying values.",
486
+ "# Reference these in .env.yaml as @identity:<key>.",
487
+ "# Values are visible locally and in case-study drafts; pass",
488
+ "# --redact-identity (TASK-173) to swap them for placeholders when",
489
+ "# sharing reports outbound.",
490
+ ];
491
+ for (const k of identityKeys.sort()) {
492
+ lines.push(`${k}: "" # fill with your ${k}`);
493
+ }
494
+ lines.push("");
495
+ writeFileSync(identityPath, lines.join("\n"), "utf-8");
496
+ }
497
+ }
498
+
499
+ const workspaceRoot = findWorkspaceRoot().root;
500
+
501
+ // Snapshot the dereferenced spec into apis/<name>/spec.json so all later
502
+ // commands (catalog, describe, generate, probe-*) read a self-contained
503
+ // local file. The spec lives inside the workspace and is git-trackable;
504
+ // an external --spec path is only consulted at register/refresh time.
505
+ let localSpecAbsPath: string | null = null;
506
+ if (dereferencedDoc) {
507
+ localSpecAbsPath = join(baseDir, SPEC_SNAPSHOT_FILENAME);
508
+ writeArtifactsFromDoc({
509
+ doc: dereferencedDoc,
510
+ baseDir,
511
+ apiName: name,
512
+ baseUrl,
513
+ workspaceRoot,
514
+ });
128
515
  }
129
516
 
130
517
  const normalizedTestPath = normalizePath(testPath);
131
518
  const normalizedBaseDir = normalizePath(baseDir);
132
519
 
520
+ // Persist the workspace-relative path to the local snapshot in
521
+ // collections.openapi_spec so we don't rely on the user's external path
522
+ // sticking around. Falls back to the external path only when the snapshot
523
+ // could not be created (no spec given to setupApi).
524
+ // Don't run normalizePath on the relative form — it calls resolve() and
525
+ // would re-absolutize the path. Posix-style separators are enough for
526
+ // SQLite + Windows compat.
527
+ const dbSpecPath = localSpecAbsPath
528
+ ? relative(workspaceRoot, localSpecAbsPath).replace(/\\/g, "/")
529
+ : (openapiSpec ?? undefined);
530
+
133
531
  // Create collection in DB
134
532
  const collectionId = createCollection({
135
533
  name,
136
534
  base_dir: normalizedBaseDir,
137
535
  test_path: normalizedTestPath,
138
- openapi_spec: openapiSpec ?? undefined,
536
+ openapi_spec: dbSpecPath,
139
537
  });
140
538
 
141
539
  const pathParamsObj = pathParams.size > 0 ? Object.fromEntries(pathParams) : undefined;
@@ -148,6 +546,7 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
148
546
  baseUrl,
149
547
  specEndpoints: endpointCount,
150
548
  ...(pathParamsObj ? { pathParams: pathParamsObj } : {}),
549
+ ...(authVarNames.length > 0 ? { authVars: authVarNames } : {}),
151
550
  ...(warnings.length > 0 ? { warnings } : {}),
152
551
  };
153
552
  }
@@ -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
+