@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
@@ -25,6 +25,9 @@ permissions:
25
25
  contents: read
26
26
  checks: write
27
27
  pull-requests: write
28
+ # ARV-5: required for github/codeql-action/upload-sarif to surface
29
+ # zond-checks findings in the Code Scanning tab.
30
+ security-events: write
28
31
 
29
32
  jobs:
30
33
  test:
@@ -55,6 +58,21 @@ jobs:
55
58
  # Add --env-var "BASE_URL=\${{ secrets.STAGING_URL }}" for staging URL
56
59
  continue-on-error: true
57
60
 
61
+ - name: Run depth checks (SARIF for Code Scanning)
62
+ run: |
63
+ zond checks run --api myapi --report sarif --output test-results/zond-checks.sarif || true
64
+ # ARV-5: \`|| true\` keeps the workflow green even when HIGH/CRITICAL
65
+ # findings would otherwise exit 1 — Code Scanning will still get the
66
+ # SARIF and gate on the alerts via branch protection if desired.
67
+ continue-on-error: true
68
+
69
+ - name: Upload SARIF to GitHub Code Scanning
70
+ if: always()
71
+ uses: github/codeql-action/upload-sarif@v3
72
+ with:
73
+ sarif_file: test-results/zond-checks.sarif
74
+ category: zond-checks
75
+
58
76
  - name: Publish test results
59
77
  uses: EnricoMi/publish-unit-test-result-action@v2
60
78
  if: always()
@@ -178,3 +196,28 @@ export async function ciInitCommand(options: CiInitOptions): Promise<number> {
178
196
 
179
197
  return 0;
180
198
  }
199
+
200
+ import type { Command } from "commander";
201
+ import { globalJson } from "../resolve.ts";
202
+
203
+ export function registerCi(program: Command): void {
204
+ const ci = program.command("ci").description("CI/CD scaffolding");
205
+ ci
206
+ .command("init")
207
+ .description("Generate CI/CD workflow (GitHub Actions, GitLab CI)")
208
+ .option("--github", "Generate GitHub Actions workflow")
209
+ .option("--gitlab", "Generate GitLab CI config")
210
+ .option("--dir <path>", "Project root directory (default: current directory)")
211
+ .option("--force", "Overwrite existing CI config")
212
+ .action(async (opts, cmd: Command) => {
213
+ let platform: "github" | "gitlab" | undefined;
214
+ if (opts.github === true) platform = "github";
215
+ else if (opts.gitlab === true) platform = "gitlab";
216
+ process.exitCode = await ciInitCommand({
217
+ platform,
218
+ force: opts.force === true,
219
+ dir: opts.dir,
220
+ json: globalJson(cmd),
221
+ });
222
+ });
223
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * `zond clean` — remove auto-generated files tracked in `.zond/manifest.json`.
3
+ *
4
+ * Default mode is dry-run; `--force` is required to actually delete. Files
5
+ * whose sha256 no longer matches the manifest entry are treated as
6
+ * manually-edited and skipped (TASK-156, m-9).
7
+ */
8
+
9
+ import { rmSync, rmdirSync, readdirSync, existsSync } from "node:fs";
10
+ import { dirname, resolve } from "node:path";
11
+ import { findWorkspaceRoot } from "../../core/workspace/root.ts";
12
+ import {
13
+ hasManifest,
14
+ inspectEntries,
15
+ loadManifest,
16
+ removeManifestEntries,
17
+ selectEntriesEx,
18
+ type CleanItem,
19
+ type ManifestCategory,
20
+ } from "../../core/workspace/manifest.ts";
21
+ import type { Command } from "commander";
22
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
23
+ import { printError, printSuccess } from "../output.ts";
24
+ import { globalJson } from "../resolve.ts";
25
+ import { getApi } from "../util/api-context.ts";
26
+
27
+ export interface CleanOptions {
28
+ api?: string;
29
+ probes?: boolean;
30
+ all?: boolean;
31
+ force?: boolean;
32
+ json?: boolean;
33
+ }
34
+
35
+ export async function cleanCommand(opts: CleanOptions): Promise<number> {
36
+ const ws = findWorkspaceRoot();
37
+ if (ws.fromFallback) {
38
+ const m = "No workspace detected. Run `zond init` first.";
39
+ if (opts.json) printJson(jsonError("clean", [m])); else printError(m);
40
+ return 2;
41
+ }
42
+
43
+ if (!hasManifest(ws.root)) {
44
+ const msg = `No .zond/manifest.json — nothing tracked yet. Run \`zond add api\`, \`zond generate\`, or a probe-* --emit first.`;
45
+ if (opts.json) {
46
+ printJson(jsonOk("clean", { dryRun: true, deleted: [], modified: [], missing: [], message: msg }));
47
+ } else {
48
+ console.log(msg);
49
+ }
50
+ return 0;
51
+ }
52
+
53
+ if (!opts.api && !opts.probes && !opts.all) {
54
+ const m = "Specify a scope: --api <name>, --probes, or --all.";
55
+ if (opts.json) printJson(jsonError("clean", [m])); else printError(m);
56
+ return 2;
57
+ }
58
+
59
+ const manifest = loadManifest(ws.root);
60
+ const category: ManifestCategory | undefined = opts.probes ? "probes" : undefined;
61
+ const { selected: entries, probesPreserved } = selectEntriesEx(manifest, {
62
+ api: opts.api,
63
+ category,
64
+ all: opts.all && !opts.api && !opts.probes,
65
+ });
66
+
67
+ if (entries.length === 0 && probesPreserved.length === 0) {
68
+ const m = "No matching auto-generated files in manifest.";
69
+ if (opts.json) {
70
+ printJson(jsonOk("clean", { dryRun: true, deleted: [], modified: [], missing: [], message: m }));
71
+ } else {
72
+ console.log(m);
73
+ }
74
+ return 0;
75
+ }
76
+
77
+ const items = inspectEntries(ws.root, entries);
78
+ const toDelete = items.filter((i) => i.verdict === "delete");
79
+ const modified = items.filter((i) => i.verdict === "modified");
80
+ const missing = items.filter((i) => i.verdict === "missing");
81
+
82
+ const dryRun = !opts.force;
83
+
84
+ if (!dryRun) {
85
+ for (const item of toDelete) {
86
+ try {
87
+ rmSync(item.absPath, { force: true });
88
+ } catch {
89
+ // best-effort
90
+ }
91
+ }
92
+ pruneEmptyDirs(ws.root, toDelete);
93
+ const removedPaths = [...toDelete, ...missing].map((i) => i.entry.path);
94
+ removeManifestEntries(ws.root, removedPaths);
95
+ }
96
+
97
+ if (opts.json) {
98
+ printJson(jsonOk("clean", {
99
+ dryRun,
100
+ scope: { api: opts.api, probes: !!opts.probes, all: !!opts.all },
101
+ deleted: toDelete.map(itemSummary),
102
+ modified: modified.map(itemSummary),
103
+ missing: missing.map(itemSummary),
104
+ probesPreserved: probesPreserved.map((e) => ({ path: e.path, api: e.api })),
105
+ }));
106
+ return 0;
107
+ }
108
+
109
+ const verb = dryRun ? "Would delete" : "Deleted";
110
+ printSuccess(`${verb} ${toDelete.length} file(s); ${modified.length} skipped (manually edited); ${missing.length} already missing.`);
111
+ if (toDelete.length > 0) {
112
+ console.log("");
113
+ console.log(`${verb}:`);
114
+ for (const i of toDelete) console.log(` - ${i.entry.path}`);
115
+ }
116
+ if (modified.length > 0) {
117
+ console.log("");
118
+ console.log("Skipped (manually edited, sha256 mismatch):");
119
+ for (const i of modified) console.log(` ! ${i.entry.path}`);
120
+ }
121
+ // TASK-258: probes belong to a separate pipeline. Scoping by --api alone
122
+ // preserves them and surfaces the regen command so users don't lose
123
+ // 30s+ of probe-validation/-methods work to a typo.
124
+ if (probesPreserved.length > 0) {
125
+ console.log("");
126
+ console.log(`Preserved ${probesPreserved.length} probe-suite file(s) under apis/${opts.api ?? "<api>"}/probes/.`);
127
+ console.log("Pass --probes to also remove them, or regenerate via:");
128
+ console.log(` zond probe-validation --api ${opts.api} --output apis/${opts.api}/probes`);
129
+ console.log(` zond probe-methods --api ${opts.api} --output apis/${opts.api}/probes`);
130
+ }
131
+ if (dryRun) {
132
+ console.log("");
133
+ console.log("Re-run with --force to actually delete.");
134
+ }
135
+ return 0;
136
+ }
137
+
138
+ function itemSummary(i: CleanItem) {
139
+ return {
140
+ path: i.entry.path,
141
+ by: i.entry.by,
142
+ ts: i.entry.ts,
143
+ api: i.entry.api,
144
+ category: i.entry.category,
145
+ verdict: i.verdict,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * After deleting tracked files, remove any directories that are now empty
151
+ * and live inside the workspace. Best-effort: stops at first non-empty dir.
152
+ */
153
+ function pruneEmptyDirs(workspaceRoot: string, items: CleanItem[]): void {
154
+ const dirs = new Set<string>();
155
+ for (const i of items) dirs.add(dirname(i.absPath));
156
+ // Process deepest first.
157
+ const sorted = [...dirs].sort((a, b) => b.length - a.length);
158
+ for (const d of sorted) {
159
+ let cur = d;
160
+ while (cur.startsWith(workspaceRoot) && cur !== workspaceRoot) {
161
+ if (!existsSync(cur)) {
162
+ cur = dirname(cur);
163
+ continue;
164
+ }
165
+ let entries: string[] = [];
166
+ try {
167
+ entries = readdirSync(cur);
168
+ } catch {
169
+ break;
170
+ }
171
+ if (entries.length > 0) break;
172
+ try {
173
+ rmdirSync(cur);
174
+ } catch {
175
+ break;
176
+ }
177
+ cur = dirname(cur);
178
+ }
179
+ }
180
+ // Keep `resolve` reachable for type-only imports.
181
+ void resolve;
182
+ }
183
+
184
+ export function registerClean(program: Command): void {
185
+ program
186
+ .command("clean")
187
+ .description("Remove auto-generated files tracked in .zond/manifest.json (TASK-156, m-9)")
188
+ .option("--api <name>", "Limit to a single API (apis/<name>/). Preserves probes/ unless --probes is also passed (TASK-258)")
189
+ .option(
190
+ "--probes",
191
+ "Scope deletion to probe-suite files only (apis/<api>/probes/). " +
192
+ "TASK-265: this is effectively `--probes-only` — `tests/`, `spec.json`, " +
193
+ "and `.api-catalog.yaml` are NEVER touched in this mode. Combine with " +
194
+ "--api <name> to limit to one API; alone, removes probes for every API.",
195
+ )
196
+ .option("--all", "Remove every tracked auto-generated file in the workspace (includes probes/)")
197
+ .option("--force", "Actually delete files (default is dry-run)")
198
+ .action(async (opts, cmd: Command) => {
199
+ // ARV-238 (R-03/F11/SD9): `--api` exists both at program level (TASK-290)
200
+ // and on this subcommand; commander absorbs the value into the program
201
+ // option, leaving `opts.api` undefined. Fall back via `getApi` so users
202
+ // who follow `zond clean --api <name>` (per skill / --help) actually
203
+ // scope the run instead of hitting "Specify a scope".
204
+ process.exitCode = await cleanCommand({
205
+ api: getApi(cmd, opts),
206
+ probes: opts.probes === true,
207
+ all: opts.all === true,
208
+ force: opts.force === true,
209
+ json: globalJson(cmd),
210
+ });
211
+ });
212
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * `zond cleanup` — retry housekeeping that probes couldn't finish.
3
+ *
4
+ * v1 ships only `--orphans`: read every record from
5
+ * `~/.zond/orphans/<api>/<run-id>.jsonl` and re-issue the DELETE for any
6
+ * resource that survived the probe's own cleanup attempts. 404 is treated
7
+ * as success — the goal is "resource is gone", and the API getting there
8
+ * before us is fine. (TASK-278.)
9
+ */
10
+ import { loadOrphans, markRemoved } from "../../core/probe/orphan-tracker.ts";
11
+ import type { OrphanRecord } from "../../core/probe/orphan-tracker.ts";
12
+ import { executeRequest } from "../../core/runner/http-client.ts";
13
+ import { loadEnvironment, loadEnvMeta } from "../../core/parser/variables.ts";
14
+ import { resolveTimeoutMs } from "../../core/workspace/config.ts";
15
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
16
+ import { printError, printWarning, printSuccess } from "../output.ts";
17
+ import { readCurrentApi } from "../../core/context/current.ts";
18
+ import type { Command } from "commander";
19
+ import { globalJson } from "../resolve.ts";
20
+
21
+ export interface CleanupOptions {
22
+ orphans: boolean;
23
+ api?: string;
24
+ /** ARV-139: pass true to disable the default current-api scoping and look
25
+ * at orphans across every API in the tracker. */
26
+ allApis?: boolean;
27
+ runId?: string;
28
+ dryRun?: boolean;
29
+ json?: boolean;
30
+ /** Override base_url resolution. By default the env from cwd .env.yaml or
31
+ * apis/<api>/.env.yaml is used. Tests inject a known URL via this. */
32
+ baseUrl?: string;
33
+ /** Per-request timeout, ms. */
34
+ timeoutMs?: number;
35
+ }
36
+
37
+ /**
38
+ * ARV-244 (R-04/F15): orphan records store `deletePath` as it was used at
39
+ * probe time — typically built by concatenating literal id segments captured
40
+ * from the response. CRLF / open-redirect probes can poison those ids with
41
+ * raw `\r`, `\n`, spaces, etc. (e.g. label name `zond-safe\rX-Zond: yes`),
42
+ * which makes the DELETE URL malformed: the API gets a request line with an
43
+ * embedded CR and silently splits or routes elsewhere → 404 → record stays
44
+ * in the queue and the resource leaks.
45
+ *
46
+ * Encode unsafe characters per path-segment while preserving anything that
47
+ * is already percent-encoded. Slashes (segment separators), the unreserved
48
+ * set, and a conservative slice of sub-delims are kept verbatim.
49
+ */
50
+ export function encodeOrphanPath(deletePath: string): string {
51
+ const SAFE = /[A-Za-z0-9._~!$&'()*+,;=:@-]/;
52
+ return deletePath
53
+ .split("/")
54
+ .map((segment) => {
55
+ if (segment.length === 0) return segment;
56
+ let out = "";
57
+ for (let i = 0; i < segment.length; i++) {
58
+ const ch = segment.charAt(i);
59
+ // Preserve existing percent escapes (`%XX`) — probe pre-encoded.
60
+ if (ch === "%" && /^[0-9A-Fa-f]{2}$/.test(segment.slice(i + 1, i + 3))) {
61
+ out += segment.slice(i, i + 3);
62
+ i += 2;
63
+ continue;
64
+ }
65
+ if (SAFE.test(ch)) {
66
+ out += ch;
67
+ } else {
68
+ out += encodeURIComponent(ch);
69
+ }
70
+ }
71
+ return out;
72
+ })
73
+ .join("/");
74
+ }
75
+
76
+ export async function cleanupCommand(opts: CleanupOptions): Promise<number> {
77
+ if (!opts.orphans) {
78
+ const m = "Nothing to do — pass --orphans to retry leaked probe resources.";
79
+ if (opts.json) printJson(jsonError("cleanup", [m]));
80
+ else printError(m);
81
+ return 2;
82
+ }
83
+
84
+ // ARV-139: scope the orphan queue to the active API by default. The on-disk
85
+ // tracker at ~/.zond/orphans/<api>/... is shared across the workspace, so
86
+ // switching APIs (e.g. apis/resend → apis/sentry) would otherwise surface
87
+ // orphans from previous work on unrelated APIs — including DELETE attempts
88
+ // against endpoints that aren't even part of the active spec. Explicit
89
+ // `--api <name>` wins; `--all-apis` opts back into the pre-ARV-139 behaviour.
90
+ let scopedApi = opts.api;
91
+ if (!scopedApi && !opts.allApis) {
92
+ scopedApi = readCurrentApi() ?? undefined;
93
+ }
94
+
95
+ let records: OrphanRecord[];
96
+ try {
97
+ const filter: { api?: string; runId?: string } = {};
98
+ if (scopedApi) filter.api = scopedApi;
99
+ if (opts.runId) filter.runId = opts.runId;
100
+ records = await loadOrphans(filter);
101
+ } catch (err) {
102
+ const m = `Failed to load orphan tracker: ${(err as Error).message}`;
103
+ if (opts.json) printJson(jsonError("cleanup", [m]));
104
+ else printError(m);
105
+ return 2;
106
+ }
107
+
108
+ // ARV-102 (F7): split orphans into retriable (have a DELETE plan) and
109
+ // manual-only (probe knew the resource was created but couldn't derive
110
+ // a deletePath / id). Manual-only entries are surfaced separately —
111
+ // we can't auto-DELETE them, but the operator must know they exist.
112
+ const manualOnly = records.filter(r => r.requires_manual_cleanup === true || r.deletePath === "");
113
+ const retriable = records.filter(r => !(r.requires_manual_cleanup === true || r.deletePath === ""));
114
+
115
+ if (records.length === 0) {
116
+ if (opts.json) printJson(jsonOk("cleanup", { retried: 0, removed: 0, failed: 0, items: [], manual_cleanup_required: [] }));
117
+ else console.log("No orphan resources to retry.");
118
+ return 0;
119
+ }
120
+
121
+ // Group by api so we resolve env once per API instead of per record.
122
+ const baseUrlByApi = new Map<string, string>();
123
+ if (opts.baseUrl) {
124
+ for (const r of retriable) baseUrlByApi.set(r.api, opts.baseUrl);
125
+ }
126
+
127
+ if (opts.dryRun) {
128
+ if (opts.json) printJson(jsonOk("cleanup", { dryRun: true, items: retriable, manual_cleanup_required: manualOnly }));
129
+ else {
130
+ console.log(`Dry-run: ${retriable.length} orphan(s) would be retried:`);
131
+ for (const r of retriable) {
132
+ console.log(` ${r.method} ${r.path} (id=${r.id}); DELETE ${r.deletePath} — last status: ${r.lastCleanupStatus ?? "n/a"}`);
133
+ }
134
+ if (manualOnly.length > 0) {
135
+ console.log(`\nManual cleanup required: ${manualOnly.length} resource(s) (no DELETE plan):`);
136
+ for (const r of manualOnly) {
137
+ console.log(` ${r.method} ${r.path}${r.id ? ` (id=${r.id})` : ""} — ${r.lastCleanupError ?? "no DELETE counterpart"}`);
138
+ }
139
+ }
140
+ }
141
+ return 0;
142
+ }
143
+
144
+ // Per-API timeout: CLI flag → apis/<api>/.env.yaml `timeoutMs:` → workspace
145
+ // `defaults.timeout_ms` → 30000.
146
+ const timeoutByApi = new Map<string, number>();
147
+ async function timeoutFor(api: string): Promise<number> {
148
+ let t = timeoutByApi.get(api);
149
+ if (t !== undefined) return t;
150
+ let envTimeout: number | undefined;
151
+ try {
152
+ const meta = await loadEnvMeta(undefined, `apis/${api}`);
153
+ envTimeout = meta.timeoutMs;
154
+ } catch { /* meta is best-effort */ }
155
+ t = resolveTimeoutMs(opts.timeoutMs, envTimeout);
156
+ timeoutByApi.set(api, t);
157
+ return t;
158
+ }
159
+
160
+ const results: Array<{ record: OrphanRecord; status: number | null; ok: boolean; error?: string }> = [];
161
+ for (const r of retriable) {
162
+ let baseUrl = baseUrlByApi.get(r.api);
163
+ if (!baseUrl) {
164
+ try {
165
+ const env = await loadEnvironment(undefined, `apis/${r.api}`);
166
+ baseUrl = env["base_url"];
167
+ } catch { /* fall through */ }
168
+ }
169
+ if (!baseUrl) {
170
+ results.push({ record: r, status: null, ok: false, error: `base_url missing — set ZOND_BASE_URL or apis/${r.api}/.env.yaml` });
171
+ continue;
172
+ }
173
+ baseUrlByApi.set(r.api, baseUrl);
174
+
175
+ const url = `${baseUrl.replace(/\/+$/, "")}${encodeOrphanPath(r.deletePath)}`;
176
+ try {
177
+ const resp = await executeRequest(
178
+ { method: "DELETE", url, headers: {} },
179
+ { timeout: await timeoutFor(r.api), retries: 0 },
180
+ );
181
+ // 404 = already gone → success. 2xx = just deleted → success.
182
+ const ok = resp.status === 404 || (resp.status >= 200 && resp.status < 300);
183
+ results.push({ record: r, status: resp.status, ok });
184
+ if (ok) await markRemoved(r);
185
+ } catch (err) {
186
+ results.push({ record: r, status: null, ok: false, error: (err as Error).message });
187
+ }
188
+ }
189
+
190
+ const removed = results.filter(r => r.ok).length;
191
+ const failed = results.length - removed;
192
+
193
+ if (opts.json) {
194
+ printJson(jsonOk("cleanup", {
195
+ retried: results.length,
196
+ removed,
197
+ failed,
198
+ items: results.map(r => ({
199
+ api: r.record.api,
200
+ runId: r.record.runId,
201
+ method: r.record.method,
202
+ path: r.record.path,
203
+ id: r.record.id,
204
+ deletePath: r.record.deletePath,
205
+ status: r.status,
206
+ ok: r.ok,
207
+ error: r.error ?? null,
208
+ })),
209
+ manual_cleanup_required: manualOnly.map(r => ({
210
+ api: r.api,
211
+ runId: r.runId,
212
+ method: r.method,
213
+ path: r.path,
214
+ id: r.id,
215
+ reason: r.lastCleanupError ?? "no DELETE counterpart",
216
+ })),
217
+ }));
218
+ } else {
219
+ if (removed > 0) printSuccess(`${removed} orphan(s) cleaned up.`);
220
+ if (failed > 0) {
221
+ printWarning(`${failed} orphan(s) still alive:`);
222
+ for (const r of results) {
223
+ if (r.ok) continue;
224
+ const tail = r.status != null ? `→ ${r.status}` : (r.error ? `→ err: ${r.error}` : "");
225
+ process.stderr.write(` ${r.record.method} ${r.record.path} (id=${r.record.id}); DELETE ${r.record.deletePath} ${tail}\n`);
226
+ }
227
+ }
228
+ if (manualOnly.length > 0) {
229
+ printWarning(`${manualOnly.length} resource(s) need manual cleanup (no DELETE plan):`);
230
+ for (const r of manualOnly) {
231
+ process.stderr.write(` ${r.method} ${r.path}${r.id ? ` (id=${r.id})` : ""} — ${r.lastCleanupError ?? "no DELETE counterpart"}\n`);
232
+ }
233
+ }
234
+ }
235
+
236
+ // Manual-only orphans count as "still alive" for exit-code purposes —
237
+ // CI must fail loudly when probes leave un-deletable state behind.
238
+ return failed > 0 || manualOnly.length > 0 ? 1 : 0;
239
+ }
240
+
241
+ export function registerCleanup(program: Command): void {
242
+ program
243
+ .command("cleanup")
244
+ .description("Retry probe-leftover work. Currently only --orphans (TASK-278) — re-issues DELETE for resources captured in ~/.zond/orphans/.")
245
+ .option("--orphans", "Retry DELETE for resources in the orphan tracker")
246
+ .option("--api <name>", "Limit to a single API (matches the orphan-tracker subdirectory; defaults to the current API)")
247
+ .option("--all-apis", "Include orphans from every API in the tracker (disables the default current-api scoping)")
248
+ .option("--run <id>", "Limit to a single probe run id")
249
+ .option("--dry-run", "Print the plan without sending DELETEs")
250
+ .option("--timeout <ms>", "Per-request timeout in ms (overrides .env.yaml `timeoutMs` and zond.config.yml `defaults.timeout_ms`; default 30000)")
251
+ .action(async (opts, cmd: Command) => {
252
+ process.exitCode = await cleanupCommand({
253
+ orphans: opts.orphans === true,
254
+ api: typeof opts.api === "string" ? opts.api : undefined,
255
+ allApis: opts.allApis === true,
256
+ runId: typeof opts.run === "string" ? opts.run : undefined,
257
+ dryRun: opts.dryRun === true,
258
+ timeoutMs: typeof opts.timeout === "string" ? Number(opts.timeout) : undefined,
259
+ json: globalJson(cmd),
260
+ });
261
+ });
262
+ }
@@ -174,3 +174,19 @@ export function completionsCommand(options: CompletionsOptions): number {
174
174
  process.stdout.write(script);
175
175
  return 0;
176
176
  }
177
+
178
+ import { printError } from "../output.ts";
179
+
180
+ export function registerCompletions(program: Command): void {
181
+ program
182
+ .command("completions <shell>")
183
+ .description(`Generate shell completion script (${COMPLETION_SHELLS.join(", ")})`)
184
+ .action((shell: string) => {
185
+ if (!(COMPLETION_SHELLS as readonly string[]).includes(shell)) {
186
+ printError(`Unsupported shell: ${shell}. Supported: ${COMPLETION_SHELLS.join(", ")}`);
187
+ process.exitCode = 2;
188
+ return;
189
+ }
190
+ process.exitCode = completionsCommand({ shell: shell as CompletionShell, program });
191
+ });
192
+ }