@kirrosh/zond 0.21.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 +758 -3
  2. package/README.md +78 -15
  3. package/package.json +17 -10
  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 +55 -6
  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 +192 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +180 -8
  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 -47
  31. package/src/cli/commands/init/agents-md.ts +61 -0
  32. package/src/cli/commands/init/bootstrap.ts +108 -0
  33. package/src/cli/commands/init/index.ts +244 -0
  34. package/src/cli/commands/init/skills.ts +98 -0
  35. package/src/cli/commands/init/templates/agents.md +77 -0
  36. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  37. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +651 -0
  40. package/src/cli/commands/init/templates/zond-config.yml +14 -0
  41. package/src/cli/commands/prepare-fixtures.ts +135 -0
  42. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  43. package/src/cli/commands/probe/security.ts +454 -0
  44. package/src/cli/commands/probe/static.ts +255 -0
  45. package/src/cli/commands/probe/webhooks.ts +161 -0
  46. package/src/cli/commands/probe.ts +459 -0
  47. package/src/cli/commands/reference.ts +87 -0
  48. package/src/cli/commands/refresh-api.ts +169 -0
  49. package/src/cli/commands/remove-api.ts +150 -0
  50. package/src/cli/commands/report-bundle.ts +318 -0
  51. package/src/cli/commands/report.ts +241 -0
  52. package/src/cli/commands/request.ts +379 -4
  53. package/src/cli/commands/run.ts +911 -33
  54. package/src/cli/commands/session.ts +244 -0
  55. package/src/cli/commands/use.ts +74 -0
  56. package/src/cli/index.ts +36 -607
  57. package/src/cli/json-envelope.ts +112 -3
  58. package/src/cli/json-schemas.ts +263 -0
  59. package/src/cli/program.ts +218 -0
  60. package/src/cli/resolve.ts +105 -0
  61. package/src/cli/status-filter.ts +124 -0
  62. package/src/cli/util/api-context.ts +85 -0
  63. package/src/cli/version.ts +8 -0
  64. package/src/core/anti-fp/bootstrap.ts +34 -0
  65. package/src/core/anti-fp/index.ts +33 -0
  66. package/src/core/anti-fp/registry.ts +44 -0
  67. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  68. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  69. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  70. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  71. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  72. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  73. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  74. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  75. package/src/core/anti-fp/types.ts +68 -0
  76. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  77. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  78. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  79. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  80. package/src/core/checks/checks/cross_call_references.ts +134 -0
  81. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  82. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  83. package/src/core/checks/checks/ignored_auth.ts +211 -0
  84. package/src/core/checks/checks/index.ts +65 -0
  85. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  86. package/src/core/checks/checks/missing_required_header.ts +40 -0
  87. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  88. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  89. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  90. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  91. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  92. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  93. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  94. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  95. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  96. package/src/core/checks/checks/unsupported_method.ts +63 -0
  97. package/src/core/checks/checks/use_after_free.ts +78 -0
  98. package/src/core/checks/index.ts +30 -0
  99. package/src/core/checks/mode.ts +79 -0
  100. package/src/core/checks/recommended-action.ts +64 -0
  101. package/src/core/checks/registry.ts +78 -0
  102. package/src/core/checks/runner.ts +874 -0
  103. package/src/core/checks/sarif.ts +230 -0
  104. package/src/core/checks/stateful.ts +121 -0
  105. package/src/core/checks/types.ts +189 -0
  106. package/src/core/classifier/recommended-action.ts +222 -0
  107. package/src/core/context/current.ts +51 -0
  108. package/src/core/context/session.ts +78 -0
  109. package/src/core/coverage/loader.ts +185 -0
  110. package/src/core/coverage/reasons.ts +300 -0
  111. package/src/core/diagnostics/db-analysis.ts +161 -12
  112. package/src/core/diagnostics/failure-class.ts +120 -0
  113. package/src/core/diagnostics/failure-hints.ts +212 -9
  114. package/src/core/diagnostics/spec-pointer.ts +99 -0
  115. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  116. package/src/core/exporter/case-study/index.ts +270 -0
  117. package/src/core/exporter/curl.ts +40 -0
  118. package/src/core/exporter/exporter.ts +48 -0
  119. package/src/core/exporter/html-report/escape.ts +24 -0
  120. package/src/core/exporter/html-report/index.ts +479 -0
  121. package/src/core/exporter/html-report/script.ts +100 -0
  122. package/src/core/exporter/html-report/styles.ts +408 -0
  123. package/src/core/generator/chunker.ts +53 -15
  124. package/src/core/generator/coverage-phase.ts +0 -0
  125. package/src/core/generator/create-body.ts +89 -0
  126. package/src/core/generator/data-factory.ts +490 -33
  127. package/src/core/generator/describe.ts +1 -1
  128. package/src/core/generator/fixtures-builder.ts +325 -0
  129. package/src/core/generator/index.ts +7 -5
  130. package/src/core/generator/openapi-reader.ts +55 -3
  131. package/src/core/generator/path-param-disambig.ts +114 -0
  132. package/src/core/generator/resources-builder.ts +648 -0
  133. package/src/core/generator/schema-utils.ts +11 -3
  134. package/src/core/generator/serializer.ts +114 -15
  135. package/src/core/generator/suite-generator.ts +484 -77
  136. package/src/core/generator/types.ts +8 -0
  137. package/src/core/identity/identity-file.ts +129 -0
  138. package/src/core/lint/affects.ts +28 -0
  139. package/src/core/lint/config.ts +96 -0
  140. package/src/core/lint/format.ts +42 -0
  141. package/src/core/lint/index.ts +94 -0
  142. package/src/core/lint/reporter.ts +128 -0
  143. package/src/core/lint/rules/consistency.ts +158 -0
  144. package/src/core/lint/rules/heuristics.ts +97 -0
  145. package/src/core/lint/rules/strictness.ts +109 -0
  146. package/src/core/lint/types.ts +96 -0
  147. package/src/core/lint/walker.ts +248 -0
  148. package/src/core/meta/meta-store.ts +6 -73
  149. package/src/core/output/README.md +91 -0
  150. package/src/core/output/index.ts +13 -0
  151. package/src/core/output/run.ts +126 -0
  152. package/src/core/output/types.ts +129 -0
  153. package/src/core/parser/env-interpolation.ts +104 -0
  154. package/src/core/parser/filter.ts +57 -0
  155. package/src/core/parser/schema.ts +132 -5
  156. package/src/core/parser/types.ts +29 -2
  157. package/src/core/parser/variables.ts +0 -0
  158. package/src/core/parser/yaml-parser.ts +108 -13
  159. package/src/core/probe/bootstrap.ts +34 -0
  160. package/src/core/probe/dry-run-envelope.ts +57 -0
  161. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  162. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  163. package/src/core/probe/mass-assignment-template.ts +212 -0
  164. package/src/core/probe/method-probe.ts +164 -0
  165. package/src/core/probe/method-shared.ts +69 -0
  166. package/src/core/probe/negative-probe.ts +691 -0
  167. package/src/core/probe/orphan-tracker.ts +188 -0
  168. package/src/core/probe/path-discovery.ts +440 -0
  169. package/src/core/probe/probe-harness.ts +120 -0
  170. package/src/core/probe/registry.ts +89 -0
  171. package/src/core/probe/runner.ts +136 -0
  172. package/src/core/probe/security-probe-class.ts +201 -0
  173. package/src/core/probe/security-probe.ts +1453 -0
  174. package/src/core/probe/shared.ts +505 -0
  175. package/src/core/probe/static-probe-class.ts +125 -0
  176. package/src/core/probe/types.ts +165 -0
  177. package/src/core/probe/verdict-aggregator.ts +33 -0
  178. package/src/core/probe/webhooks-probe.ts +284 -0
  179. package/src/core/reporter/console.ts +69 -4
  180. package/src/core/reporter/index.ts +2 -3
  181. package/src/core/reporter/json.ts +15 -2
  182. package/src/core/reporter/junit.ts +27 -12
  183. package/src/core/reporter/ndjson.ts +37 -0
  184. package/src/core/reporter/types.ts +3 -0
  185. package/src/core/runner/assertions.ts +62 -2
  186. package/src/core/runner/async-pool.ts +108 -0
  187. package/src/core/runner/auth-path.ts +8 -0
  188. package/src/core/runner/ci-context.ts +72 -0
  189. package/src/core/runner/executor.ts +391 -52
  190. package/src/core/runner/form-encode.ts +51 -0
  191. package/src/core/runner/http-client.ts +115 -7
  192. package/src/core/runner/learn-drift.ts +293 -0
  193. package/src/core/runner/preflight-vars.ts +149 -0
  194. package/src/core/runner/progress-tracker.ts +73 -0
  195. package/src/core/runner/rate-limiter.ts +203 -0
  196. package/src/core/runner/run-kind.ts +39 -0
  197. package/src/core/runner/schema-validator.ts +312 -0
  198. package/src/core/runner/send-request.ts +153 -20
  199. package/src/core/runner/types.ts +38 -0
  200. package/src/core/secrets/registry.ts +164 -0
  201. package/src/core/secrets/secrets-file.ts +115 -0
  202. package/src/core/selectors/operation-filter.ts +144 -0
  203. package/src/core/setup-api.ts +419 -17
  204. package/src/core/severity/category.ts +94 -0
  205. package/src/core/severity/index.ts +121 -0
  206. package/src/core/spec/layers.ts +154 -0
  207. package/src/core/util/format-eta.ts +21 -0
  208. package/src/core/utils.ts +5 -1
  209. package/src/core/workspace/config.ts +129 -0
  210. package/src/core/workspace/manifest.ts +283 -0
  211. package/src/core/workspace/output-rotation.ts +62 -0
  212. package/src/core/workspace/root.ts +94 -0
  213. package/src/core/workspace/triage-path.ts +87 -0
  214. package/src/db/lint-runs.ts +47 -0
  215. package/src/db/migrate.ts +126 -0
  216. package/src/db/migrations/0001_run_kind.sql +25 -0
  217. package/src/db/migrations/sql.d.ts +4 -0
  218. package/src/db/queries/collections.ts +133 -0
  219. package/src/db/queries/coverage.ts +9 -0
  220. package/src/db/queries/dashboard.ts +59 -0
  221. package/src/db/queries/results.ts +128 -0
  222. package/src/db/queries/runs.ts +235 -0
  223. package/src/db/queries/sessions.ts +42 -0
  224. package/src/db/queries/settings.ts +28 -0
  225. package/src/db/queries/types.ts +172 -0
  226. package/src/db/queries.ts +72 -802
  227. package/src/db/schema.ts +179 -48
  228. package/src/cli/commands/export.ts +0 -144
  229. package/src/cli/commands/guide.ts +0 -127
  230. package/src/cli/commands/init.ts +0 -57
  231. package/src/cli/commands/serve.ts +0 -81
  232. package/src/cli/commands/sync.ts +0 -269
  233. package/src/cli/commands/update.ts +0 -189
  234. package/src/cli/commands/validate.ts +0 -34
  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 -21
  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:
@@ -42,16 +45,34 @@ jobs:
42
45
  - name: Run smoke tests (read-only, safe for production)
43
46
  run: |
44
47
  mkdir -p test-results
45
- zond run apis/ --tag smoke --safe --report junit --no-db > test-results/smoke.xml
48
+ # --exclude-tag needs-id skips positive smoke that needs real IDs from .env.yaml
49
+ zond run apis/ --tag smoke --exclude-tag needs-id --safe --report junit --no-db > test-results/smoke.xml
46
50
  # Use --env-var "API_KEY=\${{ secrets.API_KEY }}" to inject secrets without writing to disk
47
51
  continue-on-error: true
48
52
 
49
- - name: Run CRUD tests (staging only)
53
+ - name: Run CRUD tests (staging only — ephemeral suites only)
50
54
  run: |
51
- zond run apis/ --tag crud --env staging --report junit --no-db > test-results/crud.xml
55
+ # --exclude-tag persistent-write keeps only ephemeral CRUD (suites that DELETE what they create).
56
+ # Drop --exclude-tag persistent-write to opt into write suites that leave residual data.
57
+ zond run apis/ --tag crud --exclude-tag persistent-write --env staging --report junit --no-db > test-results/crud.xml
52
58
  # Add --env-var "BASE_URL=\${{ secrets.STAGING_URL }}" for staging URL
53
59
  continue-on-error: true
54
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
+
55
76
  - name: Publish test results
56
77
  uses: EnricoMi/publish-unit-test-result-action@v2
57
78
  if: always()
@@ -86,8 +107,9 @@ api-smoke:
86
107
  - curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh
87
108
  script:
88
109
  - mkdir -p test-results
89
- # Use --env-var to inject secrets without writing to disk
90
- - zond run apis/ --tag smoke --safe --report junit --no-db --env-var "API_KEY=$API_KEY" > test-results/smoke.xml
110
+ # Use --env-var to inject secrets without writing to disk.
111
+ # --exclude-tag needs-id skips positive smoke that needs real IDs from .env.yaml.
112
+ - zond run apis/ --tag smoke --exclude-tag needs-id --safe --report junit --no-db --env-var "API_KEY=$API_KEY" > test-results/smoke.xml
91
113
  allow_failure:
92
114
  exit_codes: 1
93
115
  artifacts:
@@ -102,7 +124,9 @@ api-crud:
102
124
  - curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh
103
125
  script:
104
126
  - mkdir -p test-results
105
- - zond run apis/ --tag crud --env staging --report junit --no-db > test-results/crud.xml
127
+ # --exclude-tag persistent-write keeps only ephemeral CRUD (suites that DELETE what they create).
128
+ # Drop --exclude-tag persistent-write to opt into write suites that leave residual data.
129
+ - zond run apis/ --tag crud --exclude-tag persistent-write --env staging --report junit --no-db > test-results/crud.xml
106
130
  allow_failure:
107
131
  exit_codes: 1
108
132
  artifacts:
@@ -172,3 +196,28 @@ export async function ciInitCommand(options: CiInitOptions): Promise<number> {
172
196
 
173
197
  return 0;
174
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
+ }