@kirrosh/zond 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/CHANGELOG.md +648 -0
  2. package/README.md +58 -6
  3. package/package.json +9 -6
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +262 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +178 -7
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -46
  31. package/src/cli/commands/init/bootstrap.ts +30 -1
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +56 -3
  34. package/src/cli/commands/init/templates/agents.md +65 -61
  35. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  36. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  37. package/src/cli/commands/init/templates/skills/zond.md +592 -125
  38. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  39. package/src/cli/commands/prepare-fixtures.ts +135 -0
  40. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  41. package/src/cli/commands/probe/security.ts +454 -0
  42. package/src/cli/commands/probe/static.ts +255 -0
  43. package/src/cli/commands/probe/webhooks.ts +161 -0
  44. package/src/cli/commands/probe.ts +459 -0
  45. package/src/cli/commands/reference.ts +87 -0
  46. package/src/cli/commands/refresh-api.ts +169 -0
  47. package/src/cli/commands/remove-api.ts +150 -0
  48. package/src/cli/commands/report-bundle.ts +318 -0
  49. package/src/cli/commands/report.ts +241 -0
  50. package/src/cli/commands/request.ts +379 -4
  51. package/src/cli/commands/run.ts +842 -53
  52. package/src/cli/commands/session.ts +244 -0
  53. package/src/cli/commands/use.ts +18 -1
  54. package/src/cli/index.ts +20 -3
  55. package/src/cli/json-envelope.ts +112 -3
  56. package/src/cli/json-schemas.ts +263 -0
  57. package/src/cli/program.ts +198 -635
  58. package/src/cli/resolve.ts +105 -0
  59. package/src/cli/status-filter.ts +124 -0
  60. package/src/cli/util/api-context.ts +85 -0
  61. package/src/cli/version.ts +5 -0
  62. package/src/core/anti-fp/bootstrap.ts +34 -0
  63. package/src/core/anti-fp/index.ts +33 -0
  64. package/src/core/anti-fp/registry.ts +44 -0
  65. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  66. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  67. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  68. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  69. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  70. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  71. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  72. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  73. package/src/core/anti-fp/types.ts +68 -0
  74. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  75. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  76. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  77. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  78. package/src/core/checks/checks/cross_call_references.ts +134 -0
  79. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  80. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  81. package/src/core/checks/checks/ignored_auth.ts +211 -0
  82. package/src/core/checks/checks/index.ts +65 -0
  83. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  84. package/src/core/checks/checks/missing_required_header.ts +40 -0
  85. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  86. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  87. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  88. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  89. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  90. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  91. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  92. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  93. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  94. package/src/core/checks/checks/unsupported_method.ts +63 -0
  95. package/src/core/checks/checks/use_after_free.ts +78 -0
  96. package/src/core/checks/index.ts +30 -0
  97. package/src/core/checks/mode.ts +79 -0
  98. package/src/core/checks/recommended-action.ts +64 -0
  99. package/src/core/checks/registry.ts +78 -0
  100. package/src/core/checks/runner.ts +874 -0
  101. package/src/core/checks/sarif.ts +230 -0
  102. package/src/core/checks/stateful.ts +121 -0
  103. package/src/core/checks/types.ts +189 -0
  104. package/src/core/classifier/recommended-action.ts +222 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +185 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +151 -11
  110. package/src/core/diagnostics/failure-class.ts +120 -0
  111. package/src/core/diagnostics/failure-hints.ts +212 -9
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +42 -16
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/create-body.ts +89 -0
  124. package/src/core/generator/data-factory.ts +445 -19
  125. package/src/core/generator/describe.ts +1 -1
  126. package/src/core/generator/fixtures-builder.ts +325 -0
  127. package/src/core/generator/index.ts +7 -5
  128. package/src/core/generator/openapi-reader.ts +37 -3
  129. package/src/core/generator/path-param-disambig.ts +114 -0
  130. package/src/core/generator/resources-builder.ts +648 -0
  131. package/src/core/generator/schema-utils.ts +11 -3
  132. package/src/core/generator/serializer.ts +103 -13
  133. package/src/core/generator/suite-generator.ts +419 -111
  134. package/src/core/generator/types.ts +8 -0
  135. package/src/core/identity/identity-file.ts +129 -0
  136. package/src/core/lint/affects.ts +28 -0
  137. package/src/core/lint/config.ts +96 -0
  138. package/src/core/lint/format.ts +42 -0
  139. package/src/core/lint/index.ts +94 -0
  140. package/src/core/lint/reporter.ts +128 -0
  141. package/src/core/lint/rules/consistency.ts +158 -0
  142. package/src/core/lint/rules/heuristics.ts +97 -0
  143. package/src/core/lint/rules/strictness.ts +109 -0
  144. package/src/core/lint/types.ts +96 -0
  145. package/src/core/lint/walker.ts +248 -0
  146. package/src/core/meta/meta-store.ts +6 -73
  147. package/src/core/output/README.md +91 -0
  148. package/src/core/output/index.ts +13 -0
  149. package/src/core/output/run.ts +126 -0
  150. package/src/core/output/types.ts +129 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +57 -0
  159. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  160. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  161. package/src/core/probe/mass-assignment-template.ts +212 -0
  162. package/src/core/probe/method-probe.ts +43 -76
  163. package/src/core/probe/method-shared.ts +69 -0
  164. package/src/core/probe/negative-probe.ts +183 -149
  165. package/src/core/probe/orphan-tracker.ts +188 -0
  166. package/src/core/probe/path-discovery.ts +440 -0
  167. package/src/core/probe/probe-harness.ts +120 -0
  168. package/src/core/probe/registry.ts +89 -0
  169. package/src/core/probe/runner.ts +136 -0
  170. package/src/core/probe/security-probe-class.ts +201 -0
  171. package/src/core/probe/security-probe.ts +1453 -0
  172. package/src/core/probe/shared.ts +505 -0
  173. package/src/core/probe/static-probe-class.ts +125 -0
  174. package/src/core/probe/types.ts +165 -0
  175. package/src/core/probe/verdict-aggregator.ts +33 -0
  176. package/src/core/probe/webhooks-probe.ts +284 -0
  177. package/src/core/reporter/console.ts +41 -2
  178. package/src/core/reporter/index.ts +2 -3
  179. package/src/core/reporter/json.ts +11 -1
  180. package/src/core/reporter/junit.ts +27 -12
  181. package/src/core/reporter/ndjson.ts +37 -0
  182. package/src/core/reporter/types.ts +3 -0
  183. package/src/core/runner/assertions.ts +58 -1
  184. package/src/core/runner/async-pool.ts +108 -0
  185. package/src/core/runner/auth-path.ts +8 -0
  186. package/src/core/runner/ci-context.ts +72 -0
  187. package/src/core/runner/executor.ts +264 -20
  188. package/src/core/runner/form-encode.ts +51 -0
  189. package/src/core/runner/http-client.ts +75 -2
  190. package/src/core/runner/learn-drift.ts +293 -0
  191. package/src/core/runner/preflight-vars.ts +149 -0
  192. package/src/core/runner/progress-tracker.ts +73 -0
  193. package/src/core/runner/rate-limiter.ts +89 -17
  194. package/src/core/runner/run-kind.ts +39 -0
  195. package/src/core/runner/schema-validator.ts +312 -0
  196. package/src/core/runner/send-request.ts +153 -20
  197. package/src/core/runner/types.ts +38 -0
  198. package/src/core/secrets/registry.ts +164 -0
  199. package/src/core/secrets/secrets-file.ts +115 -0
  200. package/src/core/selectors/operation-filter.ts +144 -0
  201. package/src/core/setup-api.ts +415 -16
  202. package/src/core/severity/category.ts +94 -0
  203. package/src/core/severity/index.ts +121 -0
  204. package/src/core/spec/layers.ts +154 -0
  205. package/src/core/util/format-eta.ts +21 -0
  206. package/src/core/utils.ts +5 -1
  207. package/src/core/workspace/config.ts +129 -0
  208. package/src/core/workspace/manifest.ts +283 -0
  209. package/src/core/workspace/output-rotation.ts +62 -0
  210. package/src/core/workspace/triage-path.ts +87 -0
  211. package/src/db/lint-runs.ts +47 -0
  212. package/src/db/migrate.ts +126 -0
  213. package/src/db/migrations/0001_run_kind.sql +25 -0
  214. package/src/db/migrations/sql.d.ts +4 -0
  215. package/src/db/queries/collections.ts +133 -0
  216. package/src/db/queries/coverage.ts +9 -0
  217. package/src/db/queries/dashboard.ts +59 -0
  218. package/src/db/queries/results.ts +128 -0
  219. package/src/db/queries/runs.ts +235 -0
  220. package/src/db/queries/sessions.ts +42 -0
  221. package/src/db/queries/settings.ts +28 -0
  222. package/src/db/queries/types.ts +172 -0
  223. package/src/db/queries.ts +72 -802
  224. package/src/db/schema.ts +178 -50
  225. package/src/cli/commands/export.ts +0 -144
  226. package/src/cli/commands/guide.ts +0 -127
  227. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  228. package/src/cli/commands/probe-methods.ts +0 -108
  229. package/src/cli/commands/probe-validation.ts +0 -124
  230. package/src/cli/commands/serve.ts +0 -114
  231. package/src/cli/commands/sync.ts +0 -268
  232. package/src/cli/commands/update.ts +0 -189
  233. package/src/cli/commands/validate.ts +0 -34
  234. package/src/core/diagnostics/render-md.ts +0 -112
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -19
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -1,15 +1,14 @@
1
1
  # zond workspace config
2
2
  #
3
3
  # The presence of this file marks the workspace root for `zond` walk-up
4
- # resolution (zond.db, apis/<name>/, .zond-current are anchored here).
5
- #
6
- # Schema below is a placeholder — TASK-12 will wire a real config loader.
7
- # Uncomment and edit as needed.
4
+ # resolution (zond.db, apis/<name>/, .zond/current-api are anchored here).
8
5
 
9
6
  version: 1
10
7
 
11
- # default_reporter: console # console | json | junit
12
- # default_safe: false
13
- # default_timeout_ms: 30000
14
- # default_tags: []
15
- # fail_on_coverage: 0
8
+ # Workspace-level defaults. Per-command flags always win; per-API
9
+ # override lives in apis/<name>/.env.yaml as `rateLimit:` / `timeoutMs:`.
10
+ # Resolution: CLI flag > .env.yaml meta > defaults below > built-in fallback.
11
+ #
12
+ # defaults:
13
+ # timeout_ms: 30000 # cleanup / prepare-fixtures / probe / request
14
+ # rate_limit: 5 # `zond run` (number rps, or "auto" for adaptive)
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `zond prepare-fixtures` — unified fixture-pack command.
3
+ *
4
+ * Consolidates the former `discover` and `bootstrap` (TASK-299, m-13 D):
5
+ *
6
+ * - default → single-pass discover (auto-fill FK ids
7
+ * from list endpoints).
8
+ * - --cascade → multi-pass cascade (former bootstrap).
9
+ * - --seed → cascade + POST-create when discover misses
10
+ * (implies --cascade).
11
+ * - --verify / --refresh → revalidate fixtures via read-by-id
12
+ * (former `discover --verify/--refresh`).
13
+ *
14
+ * The imperative cores (`discoverCommand`, `bootstrapCommand`) live in
15
+ * the original modules and are still consumed directly by tests. This
16
+ * module only owns the CLI surface.
17
+ */
18
+
19
+ import type { Command } from "commander";
20
+ import { globalJson, resolveSpecArg } from "../resolve.ts";
21
+ import { parsePositiveInt } from "../argv.ts";
22
+ import { getDb } from "../../db/schema.ts";
23
+ import { findCollectionByNameOrId } from "../../db/queries.ts";
24
+ import { printError } from "../output.ts";
25
+ import { discoverCommand } from "./discover.ts";
26
+ import { bootstrapCommand } from "./bootstrap.ts";
27
+ import { loadEnvMeta } from "../../core/parser/variables.ts";
28
+ import { resolveTimeoutMs } from "../../core/workspace/config.ts";
29
+ import { getApi, MISSING_API_MESSAGE } from "../util/api-context.ts";
30
+
31
+ export function registerPrepareFixtures(program: Command): void {
32
+ program
33
+ .command("prepare-fixtures")
34
+ .description(
35
+ "Auto-fill apis/<name>/.env.yaml — single-pass discover by default, " +
36
+ "or `--cascade` for the multi-pass discover+seed flow (replaces the legacy " +
37
+ "`discover` and `bootstrap` commands; TASK-299).",
38
+ )
39
+ // Not `requiredOption` — the value can also come from the program-level
40
+ // --api flag (parsed by program.ts and mirrored into ZOND_API_GLOBAL),
41
+ // ZOND_API env, or .zond/current-api. Commander would otherwise reject
42
+ // `zond prepare-fixtures --api foo` because it routes `--api` to the
43
+ // global option, leaving the subcommand's opts.api undefined.
44
+ .option("--api <name>", "Registered API to prepare (apis/<name>/.env.yaml). Falls back to ZOND_API / .zond/current-api.")
45
+ .option("--db <path>", "Path to SQLite database file")
46
+ .option("--api-dir <path>", "Override apis/<name>/ root (defaults to the collection's base_dir)")
47
+ .option("--env <path>", "Override .env.yaml path (defaults to <api-dir>/.env.yaml)")
48
+ .option("--apply", "Write discovered values to .env.yaml (with .env.yaml.bak backup). Default: dry-run.")
49
+ .option("--cascade", "Multi-pass cascade discover (former `bootstrap`). Required for --seed / --force / --max-passes.")
50
+ .option("--seed", "POST-create resources when discover can't find an existing record (implies --cascade)")
51
+ .option("--force", "Re-discover/re-seed even if a fixture is already filled (cascade only)")
52
+ .option("--verify", "GET each fixture's read-by-id endpoint and classify live/stale/unknown (single-pass only). Combine with --apply (or use --refresh) to drop stale fixtures and re-resolve them. (TASK-281)")
53
+ .option("--refresh", "Shortcut for --verify --apply (single-pass only). (TASK-281)")
54
+ .option("--timeout <ms>", "Per-request timeout in ms (overrides apis/<name>/.env.yaml `timeoutMs` and zond.config.yml `defaults.timeout_ms`; default 30000)", parsePositiveInt("--timeout"))
55
+ .option("--max-passes <n>", "Cap on cascade passes (default 8; cascade only)", parsePositiveInt("--max-passes"))
56
+ .action(async (opts, cmd: Command) => {
57
+ // ARV-53: --api resolution lives in cli/util/api-context.ts —
58
+ // local opt > ancestor opt > ZOND_API_GLOBAL/ZOND_API/.zond/current-api.
59
+ const apiName = getApi(cmd, opts);
60
+ if (!apiName) {
61
+ printError(MISSING_API_MESSAGE);
62
+ process.exitCode = 2;
63
+ return;
64
+ }
65
+ opts.api = apiName;
66
+
67
+ const cascade = opts.cascade === true || opts.seed === true;
68
+ const refresh = opts.refresh === true;
69
+ const verify = opts.verify === true || refresh;
70
+
71
+ // Flag combos that don't make sense — fail fast with a clear hint.
72
+ if (cascade && verify) {
73
+ printError("--verify / --refresh are single-pass options; drop --cascade/--seed or drop --verify.");
74
+ process.exitCode = 2;
75
+ return;
76
+ }
77
+ if (!cascade && (opts.force === true || typeof opts.maxPasses === "number")) {
78
+ printError("--force / --max-passes only apply with --cascade (or --seed).");
79
+ process.exitCode = 2;
80
+ return;
81
+ }
82
+
83
+ const resolved = resolveSpecArg(undefined, opts.api, opts.db);
84
+ if ("error" in resolved) { printError(resolved.error); process.exitCode = 2; return; }
85
+
86
+ let apiDir = opts.apiDir as string | undefined;
87
+ if (!apiDir) {
88
+ try {
89
+ getDb(opts.db);
90
+ const col = findCollectionByNameOrId(opts.api);
91
+ apiDir = col?.base_dir ?? `apis/${opts.api}`;
92
+ } catch {
93
+ apiDir = `apis/${opts.api}`;
94
+ }
95
+ }
96
+
97
+ let envTimeout: number | undefined;
98
+ try {
99
+ envTimeout = (await loadEnvMeta(undefined, apiDir)).timeoutMs;
100
+ } catch { /* meta is best-effort */ }
101
+ const timeoutMs = resolveTimeoutMs(opts.timeout, envTimeout);
102
+
103
+ if (cascade) {
104
+ process.exitCode = await bootstrapCommand({
105
+ specPath: resolved.spec,
106
+ apiDir,
107
+ envPath: opts.env,
108
+ apply: opts.apply === true,
109
+ seed: opts.seed === true,
110
+ force: opts.force === true,
111
+ timeoutMs,
112
+ maxPasses: opts.maxPasses,
113
+ json: globalJson(cmd),
114
+ // ARV-205 (R10/F6, R13/F19): surface the user-facing command name
115
+ // in the JSON envelope. Without this the user sees command="bootstrap"
116
+ // even though they typed `zond prepare-fixtures …`.
117
+ commandName: "prepare-fixtures",
118
+ });
119
+ return;
120
+ }
121
+
122
+ process.exitCode = await discoverCommand({
123
+ specPath: resolved.spec,
124
+ apiDir,
125
+ envPath: opts.env,
126
+ apply: opts.apply === true || refresh,
127
+ verify,
128
+ timeoutMs,
129
+ json: globalJson(cmd),
130
+ // ARV-205 (R10/F6, R13/F19, R14): single-pass branch also delegates,
131
+ // so surface the user-facing command name in the JSON envelope.
132
+ commandName: "prepare-fixtures",
133
+ });
134
+ });
135
+ }
@@ -0,0 +1,503 @@
1
+ import { join } from "path";
2
+ import { mkdir, writeFile } from "fs/promises";
3
+ import { loadEnvironment, loadEnvFile } from "../../../core/parser/variables.ts";
4
+ import {
5
+ runMassAssignmentProbes,
6
+ formatDigestMarkdown,
7
+ emitRegressionSuites,
8
+ } from "../../../core/probe/mass-assignment-probe.ts";
9
+ import { loadSpecForProbe, writeProbeSuites } from "../../../core/probe/runner.ts";
10
+ import { printError, printSuccess, printWarning } from "../../output.ts";
11
+ import { jsonOk, jsonError, printJson } from "../../json-envelope.ts";
12
+ import { getSecretRegistry } from "../../../core/secrets/registry.ts";
13
+ import { applySanitizer } from "../../../core/exporter/exporter.ts";
14
+ import { rotateOutputTarget } from "../../../core/workspace/output-rotation.ts";
15
+ import { tallyBySeverity, formatSummaryLine } from "../../../core/probe/verdict-aggregator.ts";
16
+ import { printMutationBanner, countCleanupFailures } from "../../../core/probe/shared.ts";
17
+ import { MassAssignmentProbe } from "../../../core/probe/mass-assignment-probe-class.ts";
18
+ import { summarizeDryRun, formatDryRunDigest } from "../../../core/probe/dry-run-envelope.ts";
19
+ import { compileOperationFilter } from "../../../core/selectors/operation-filter.ts";
20
+ import type { EndpointVerdict, MassAssignmentResult } from "../../../core/probe/mass-assignment-probe.ts";
21
+ import type { ProbeEndpointResult, ProbeEndpointStatus, ProbeFindingSeverity } from "../../../core/probe/types.ts";
22
+
23
+ interface BucketCounts {
24
+ high: number;
25
+ inconclusiveBaseline: number;
26
+ inconclusive5xx: number;
27
+ medium: number;
28
+ low: number;
29
+ ok: number;
30
+ skipped: number;
31
+ }
32
+
33
+ const MA_BUCKETS: ReadonlyArray<readonly [string, keyof BucketCounts & string]> = [
34
+ ["high", "high"],
35
+ ["inconclusive-baseline", "inconclusiveBaseline"],
36
+ ["inconclusive-5xx", "inconclusive5xx"],
37
+ ["medium", "medium"],
38
+ ["low", "low"],
39
+ ["ok", "ok"],
40
+ ["skipped", "skipped"],
41
+ ];
42
+
43
+ const MA_SUMMARY: ReadonlyArray<readonly [string, keyof BucketCounts & string]> = [
44
+ ["HIGH", "high"],
45
+ ["INCONCLUSIVE", "inconclusiveBaseline"],
46
+ ["INCONCLUSIVE-5XX", "inconclusive5xx"],
47
+ ["MED", "medium"],
48
+ ["LOW", "low"],
49
+ ["OK", "ok"],
50
+ ["SKIPPED", "skipped"],
51
+ ];
52
+
53
+ const MA_ZERO: BucketCounts = {
54
+ high: 0, inconclusiveBaseline: 0, inconclusive5xx: 0, medium: 0, low: 0, ok: 0, skipped: 0,
55
+ };
56
+
57
+ export interface ProbeMassAssignmentOptions {
58
+ specPath: string;
59
+ env?: string;
60
+ /** Markdown digest output file. If omitted — print to stdout. */
61
+ output?: string;
62
+ /** Emit regression YAML suites into this directory. */
63
+ emitTests?: string;
64
+ tag?: string;
65
+ noCleanup?: boolean;
66
+ noDiscover?: boolean;
67
+ timeoutMs?: number;
68
+ json?: boolean;
69
+ listTags?: boolean;
70
+ overwrite?: boolean;
71
+ /** m-17 / ARV-52: list which endpoints + fields would be attacked
72
+ * without sending live traffic. */
73
+ dryRun?: boolean;
74
+ /** m-17 / ARV-52: m-15 ARV-9 selector grammar (`path:`/`method:`/`tag:`/`operation-id:`). */
75
+ include?: string[];
76
+ exclude?: string[];
77
+ /** m-17 / ARV-51: format for --output / non-json stdout. */
78
+ report?: "markdown" | "json";
79
+ /** ARV-252: surface INFO-severity inconclusive verdicts (absent-but-
80
+ * unverifiable). Silently-ignored verdicts are never shown — they
81
+ * represent correct framework behaviour. */
82
+ verbose?: boolean;
83
+ /** ARV-252: additional suspect fields to inject, in `name=value`
84
+ * form. Extends the curated SUSPECTED_FIELDS list per-run. Full
85
+ * spec-extension support (x-zond-suspect-fields) is tracked in
86
+ * ARV-189. */
87
+ suspectField?: string[];
88
+ }
89
+
90
+ export async function probeMassAssignmentCommand(
91
+ options: ProbeMassAssignmentOptions,
92
+ ): Promise<number> {
93
+ try {
94
+ const loaded = await loadSpecForProbe({
95
+ specPath: options.specPath,
96
+ tag: options.tag,
97
+ listTags: options.listTags,
98
+ });
99
+
100
+ if (loaded.kind === "tags") {
101
+ if (options.json) {
102
+ printJson(jsonOk("probe-mass-assignment", { tags: loaded.tags }));
103
+ } else if (loaded.tags.length === 0) {
104
+ console.log("No tags found in spec.");
105
+ } else {
106
+ console.log("Available tags:");
107
+ for (const t of loaded.tags) console.log(` - ${t}`);
108
+ }
109
+ return 0;
110
+ }
111
+ if (loaded.kind === "tag-not-found") {
112
+ const msg = `No endpoints tagged "${loaded.tag}". Available tags: ${loaded.available.length ? loaded.available.join(", ") : "(none)"}`;
113
+ if (options.json) printJson(jsonError("probe-mass-assignment", [msg]));
114
+ else printWarning(msg);
115
+ return 2;
116
+ }
117
+ const { endpoints: rawEndpoints, securitySchemes } = loaded;
118
+
119
+ // m-17 / ARV-52: apply --include / --exclude through the unified
120
+ // operation filter (m-15 ARV-9). probe-family was deferred at AC#6;
121
+ // wiring it here closes that and gives mass-assignment parity with
122
+ // probe-static / probe-security.
123
+ let endpoints = rawEndpoints;
124
+ if (options.include?.length || options.exclude?.length) {
125
+ const compiled = compileOperationFilter({ includes: options.include, excludes: options.exclude });
126
+ if (compiled.errors.length > 0) {
127
+ const message = compiled.errors.join("\n");
128
+ if (options.json) printJson(jsonError("probe-mass-assignment", [message]));
129
+ else printError(message);
130
+ return 2;
131
+ }
132
+ endpoints = endpoints.filter(compiled.filter);
133
+ }
134
+
135
+ // Load env vars (base_url, auth_token, api_key, path-param overrides).
136
+ let vars: Record<string, string> = {};
137
+ if (options.env) {
138
+ const fromFile = await loadEnvFile(options.env);
139
+ if (!fromFile) {
140
+ const msg = `Environment file not found: ${options.env}`;
141
+ if (options.json) printJson(jsonError("probe-mass-assignment", [msg]));
142
+ else printError(msg);
143
+ return 2;
144
+ }
145
+ vars = fromFile;
146
+ } else {
147
+ vars = await loadEnvironment();
148
+ }
149
+
150
+ // m-17 / ARV-52: --dry-run lists which endpoints + suspect fields the
151
+ // probe would touch without sending live traffic. base_url is not
152
+ // required on this path (mirrors probe-security).
153
+ if (options.dryRun) {
154
+ const probe = new MassAssignmentProbe();
155
+ const plan = await probe.dryRun({
156
+ specPath: options.specPath,
157
+ endpoints,
158
+ securitySchemes,
159
+ vars,
160
+ options: {},
161
+ });
162
+ const data = summarizeDryRun(plan);
163
+ if (options.json) {
164
+ printJson(jsonOk("probe-mass-assignment", data));
165
+ } else {
166
+ console.log(formatDryRunDigest(plan));
167
+ }
168
+ return 0;
169
+ }
170
+
171
+ if (!vars["base_url"]) {
172
+ const msg = "base_url is required (set in .env.yaml or via --env file). Probing requires a live API.";
173
+ if (options.json) printJson(jsonError("probe-mass-assignment", [msg]));
174
+ else printError(msg);
175
+ return 2;
176
+ }
177
+
178
+ // TASK-259: tell the user *before* we mutate anything. Suppressed in
179
+ // --json mode (warnings already in envelope) and when --no-cleanup
180
+ // is off — this banner is about the cleanup-pass, too.
181
+ printMutationBanner("probe-mass-assignment", vars, { quiet: options.json === true });
182
+
183
+ const result = await runMassAssignmentProbes({
184
+ endpoints,
185
+ securitySchemes,
186
+ vars,
187
+ noCleanup: options.noCleanup,
188
+ timeoutMs: options.timeoutMs,
189
+ discover: !options.noDiscover,
190
+ extraSuspectFields: parseSuspectFieldFlags(options.suspectField),
191
+ });
192
+
193
+ // ARV-252: filter verdicts for display under the evidence-chain
194
+ // principle. Silently-ignored (correct framework behaviour) never
195
+ // surfaces; absent-but-unverifiable surfaces only under --verbose.
196
+ // HIGH and inconclusive-baseline/5xx always show. JSON envelope
197
+ // always carries the full unfiltered list (agents triage explicitly).
198
+ const displayResult: MassAssignmentResult = {
199
+ ...result,
200
+ verdicts: filterVerdictsForDisplay(result.verdicts, { verbose: options.verbose === true }),
201
+ };
202
+
203
+ // TASK-168 (m-10): vars came from .env.yaml — register them so any
204
+ // echoed token (URL, body, header) gets redacted in the digest.
205
+ getSecretRegistry().registerAll(vars);
206
+ const md = applySanitizer(formatDigestMarkdown(displayResult, options.specPath));
207
+
208
+ // m-17 / ARV-51: --output writes whichever format `--report` selected
209
+ // (default markdown). `--json` envelope is always structured.
210
+ const reportFmt: "markdown" | "json" = options.report ?? "markdown";
211
+ const structuredEndpoints = buildMaStructuredEndpoints(result);
212
+ if (options.output) {
213
+ await mkdir(join(options.output, "..").replace(/\/\.$/, ""), { recursive: true }).catch(() => {});
214
+ // TASK-162 (m-9 P6): rotate previous digest to <stem>-vN.md instead
215
+ // of silent overwrite. --overwrite opts back into the old behaviour.
216
+ rotateOutputTarget(options.output, { overwrite: options.overwrite });
217
+ const payload = reportFmt === "json"
218
+ ? JSON.stringify(maStructuredReport(result, structuredEndpoints), null, 2) + "\n"
219
+ : md;
220
+ await writeFile(options.output, payload, "utf-8");
221
+ }
222
+
223
+ let emittedSuites: Array<{ file: string; suite: string; tests: number }> = [];
224
+ if (options.emitTests) {
225
+ const suites = emitRegressionSuites(result, endpoints, securitySchemes);
226
+ const written = await writeProbeSuites({
227
+ output: options.emitTests,
228
+ suites,
229
+ command: "zond probe-mass-assignment --emit-tests",
230
+ headerExample: `zond probe-mass-assignment --api <name> --emit-tests ${options.emitTests}`,
231
+ });
232
+ emittedSuites = written.files;
233
+ }
234
+
235
+ const counts = tallyBySeverity(result.verdicts, MA_BUCKETS, MA_ZERO);
236
+ const orphans = countCleanupFailures(result.verdicts);
237
+
238
+ if (options.json) {
239
+ // m-17 / ARV-51: structured envelope; no `data.digest.stdout`.
240
+ printJson(
241
+ jsonOk("probe-mass-assignment", {
242
+ endpoints: structuredEndpoints,
243
+ summary: {
244
+ totalEndpoints: result.totalEndpoints,
245
+ probed: result.specProbed,
246
+ by_status: maByStatus(structuredEndpoints),
247
+ },
248
+ orphans,
249
+ warnings: result.warnings,
250
+ emittedTests: emittedSuites,
251
+ }),
252
+ );
253
+ } else {
254
+ if (!options.output) {
255
+ if (reportFmt === "json") {
256
+ process.stdout.write(JSON.stringify(maStructuredReport(result, structuredEndpoints), null, 2) + "\n");
257
+ } else {
258
+ console.log(md);
259
+ }
260
+ } else printSuccess(`${reportFmt === "json" ? "Structured report" : "Digest"} written to ${options.output}`);
261
+ console.log("");
262
+ console.log(formatSummaryLine(counts, MA_SUMMARY));
263
+ if (emittedSuites.length > 0) {
264
+ printSuccess(`Emitted ${emittedSuites.length} regression suite(s) in ${options.emitTests}`);
265
+ console.log(` Run them on CI: zond run ${options.emitTests} --env ${options.env ?? ".env.yaml"}`);
266
+ } else if (options.emitTests) {
267
+ console.log(`No findings to emit. Directory ${options.emitTests} not created.`);
268
+ }
269
+ if (counts.high > 0) {
270
+ printWarning(`${counts.high} HIGH-severity finding(s) — privilege escalation candidates. Review the digest.`);
271
+ }
272
+ if (counts.inconclusiveBaseline > 0) {
273
+ printWarning(
274
+ `${counts.inconclusiveBaseline} endpoint(s) had baseline POST failures — fix env fixtures (FK ids / path-params) and re-run. These are excluded from --emit-tests on purpose.`,
275
+ );
276
+ }
277
+ // TASK-259: cleanup-failure surfaces as "orphans" in summary. 404 was
278
+ // already filtered out (resource gone is success). Prompt for manual
279
+ // cleanup so the user doesn't discover the leak only via 5xx in CI.
280
+ if (orphans > 0) {
281
+ printWarning(
282
+ `${orphans} orphan resource(s): cleanup DELETE failed (non-404). Manual cleanup may be needed — see digest "Cleanup DELETE: …" lines.`,
283
+ );
284
+ }
285
+ // Stale-fixture hint when probes successfully cleaned up at least one
286
+ // resource: that means we POSTed (and re-DELETEd) — `.env.yaml` slug/id
287
+ // values for that resource type may now point at a tombstone.
288
+ const cleanedCount = result.verdicts.filter(v => v.cleanup?.attempted && v.cleanup.status != null && v.cleanup.status < 400).length;
289
+ if (cleanedCount > 0) {
290
+ printWarning(
291
+ `${cleanedCount} resource(s) created and deleted by probes. FK fixtures in .env.yaml may be stale — re-run \`zond prepare-fixtures --api <name>\` before next CRUD run.`,
292
+ );
293
+ }
294
+ }
295
+
296
+ // Non-zero exit when HIGH findings — useful for CI gating.
297
+ return counts.high > 0 ? 1 : 0;
298
+ } catch (err) {
299
+ const message = err instanceof Error ? err.message : String(err);
300
+ if (options.json) printJson(jsonError("probe-mass-assignment", [message]));
301
+ else printError(message);
302
+ return 2;
303
+ }
304
+ }
305
+
306
+ // ──────────────────────────────────────────────
307
+ // TASK-146: --emit-template short-circuit
308
+ // ──────────────────────────────────────────────
309
+
310
+ import { buildMassAssignmentTemplate } from "../../../core/probe/mass-assignment-template.ts";
311
+
312
+ export interface EmitTemplateCliOptions {
313
+ specPath: string;
314
+ /** "METHOD:/path", e.g. "POST:/users" or "POST /users". */
315
+ methodPath: string;
316
+ output?: string;
317
+ json?: boolean;
318
+ }
319
+
320
+ export async function emitMassAssignmentTemplateCommand(
321
+ options: EmitTemplateCliOptions,
322
+ ): Promise<number> {
323
+ const parsed = parseMethodPath(options.methodPath);
324
+ if (!parsed) {
325
+ const msg = `--emit-template expects "METHOD:/path" (e.g. "POST:/users"), got: ${options.methodPath}`;
326
+ if (options.json) printJson(jsonError("probe-mass-assignment", [msg]));
327
+ else printError(msg);
328
+ return 2;
329
+ }
330
+
331
+ try {
332
+ const result = await buildMassAssignmentTemplate({
333
+ specPath: options.specPath,
334
+ method: parsed.method,
335
+ path: parsed.path,
336
+ });
337
+
338
+ if (result.kind === "endpoint-not-found") {
339
+ const lines = [`endpoint not found: ${parsed.method} ${parsed.path}`];
340
+ if (result.nearest.length > 0) {
341
+ lines.push(`nearest paths with method ${parsed.method}: ${result.nearest.join(", ")}`);
342
+ }
343
+ const msg = lines.join("\n");
344
+ if (options.json) printJson(jsonError("probe-mass-assignment", [msg]));
345
+ else printError(msg);
346
+ return 2;
347
+ }
348
+
349
+ if (options.output) {
350
+ await mkdir(join(options.output, "..").replace(/[^/]+$/, ""), { recursive: true }).catch(() => {});
351
+ await writeFile(options.output, result.yaml, "utf-8");
352
+ if (options.json) {
353
+ printJson(
354
+ jsonOk("probe-mass-assignment", {
355
+ template: { file: options.output, chain: result.chain, protectedFields: result.protectedFields },
356
+ }),
357
+ );
358
+ } else {
359
+ printSuccess(`Template written to ${options.output} (chain=${result.chain})`);
360
+ if (result.protectedFields.length > 0) {
361
+ console.log(` readOnly/x-zond-protected fields injected: ${result.protectedFields.join(", ")}`);
362
+ }
363
+ }
364
+ } else {
365
+ if (options.json) {
366
+ printJson(
367
+ jsonOk("probe-mass-assignment", {
368
+ template: { yaml: result.yaml, chain: result.chain, protectedFields: result.protectedFields },
369
+ }),
370
+ );
371
+ } else {
372
+ process.stdout.write(result.yaml);
373
+ }
374
+ }
375
+ return 0;
376
+ } catch (err) {
377
+ const message = err instanceof Error ? err.message : String(err);
378
+ if (options.json) printJson(jsonError("probe-mass-assignment", [message]));
379
+ else printError(message);
380
+ return 2;
381
+ }
382
+ }
383
+
384
+ function parseMethodPath(s: string): { method: string; path: string } | null {
385
+ const m = s.match(/^\s*([A-Za-z]+)\s*[: ]\s*(\/.*?)\s*$/);
386
+ if (!m) return null;
387
+ return { method: m[1]!.toUpperCase(), path: m[2]! };
388
+ }
389
+
390
+ // m-17 / ARV-51: structured per-endpoint shape for mass-assignment.
391
+
392
+ /**
393
+ * ARV-252: parse repeatable `--suspect-field name=value` flags into the
394
+ * extra-fields map. Values are kept as strings — generateFromSchema /
395
+ * sentinel inference happens server-side via the suspect-fields machinery.
396
+ * Malformed entries (no `=`) are skipped silently rather than failing the
397
+ * run — this keeps ad-hoc CLI usage forgiving.
398
+ */
399
+ function parseSuspectFieldFlags(raw: string[] | undefined): Record<string, unknown> | undefined {
400
+ if (!raw || raw.length === 0) return undefined;
401
+ const out: Record<string, unknown> = {};
402
+ for (const entry of raw) {
403
+ const eq = entry.indexOf("=");
404
+ if (eq <= 0) continue;
405
+ const name = entry.slice(0, eq).trim();
406
+ const value = entry.slice(eq + 1);
407
+ if (name) out[name] = value;
408
+ }
409
+ return Object.keys(out).length > 0 ? out : undefined;
410
+ }
411
+
412
+ /**
413
+ * ARV-252: filter verdicts for the digest/console display under the
414
+ * evidence-chain principle.
415
+ *
416
+ * - HIGH (applied) — always show; this is the actual finding.
417
+ * - inconclusive-baseline / inconclusive-5xx / ok / skipped — always
418
+ * show; operator needs them to triage probe coverage.
419
+ * - INFO with at least one `absent` outcome (couldn't verify via
420
+ * follow-up GET) — show only under --verbose. This is the "single
421
+ * signal, no proof" case.
422
+ * - INFO with only `ignored` outcomes (silently dropped — correct
423
+ * framework behaviour) — NEVER show. Reports must not noise-floor
424
+ * on intentional behaviour.
425
+ *
426
+ * JSON envelope is unfiltered; this is a display-layer transform only.
427
+ */
428
+ function filterVerdictsForDisplay(
429
+ verdicts: EndpointVerdict[],
430
+ opts: { verbose: boolean },
431
+ ): EndpointVerdict[] {
432
+ return verdicts.filter((v) => {
433
+ if (v.severity !== "info") return true;
434
+ const hasAbsent = v.fields.some((f) => f.outcome === "absent");
435
+ if (!hasAbsent) return false; // silently-ignored: always hidden
436
+ return opts.verbose;
437
+ });
438
+ }
439
+
440
+ function maStatusFromSeverity(s: EndpointVerdict["severity"]): ProbeEndpointStatus {
441
+ switch (s) {
442
+ case "high": return "high";
443
+ case "low":
444
+ case "medium":
445
+ case "info":
446
+ return "low";
447
+ case "ok": return "ok";
448
+ case "skipped": return "skipped";
449
+ case "inconclusive-baseline":
450
+ case "inconclusive-5xx":
451
+ return "inconclusive";
452
+ }
453
+ }
454
+
455
+ function maFindingSeverity(s: EndpointVerdict["severity"]): ProbeFindingSeverity {
456
+ if (s === "high") return "high";
457
+ if (s === "low" || s === "medium") return "low";
458
+ if (s === "ok") return "ok";
459
+ return "inconclusive";
460
+ }
461
+
462
+ function buildMaStructuredEndpoints(result: MassAssignmentResult): ProbeEndpointResult[] {
463
+ return result.verdicts.map((v) => ({
464
+ path: v.path,
465
+ method: v.method,
466
+ classes_run: ["mass-assignment"],
467
+ findings: v.severity === "skipped" || v.severity === "ok"
468
+ ? []
469
+ : [{
470
+ class: "mass-assignment",
471
+ severity: maFindingSeverity(v.severity),
472
+ evidence: {
473
+ summary: v.summary,
474
+ request: { url: v.request.url, injectedFields: v.request.injectedFields },
475
+ ...(v.response ? { response: { status: v.response.status } } : {}),
476
+ ...(v.fields ? { fields: v.fields } : {}),
477
+ ...(v.recommended_action ? { recommended_action: v.recommended_action } : {}),
478
+ },
479
+ }],
480
+ status: maStatusFromSeverity(v.severity),
481
+ ...(v.severity === "skipped" ? { skip_reason: v.skipReason ?? v.summary } : {}),
482
+ }));
483
+ }
484
+
485
+ function maByStatus(endpoints: ProbeEndpointResult[]): Record<ProbeEndpointStatus, number> {
486
+ const out: Record<ProbeEndpointStatus, number> = {
487
+ ok: 0, high: 0, low: 0, inconclusive: 0, skipped: 0,
488
+ };
489
+ for (const e of endpoints) out[e.status]++;
490
+ return out;
491
+ }
492
+
493
+ function maStructuredReport(result: MassAssignmentResult, endpoints: ProbeEndpointResult[]): object {
494
+ return {
495
+ endpoints,
496
+ summary: {
497
+ totalEndpoints: result.totalEndpoints,
498
+ probed: result.specProbed,
499
+ by_status: maByStatus(endpoints),
500
+ },
501
+ };
502
+ }
503
+