@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,480 @@
1
+ /**
2
+ * `zond audit --api X` — macro-команда для полного pipeline (TASK-262).
3
+ *
4
+ * Оборачивает 8-10 ручных шагов (prepare-fixtures → generate → probes
5
+ * → session-wrapped run → coverage → HTML report) в одну команду:
6
+ *
7
+ * 1. `prepare-fixtures --apply` (или `--cascade --seed --apply` при
8
+ * `--seed`) — заполняет `.env.yaml` FK-идентификаторами.
9
+ * 2. `generate` — пропускается если `apis/<name>/tests/` свежее, чем
10
+ * `spec.json` (mtime-эвристика; `--force` отключает skip).
11
+ * 3. `probe static` (validation+methods, всегда). `mass-assignment` и
12
+ * `security` — за `--with-mass-assignment` / `--with-security`.
13
+ * 4. `session start` → `run apis/<name>/tests` + `run apis/<name>/probes`
14
+ * → `session end`. Все runs наследуют один session_id.
15
+ * 5. `coverage --api X --union session --json` для embed'a в репорт.
16
+ * 6. Запись `audit-report.html` (или `--out`) с таблицей stages,
17
+ * coverage-сводкой и подсказками для drill-down.
18
+ *
19
+ * Каждая stage спавнится как отдельный subprocess `zond ...`. Failure
20
+ * любой stage НЕ останавливает pipeline — финальный exit 1 если хоть одна
21
+ * упала, 0 если все ok. `--dry-run` печатает план без выполнения.
22
+ */
23
+
24
+ import { existsSync, statSync } from "node:fs";
25
+ import { writeFile } from "node:fs/promises";
26
+ import { join } from "node:path";
27
+ import type { Command } from "commander";
28
+ import { globalJson } from "../resolve.ts";
29
+ import { getDb } from "../../db/schema.ts";
30
+ import { findCollectionByNameOrId } from "../../db/queries.ts";
31
+ import { resolveCollectionSpec } from "../../core/setup-api.ts";
32
+ import { printSuccess, printWarning, printError } from "../output.ts";
33
+ import { getApi, MISSING_API_MESSAGE } from "../util/api-context.ts";
34
+ import { jsonOk, printJson } from "../json-envelope.ts";
35
+ import { VERSION } from "../version.ts";
36
+
37
+ interface Stage {
38
+ key: string;
39
+ name: string;
40
+ args: string[];
41
+ /** If returns string, stage is skipped with that reason. */
42
+ skip?: () => string | null;
43
+ }
44
+
45
+ interface StageResult {
46
+ key: string;
47
+ name: string;
48
+ status: "ok" | "failed" | "skipped";
49
+ exit_code: number | null;
50
+ duration_ms: number;
51
+ reason?: string;
52
+ }
53
+
54
+ export interface AuditOptions {
55
+ api: string;
56
+ dbPath?: string;
57
+ seed?: boolean;
58
+ withMassAssignment?: boolean;
59
+ withSecurity?: boolean;
60
+ out?: string;
61
+ dryRun?: boolean;
62
+ force?: boolean;
63
+ json?: boolean;
64
+ }
65
+
66
+ /**
67
+ * Build the prefix for self-spawning `zond ...`. When the binary is
68
+ * compiled, `process.execPath` IS the zond binary. In dev, `bun` runs the
69
+ * script directly — fall back to `[bun, src/cli/index.ts]`.
70
+ */
71
+ function zondInvoker(): string[] {
72
+ const exec = process.execPath;
73
+ const base = exec.replace(/\\/g, "/");
74
+ if (base.endsWith("/zond") || base.endsWith("/zond.exe")) return [exec];
75
+ const script = process.argv[1] || "src/cli/index.ts";
76
+ return [exec, script];
77
+ }
78
+
79
+ function buildStages(opts: AuditOptions, apiDir: string, specPath: string | null): Stage[] {
80
+ const api = opts.api;
81
+ const stages: Stage[] = [];
82
+
83
+ if (opts.seed) {
84
+ stages.push({
85
+ key: "prepare-fixtures-cascade",
86
+ name: "prepare-fixtures (cascade discover + seed)",
87
+ args: ["prepare-fixtures", "--api", api, "--apply", "--seed"],
88
+ });
89
+ } else {
90
+ stages.push({
91
+ key: "prepare-fixtures",
92
+ name: "prepare-fixtures (path-FK fixtures)",
93
+ args: ["prepare-fixtures", "--api", api, "--apply"],
94
+ });
95
+ }
96
+
97
+ stages.push({
98
+ key: "generate",
99
+ name: "generate (smoke + crud)",
100
+ args: ["generate", "--api", api, "--output", join(apiDir, "tests")],
101
+ skip: () => {
102
+ if (opts.force) return null;
103
+ if (!specPath || !existsSync(specPath)) return null;
104
+ const testsDir = join(apiDir, "tests");
105
+ if (!existsSync(testsDir)) return null;
106
+ try {
107
+ const specMtime = statSync(specPath).mtimeMs;
108
+ const testsMtime = statSync(testsDir).mtimeMs;
109
+ if (testsMtime > specMtime) return "tests/ newer than spec — pass --force to regenerate";
110
+ } catch {
111
+ // ignore — fall through to running generate
112
+ }
113
+ return null;
114
+ },
115
+ });
116
+
117
+ stages.push({
118
+ key: "probe-static",
119
+ name: "probe static (validation+methods)",
120
+ args: ["probe", "static", "--api", api, "--output", join(apiDir, "probes", "static")],
121
+ });
122
+
123
+ if (opts.withMassAssignment) {
124
+ stages.push({
125
+ key: "probe-mass-assignment",
126
+ name: "probe mass-assignment",
127
+ args: [
128
+ "probe", "mass-assignment", "--api", api,
129
+ "--output", join(apiDir, "probes", "mass-assignment-digest.md"),
130
+ "--emit-tests", join(apiDir, "probes", "mass-assignment"),
131
+ "--overwrite",
132
+ ],
133
+ });
134
+ }
135
+ if (opts.withSecurity) {
136
+ stages.push({
137
+ key: "probe-security",
138
+ name: "probe security (ssrf,crlf,open-redirect)",
139
+ args: [
140
+ "probe", "security", "ssrf,crlf,open-redirect", "--api", api,
141
+ "--output", join(apiDir, "probes", "security-digest.md"),
142
+ "--emit-tests", join(apiDir, "probes", "security"),
143
+ "--overwrite",
144
+ ],
145
+ });
146
+ }
147
+
148
+ const sessionLabel = `audit-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}`;
149
+ stages.push({ key: "session-start", name: `session start (${sessionLabel})`, args: ["session", "start", "--label", sessionLabel] });
150
+ stages.push({ key: "run-tests", name: "run tests", args: ["run", join(apiDir, "tests"), "--api", api] });
151
+ stages.push({ key: "run-probes", name: "run probes", args: ["run", join(apiDir, "probes"), "--api", api] });
152
+ stages.push({ key: "session-end", name: "session end", args: ["session", "end"] });
153
+ // ARV-108: surface the post-stage coverage capture in the plan so the
154
+ // dry-run listing matches the actual pipeline. The stage is special-cased
155
+ // in auditCommand — we keep stdout for JSON parsing rather than inheriting.
156
+ stages.push({
157
+ key: "coverage",
158
+ name: "coverage (session union)",
159
+ args: ["coverage", "--api", api, "--union", "session", "--json"],
160
+ });
161
+
162
+ return stages;
163
+ }
164
+
165
+ async function runStage(stage: Stage, idx: number, total: number, json: boolean): Promise<StageResult> {
166
+ const skipReason = stage.skip?.();
167
+ if (skipReason) {
168
+ if (!json) console.log(`==> Stage ${idx}/${total}: ${stage.name} — skipped (${skipReason})`);
169
+ return { key: stage.key, name: stage.name, status: "skipped", exit_code: null, duration_ms: 0, reason: skipReason };
170
+ }
171
+ if (!json) console.log(`==> Stage ${idx}/${total}: ${stage.name}`);
172
+ const t0 = Date.now();
173
+ const cmd = [...zondInvoker(), ...stage.args];
174
+ const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" });
175
+ const code = await proc.exited;
176
+ const ms = Date.now() - t0;
177
+ return {
178
+ key: stage.key,
179
+ name: stage.name,
180
+ status: code === 0 ? "ok" : "failed",
181
+ exit_code: code,
182
+ duration_ms: ms,
183
+ };
184
+ }
185
+
186
+ interface CoverageCapture {
187
+ data: unknown | null;
188
+ exitCode: number | null;
189
+ parseError: string | null;
190
+ durationMs: number;
191
+ }
192
+
193
+ async function captureCoverage(api: string): Promise<CoverageCapture> {
194
+ const t0 = Date.now();
195
+ try {
196
+ const cmd = [...zondInvoker(), "coverage", "--api", api, "--union", "session", "--json"];
197
+ const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
198
+ const stdout = await new Response(proc.stdout).text();
199
+ const code = await proc.exited;
200
+ const ms = Date.now() - t0;
201
+ if (code !== 0) {
202
+ return { data: null, exitCode: code, parseError: null, durationMs: ms };
203
+ }
204
+ try {
205
+ return { data: JSON.parse(stdout), exitCode: code, parseError: null, durationMs: ms };
206
+ } catch (e) {
207
+ return { data: null, exitCode: code, parseError: (e as Error).message, durationMs: ms };
208
+ }
209
+ } catch (e) {
210
+ return { data: null, exitCode: null, parseError: (e as Error).message, durationMs: Date.now() - t0 };
211
+ }
212
+ }
213
+
214
+ export async function auditCommand(options: AuditOptions): Promise<number> {
215
+ // Like bootstrap: fall back to apis/<name>/ when no DB/collection — the
216
+ // workspace-on-disk shape is enough for the macro to drive subprocesses.
217
+ let apiDir = `apis/${options.api}`;
218
+ let specPath: string | null = null;
219
+ try {
220
+ getDb(options.dbPath);
221
+ const col = findCollectionByNameOrId(options.api);
222
+ if (col) {
223
+ apiDir = col.base_dir ?? apiDir;
224
+ specPath = col.openapi_spec ? resolveCollectionSpec(col.openapi_spec) : null;
225
+ }
226
+ } catch {
227
+ // No DB — keep filesystem-only fallback.
228
+ }
229
+ if (!specPath) {
230
+ const guess = join(apiDir, "spec.json");
231
+ if (existsSync(guess)) specPath = guess;
232
+ }
233
+
234
+ const stages = buildStages(options, apiDir, specPath);
235
+ const out = options.out ?? "audit-report.html";
236
+
237
+ if (options.dryRun) {
238
+ if (options.json) {
239
+ printJson(jsonOk("audit", {
240
+ plan: stages.map((s) => ({ key: s.key, name: s.name, args: s.args })),
241
+ out,
242
+ }));
243
+ } else {
244
+ console.log(`Plan: zond audit --api ${options.api} (${stages.length} stages)`);
245
+ stages.forEach((s, i) => {
246
+ console.log(` ${(i + 1).toString().padStart(2)}. ${s.name}`);
247
+ console.log(` zond ${s.args.join(" ")}`);
248
+ });
249
+ console.log(`\nReport will be written to: ${out}`);
250
+ }
251
+ return 0;
252
+ }
253
+
254
+ const t0 = Date.now();
255
+ const results: StageResult[] = [];
256
+ let coverageJson: unknown = null;
257
+ let coverageCapture: CoverageCapture | null = null;
258
+ for (let i = 0; i < stages.length; i++) {
259
+ const stage = stages[i]!;
260
+ if (stage.key === "coverage") {
261
+ // ARV-108: coverage runs via captureCoverage so we keep stdout JSON.
262
+ if (!options.json) console.log(`==> Stage ${i + 1}/${stages.length}: ${stage.name}`);
263
+ coverageCapture = await captureCoverage(options.api);
264
+ coverageJson = coverageCapture.data;
265
+ const status: StageResult["status"] = coverageCapture.data
266
+ ? "ok"
267
+ : coverageCapture.exitCode === 0 && coverageCapture.parseError
268
+ ? "failed"
269
+ : coverageCapture.exitCode === 0
270
+ ? "skipped"
271
+ : "failed";
272
+ results.push({
273
+ key: stage.key,
274
+ name: stage.name,
275
+ status,
276
+ exit_code: coverageCapture.exitCode,
277
+ duration_ms: coverageCapture.durationMs,
278
+ reason: status === "skipped"
279
+ ? "no runs in session"
280
+ : coverageCapture.parseError
281
+ ? `non-JSON output: ${coverageCapture.parseError}`
282
+ : status === "failed"
283
+ ? `coverage exited ${coverageCapture.exitCode}`
284
+ : undefined,
285
+ });
286
+ continue;
287
+ }
288
+ results.push(await runStage(stage, i + 1, stages.length, options.json === true));
289
+ }
290
+ const totalMs = Date.now() - t0;
291
+
292
+ await writeAuditReport(out, {
293
+ api: options.api,
294
+ apiDir,
295
+ stages: results,
296
+ totalMs,
297
+ coverage: coverageJson,
298
+ coverageStage: results.find((r) => r.key === "coverage") ?? null,
299
+ options,
300
+ });
301
+
302
+ // ARV-108: coverage is informational — keep it out of the fail count so we
303
+ // don't regress the "non-fatal coverage" contract.
304
+ const failedStages = results.filter((r) => r.status === "failed" && r.key !== "coverage");
305
+ const failed = failedStages.length;
306
+
307
+ if (options.json) {
308
+ printJson(jsonOk("audit", {
309
+ api: options.api,
310
+ stages: results,
311
+ total_ms: totalMs,
312
+ failed_stages: failed,
313
+ report: out,
314
+ coverage: coverageJson,
315
+ }));
316
+ } else {
317
+ console.log("");
318
+ const summary = `Audit complete (${results.length} stages, ${(totalMs / 1000).toFixed(1)}s) → ${out}`;
319
+ if (failed === 0) {
320
+ printSuccess(summary);
321
+ } else {
322
+ printWarning(`${summary} — ${failed} failed: ${failedStages.map((s) => s.key).join(", ")}`);
323
+ }
324
+ }
325
+ return failed === 0 ? 0 : 1;
326
+ }
327
+
328
+ interface ReportInput {
329
+ api: string;
330
+ apiDir: string;
331
+ stages: StageResult[];
332
+ totalMs: number;
333
+ coverage: unknown;
334
+ /** ARV-108: outcome of the post-stage coverage capture, so the HTML can
335
+ * distinguish "no session runs" from "coverage subcommand failed". */
336
+ coverageStage: StageResult | null;
337
+ options: AuditOptions;
338
+ }
339
+
340
+ function escapeHtml(s: string): string {
341
+ return s.replace(/[&<>"']/g, (c) => {
342
+ const map: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
343
+ return map[c]!;
344
+ });
345
+ }
346
+
347
+ interface CoverageEnvelope {
348
+ data?: {
349
+ totals?: { all?: number; covered2xx?: number; coveredButNon2xx?: number; unhit?: number };
350
+ pass_coverage?: { ratio?: number };
351
+ hit_coverage?: { ratio?: number };
352
+ coveredButNon2xxEndpoints?: Array<{ endpoint?: string }>;
353
+ unhitEndpoints?: Array<{ endpoint?: string }>;
354
+ };
355
+ }
356
+
357
+ async function writeAuditReport(outPath: string, data: ReportInput): Promise<void> {
358
+ const cov = data.coverage as CoverageEnvelope | null;
359
+ const totals = cov?.data?.totals;
360
+ const pass = cov?.data?.pass_coverage?.ratio;
361
+ const hit = cov?.data?.hit_coverage?.ratio;
362
+
363
+ const stageRows = data.stages.map((s) => {
364
+ const cls = s.status === "ok" ? "ok" : s.status === "failed" ? "fail" : "skip";
365
+ const ms = s.duration_ms === 0 ? "—" : `${(s.duration_ms / 1000).toFixed(1)}s`;
366
+ return `<tr class="${cls}"><td>${escapeHtml(s.name)}</td><td>${s.status}</td><td>${s.exit_code ?? "—"}</td><td>${ms}</td><td>${escapeHtml(s.reason ?? "")}</td></tr>`;
367
+ }).join("\n");
368
+
369
+ const reruncmd = `zond audit --api ${data.api}`
370
+ + (data.options.seed ? " --seed" : "")
371
+ + (data.options.withMassAssignment ? " --with-mass-assignment" : "")
372
+ + (data.options.withSecurity ? " --with-security" : "");
373
+
374
+ const covStage = data.coverageStage;
375
+ // ARV-108: tailor the warning to what actually happened so the HTML stops
376
+ // misreporting "stage failed" when the stage was skipped (no runs in the
377
+ // session) or simply produced unparseable output.
378
+ const coverageWarning = covStage
379
+ ? covStage.status === "skipped"
380
+ ? "No session runs to summarise. Add `--with-mass-assignment` / `--with-security`, or run tests/probes that succeed."
381
+ : covStage.reason
382
+ ? `Coverage stage ${covStage.status}: ${escapeHtml(covStage.reason)}.`
383
+ : `Coverage stage ${covStage.status} (exit ${covStage.exit_code ?? "?"}).`
384
+ : "Coverage stage was not part of this audit (older binary?).";
385
+
386
+ const coverageBlock = totals
387
+ ? `<h2>Coverage (session union)</h2>
388
+ <div class="cov">
389
+ <div><div class="num">${totals.covered2xx ?? 0}/${totals.all ?? 0}</div><div class="lbl">covered2xx</div></div>
390
+ <div><div class="num">${totals.coveredButNon2xx ?? 0}</div><div class="lbl">covered but non-2xx</div></div>
391
+ <div><div class="num">${totals.unhit ?? 0}</div><div class="lbl">unhit</div></div>
392
+ ${typeof pass === "number" ? `<div><div class="num">${(pass * 100).toFixed(0)}%</div><div class="lbl">pass coverage</div></div>` : ""}
393
+ ${typeof hit === "number" ? `<div><div class="num">${(hit * 100).toFixed(0)}%</div><div class="lbl">hit coverage</div></div>` : ""}
394
+ </div>`
395
+ : `<h2>Coverage</h2><div class="warn">${coverageWarning}</div>`;
396
+
397
+ const html = `<!doctype html>
398
+ <html lang="en"><head><meta charset="utf-8"><title>zond audit — ${escapeHtml(data.api)}</title>
399
+ <style>
400
+ body { font: 14px -apple-system, system-ui, sans-serif; max-width: 960px; margin: 2em auto; padding: 0 1em; color: #222; }
401
+ h1 { font-size: 1.4em; margin-bottom: 0.2em; }
402
+ h2 { font-size: 1.05em; margin-top: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
403
+ .meta { color: #666; font-size: 0.9em; margin-bottom: 1em; }
404
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 0.92em; }
405
+ th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid #eee; }
406
+ th { background: #f7f7f7; }
407
+ tr.ok td:nth-child(2) { color: #0a7; }
408
+ tr.fail td:nth-child(2) { color: #c33; font-weight: 600; }
409
+ tr.skip td:nth-child(2) { color: #888; font-style: italic; }
410
+ .cov { display: flex; gap: 2em; margin: 1em 0; flex-wrap: wrap; }
411
+ .cov .num { font-size: 1.6em; font-weight: 600; }
412
+ .cov .lbl { font-size: 0.8em; color: #666; }
413
+ .warn { background: #fef9e7; padding: 8px 12px; border-left: 3px solid #f0c040; margin: 1em 0; }
414
+ code { background: #f4f4f4; padding: 1px 5px; border-radius: 3px; font-size: 0.9em; }
415
+ ul { line-height: 1.6; }
416
+ </style></head>
417
+ <body>
418
+ <h1>zond audit — ${escapeHtml(data.api)}</h1>
419
+ <div class="meta">
420
+ zond ${escapeHtml(VERSION)} · ${new Date().toISOString()} · total ${(data.totalMs / 1000).toFixed(1)}s · apiDir <code>${escapeHtml(data.apiDir)}</code>
421
+ </div>
422
+
423
+ <h2>Stages</h2>
424
+ <table><thead><tr><th>Stage</th><th>Status</th><th>Exit</th><th>Duration</th><th>Note</th></tr></thead><tbody>
425
+ ${stageRows}
426
+ </tbody></table>
427
+
428
+ ${coverageBlock}
429
+
430
+ <h2>Drill-down</h2>
431
+ <ul>
432
+ <li>Per-run HTML: <code>zond report export &lt;run-id&gt;</code></li>
433
+ <li>Diagnose failures: <code>zond db diagnose &lt;run-id&gt; --json</code></li>
434
+ <li>Re-run audit: <code>${escapeHtml(reruncmd)}</code></li>
435
+ </ul>
436
+ </body></html>`;
437
+
438
+ await writeFile(outPath, html, "utf-8");
439
+ }
440
+
441
+ export function registerAudit(program: Command): void {
442
+ program
443
+ .command("audit")
444
+ .description("Macro: prepare-fixtures → generate → probes → run → coverage → HTML report (TASK-262)")
445
+ // ARV-29: not `requiredOption` — same regression that hit prepare-fixtures
446
+ // (TASK-20) and checks run (TASK-17). Commander routes `--api` to the
447
+ // program-level option, so the subcommand's opts.api ends up undefined and
448
+ // requiredOption rejects every form (`--api foo`, `--api=foo`, even
449
+ // `zond --api foo audit`). Fall back the same way: explicit > program-level
450
+ // mirror > .zond/current-api.
451
+ .option("--api <name>", "Registered API to audit. Falls back to ZOND_API / .zond/current-api.")
452
+ .option("--db <path>", "Path to SQLite database file")
453
+ .option("--seed", "Use 'prepare-fixtures --cascade --seed --apply' instead of the plain single-pass prep stage")
454
+ .option("--with-mass-assignment", "Include 'probe mass-assignment' as an extra stage")
455
+ .option("--with-security", "Include 'probe security ssrf,crlf,open-redirect' as an extra stage")
456
+ .option("--out <path>", "HTML report output path (default: audit-report.html)")
457
+ .option("--dry-run", "Print the stage plan without executing anything")
458
+ .option("--force", "Disable mtime-based skip (always regenerate, even if tests/ newer than spec)")
459
+ .action(async (opts, cmd: Command) => {
460
+ // ARV-53.
461
+ const apiName = getApi(cmd, opts);
462
+ if (!apiName) {
463
+ printError(MISSING_API_MESSAGE);
464
+ process.exitCode = 2;
465
+ return;
466
+ }
467
+ opts.api = apiName;
468
+ process.exitCode = await auditCommand({
469
+ api: opts.api,
470
+ dbPath: opts.db,
471
+ seed: opts.seed === true,
472
+ withMassAssignment: opts.withMassAssignment === true,
473
+ withSecurity: opts.withSecurity === true,
474
+ out: opts.out,
475
+ dryRun: opts.dryRun === true,
476
+ force: opts.force === true,
477
+ json: globalJson(cmd),
478
+ });
479
+ });
480
+ }