@kirrosh/zond 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/CHANGELOG.md +648 -0
  2. package/README.md +58 -6
  3. package/package.json +9 -6
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +262 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +178 -7
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -46
  31. package/src/cli/commands/init/bootstrap.ts +30 -1
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +56 -3
  34. package/src/cli/commands/init/templates/agents.md +65 -61
  35. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  36. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  37. package/src/cli/commands/init/templates/skills/zond.md +592 -125
  38. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  39. package/src/cli/commands/prepare-fixtures.ts +135 -0
  40. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  41. package/src/cli/commands/probe/security.ts +454 -0
  42. package/src/cli/commands/probe/static.ts +255 -0
  43. package/src/cli/commands/probe/webhooks.ts +161 -0
  44. package/src/cli/commands/probe.ts +459 -0
  45. package/src/cli/commands/reference.ts +87 -0
  46. package/src/cli/commands/refresh-api.ts +169 -0
  47. package/src/cli/commands/remove-api.ts +150 -0
  48. package/src/cli/commands/report-bundle.ts +318 -0
  49. package/src/cli/commands/report.ts +241 -0
  50. package/src/cli/commands/request.ts +379 -4
  51. package/src/cli/commands/run.ts +842 -53
  52. package/src/cli/commands/session.ts +244 -0
  53. package/src/cli/commands/use.ts +18 -1
  54. package/src/cli/index.ts +20 -3
  55. package/src/cli/json-envelope.ts +112 -3
  56. package/src/cli/json-schemas.ts +263 -0
  57. package/src/cli/program.ts +198 -635
  58. package/src/cli/resolve.ts +105 -0
  59. package/src/cli/status-filter.ts +124 -0
  60. package/src/cli/util/api-context.ts +85 -0
  61. package/src/cli/version.ts +5 -0
  62. package/src/core/anti-fp/bootstrap.ts +34 -0
  63. package/src/core/anti-fp/index.ts +33 -0
  64. package/src/core/anti-fp/registry.ts +44 -0
  65. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  66. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  67. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  68. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  69. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  70. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  71. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  72. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  73. package/src/core/anti-fp/types.ts +68 -0
  74. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  75. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  76. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  77. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  78. package/src/core/checks/checks/cross_call_references.ts +134 -0
  79. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  80. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  81. package/src/core/checks/checks/ignored_auth.ts +211 -0
  82. package/src/core/checks/checks/index.ts +65 -0
  83. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  84. package/src/core/checks/checks/missing_required_header.ts +40 -0
  85. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  86. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  87. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  88. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  89. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  90. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  91. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  92. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  93. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  94. package/src/core/checks/checks/unsupported_method.ts +63 -0
  95. package/src/core/checks/checks/use_after_free.ts +78 -0
  96. package/src/core/checks/index.ts +30 -0
  97. package/src/core/checks/mode.ts +79 -0
  98. package/src/core/checks/recommended-action.ts +64 -0
  99. package/src/core/checks/registry.ts +78 -0
  100. package/src/core/checks/runner.ts +874 -0
  101. package/src/core/checks/sarif.ts +230 -0
  102. package/src/core/checks/stateful.ts +121 -0
  103. package/src/core/checks/types.ts +189 -0
  104. package/src/core/classifier/recommended-action.ts +222 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +185 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +151 -11
  110. package/src/core/diagnostics/failure-class.ts +120 -0
  111. package/src/core/diagnostics/failure-hints.ts +212 -9
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +42 -16
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/create-body.ts +89 -0
  124. package/src/core/generator/data-factory.ts +445 -19
  125. package/src/core/generator/describe.ts +1 -1
  126. package/src/core/generator/fixtures-builder.ts +325 -0
  127. package/src/core/generator/index.ts +7 -5
  128. package/src/core/generator/openapi-reader.ts +37 -3
  129. package/src/core/generator/path-param-disambig.ts +114 -0
  130. package/src/core/generator/resources-builder.ts +648 -0
  131. package/src/core/generator/schema-utils.ts +11 -3
  132. package/src/core/generator/serializer.ts +103 -13
  133. package/src/core/generator/suite-generator.ts +419 -111
  134. package/src/core/generator/types.ts +8 -0
  135. package/src/core/identity/identity-file.ts +129 -0
  136. package/src/core/lint/affects.ts +28 -0
  137. package/src/core/lint/config.ts +96 -0
  138. package/src/core/lint/format.ts +42 -0
  139. package/src/core/lint/index.ts +94 -0
  140. package/src/core/lint/reporter.ts +128 -0
  141. package/src/core/lint/rules/consistency.ts +158 -0
  142. package/src/core/lint/rules/heuristics.ts +97 -0
  143. package/src/core/lint/rules/strictness.ts +109 -0
  144. package/src/core/lint/types.ts +96 -0
  145. package/src/core/lint/walker.ts +248 -0
  146. package/src/core/meta/meta-store.ts +6 -73
  147. package/src/core/output/README.md +91 -0
  148. package/src/core/output/index.ts +13 -0
  149. package/src/core/output/run.ts +126 -0
  150. package/src/core/output/types.ts +129 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +57 -0
  159. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  160. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  161. package/src/core/probe/mass-assignment-template.ts +212 -0
  162. package/src/core/probe/method-probe.ts +43 -76
  163. package/src/core/probe/method-shared.ts +69 -0
  164. package/src/core/probe/negative-probe.ts +183 -149
  165. package/src/core/probe/orphan-tracker.ts +188 -0
  166. package/src/core/probe/path-discovery.ts +440 -0
  167. package/src/core/probe/probe-harness.ts +120 -0
  168. package/src/core/probe/registry.ts +89 -0
  169. package/src/core/probe/runner.ts +136 -0
  170. package/src/core/probe/security-probe-class.ts +201 -0
  171. package/src/core/probe/security-probe.ts +1453 -0
  172. package/src/core/probe/shared.ts +505 -0
  173. package/src/core/probe/static-probe-class.ts +125 -0
  174. package/src/core/probe/types.ts +165 -0
  175. package/src/core/probe/verdict-aggregator.ts +33 -0
  176. package/src/core/probe/webhooks-probe.ts +284 -0
  177. package/src/core/reporter/console.ts +41 -2
  178. package/src/core/reporter/index.ts +2 -3
  179. package/src/core/reporter/json.ts +11 -1
  180. package/src/core/reporter/junit.ts +27 -12
  181. package/src/core/reporter/ndjson.ts +37 -0
  182. package/src/core/reporter/types.ts +3 -0
  183. package/src/core/runner/assertions.ts +58 -1
  184. package/src/core/runner/async-pool.ts +108 -0
  185. package/src/core/runner/auth-path.ts +8 -0
  186. package/src/core/runner/ci-context.ts +72 -0
  187. package/src/core/runner/executor.ts +264 -20
  188. package/src/core/runner/form-encode.ts +51 -0
  189. package/src/core/runner/http-client.ts +75 -2
  190. package/src/core/runner/learn-drift.ts +293 -0
  191. package/src/core/runner/preflight-vars.ts +149 -0
  192. package/src/core/runner/progress-tracker.ts +73 -0
  193. package/src/core/runner/rate-limiter.ts +89 -17
  194. package/src/core/runner/run-kind.ts +39 -0
  195. package/src/core/runner/schema-validator.ts +312 -0
  196. package/src/core/runner/send-request.ts +153 -20
  197. package/src/core/runner/types.ts +38 -0
  198. package/src/core/secrets/registry.ts +164 -0
  199. package/src/core/secrets/secrets-file.ts +115 -0
  200. package/src/core/selectors/operation-filter.ts +144 -0
  201. package/src/core/setup-api.ts +415 -16
  202. package/src/core/severity/category.ts +94 -0
  203. package/src/core/severity/index.ts +121 -0
  204. package/src/core/spec/layers.ts +154 -0
  205. package/src/core/util/format-eta.ts +21 -0
  206. package/src/core/utils.ts +5 -1
  207. package/src/core/workspace/config.ts +129 -0
  208. package/src/core/workspace/manifest.ts +283 -0
  209. package/src/core/workspace/output-rotation.ts +62 -0
  210. package/src/core/workspace/triage-path.ts +87 -0
  211. package/src/db/lint-runs.ts +47 -0
  212. package/src/db/migrate.ts +126 -0
  213. package/src/db/migrations/0001_run_kind.sql +25 -0
  214. package/src/db/migrations/sql.d.ts +4 -0
  215. package/src/db/queries/collections.ts +133 -0
  216. package/src/db/queries/coverage.ts +9 -0
  217. package/src/db/queries/dashboard.ts +59 -0
  218. package/src/db/queries/results.ts +128 -0
  219. package/src/db/queries/runs.ts +235 -0
  220. package/src/db/queries/sessions.ts +42 -0
  221. package/src/db/queries/settings.ts +28 -0
  222. package/src/db/queries/types.ts +172 -0
  223. package/src/db/queries.ts +72 -802
  224. package/src/db/schema.ts +178 -50
  225. package/src/cli/commands/export.ts +0 -144
  226. package/src/cli/commands/guide.ts +0 -127
  227. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  228. package/src/cli/commands/probe-methods.ts +0 -108
  229. package/src/cli/commands/probe-validation.ts +0 -124
  230. package/src/cli/commands/serve.ts +0 -114
  231. package/src/cli/commands/sync.ts +0 -268
  232. package/src/cli/commands/update.ts +0 -189
  233. package/src/cli/commands/validate.ts +0 -34
  234. package/src/core/diagnostics/render-md.ts +0 -112
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -19
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,188 @@
1
+ /**
2
+ * TASK-278: persist «probe created this resource and DELETE failed» records to
3
+ * `~/.zond/orphans/<api>/<run-id>.jsonl` so `zond cleanup --orphans` can
4
+ * retry the deletion later — without re-running the probe.
5
+ *
6
+ * One JSON object per line for forward-compatible streaming reads. We never
7
+ * rewrite existing lines; cleanup-success appends a new `removed: true`
8
+ * record that supersedes the original one (loadOrphans collapses
9
+ * supersessions in memory so the on-disk file is append-only and crash-safe).
10
+ */
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { mkdir, readdir, readFile, appendFile } from "node:fs/promises";
14
+ import type { SecurityVerdict } from "./security-probe.ts";
15
+
16
+ export interface OrphanRecord {
17
+ api: string;
18
+ runId: string;
19
+ /** ISO timestamp of the cleanup-attempt that produced this record. */
20
+ createdAt: string;
21
+ /** Method/path of the *creating* endpoint (e.g. POST /teams/). */
22
+ method: string;
23
+ path: string;
24
+ /** Captured id of the leaked resource (slug/uuid/numeric id). */
25
+ id: string;
26
+ /** Concrete DELETE URL path with the id already substituted. */
27
+ deletePath: string;
28
+ /** Last DELETE status zond observed; `null` for network errors. */
29
+ lastCleanupStatus: number | null;
30
+ /** Last error string (network message or HTTP-status sentence). */
31
+ lastCleanupError: string | null;
32
+ /** When true, this record cancels a prior orphan with the same
33
+ * (api, runId, deletePath, id) tuple. Used by `cleanup --orphans` to
34
+ * mark replayed-and-now-gone resources. */
35
+ removed?: boolean;
36
+ /** ARV-102 (F7): probe knew the resource was created but couldn't
37
+ * derive a DELETE plan (response had no usable id, or the spec has no
38
+ * DELETE counterpart for the create endpoint). The record is still
39
+ * worth keeping so `cleanup --orphans` can surface "manual cleanup
40
+ * required" — DELETE retry isn't possible, but the user must know.
41
+ * When set, deletePath / id may be empty and `lastCleanupError`
42
+ * carries the reason from the probe (e.g. "cleanup skipped: response
43
+ * had no usable id"). */
44
+ requires_manual_cleanup?: boolean;
45
+ }
46
+
47
+ export function orphansRoot(): string {
48
+ return process.env.ZOND_ORPHANS_DIR ?? join(homedir(), ".zond", "orphans");
49
+ }
50
+
51
+ function recordFile(api: string, runId: string): string {
52
+ return join(orphansRoot(), api, `${runId}.jsonl`);
53
+ }
54
+
55
+ export async function appendOrphanRecord(record: OrphanRecord): Promise<void> {
56
+ const file = recordFile(record.api, record.runId);
57
+ await mkdir(join(orphansRoot(), record.api), { recursive: true });
58
+ await appendFile(file, JSON.stringify(record) + "\n", "utf-8");
59
+ }
60
+
61
+ /**
62
+ * Snapshot a probe-run's verdicts into the orphans store. We persist EVERY
63
+ * cleanup attempt that has a known id (regardless of success) so a SIGINT
64
+ * mid-run still leaves a trace of created resources. Successful DELETEs are
65
+ * written with `lastCleanupStatus` in 2xx — `loadOrphans` will treat them as
66
+ * already-removed and skip them.
67
+ */
68
+ export async function persistVerdictsAsOrphans(api: string, runId: string, verdicts: SecurityVerdict[]): Promise<number> {
69
+ let written = 0;
70
+ for (const v of verdicts) {
71
+ const c = v.cleanup;
72
+ if (!c?.attempted) continue;
73
+ // ARV-102 (F7): pre-fix this branch dropped any verdict whose
74
+ // cleanup had no usable id OR no DELETE path (e.g. "no DELETE
75
+ // counterpart for POST /symbol-sources/", "response had no usable
76
+ // id"). Probe digest still counted these as cleanup-failures, but
77
+ // they never reached the orphan registry — `cleanup --orphans` then
78
+ // reported zero, hiding live API leakage. Now we persist a
79
+ // `requires_manual_cleanup: true` record so the operator at least
80
+ // sees "5 resources need manual cleanup".
81
+ const haveDeletePlan = c.id !== undefined && !!c.deletePath;
82
+ if (!haveDeletePlan) {
83
+ const record: OrphanRecord = {
84
+ api,
85
+ runId,
86
+ createdAt: new Date().toISOString(),
87
+ method: v.method.toUpperCase(),
88
+ path: v.path,
89
+ id: c.id !== undefined ? String(c.id) : "",
90
+ deletePath: c.deletePath ?? "",
91
+ lastCleanupStatus: c.status ?? null,
92
+ lastCleanupError: c.error ?? "cleanup skipped: no DELETE plan",
93
+ requires_manual_cleanup: true,
94
+ };
95
+ await appendOrphanRecord(record);
96
+ written++;
97
+ continue;
98
+ }
99
+ const removed = c.status != null && c.status >= 200 && c.status < 300;
100
+ const record: OrphanRecord = {
101
+ api,
102
+ runId,
103
+ createdAt: new Date().toISOString(),
104
+ method: v.method.toUpperCase(),
105
+ path: v.path,
106
+ id: String(c.id),
107
+ deletePath: c.deletePath ?? "",
108
+ lastCleanupStatus: c.status ?? null,
109
+ lastCleanupError: c.error ?? null,
110
+ ...(removed ? { removed: true } : {}),
111
+ };
112
+ await appendOrphanRecord(record);
113
+ written++;
114
+ }
115
+ return written;
116
+ }
117
+
118
+ /**
119
+ * Read every orphan file (optionally filtered by `--api` and `--run`) and
120
+ * return the surviving records — i.e. those NOT yet superseded by a
121
+ * `removed: true` follow-up.
122
+ */
123
+ export async function loadOrphans(filter: { api?: string; runId?: string } = {}): Promise<OrphanRecord[]> {
124
+ const root = orphansRoot();
125
+ const apis: string[] = [];
126
+ try {
127
+ const entries = await readdir(root, { withFileTypes: true });
128
+ for (const e of entries) {
129
+ if (e.isDirectory() && (!filter.api || e.name === filter.api)) apis.push(e.name);
130
+ }
131
+ } catch {
132
+ return [];
133
+ }
134
+
135
+ const out: OrphanRecord[] = [];
136
+ for (const api of apis) {
137
+ const dir = join(root, api);
138
+ let files: string[] = [];
139
+ try {
140
+ files = (await readdir(dir)).filter(f => f.endsWith(".jsonl"));
141
+ } catch {
142
+ continue;
143
+ }
144
+ for (const f of files) {
145
+ const runId = f.replace(/\.jsonl$/, "");
146
+ if (filter.runId && runId !== filter.runId) continue;
147
+ const file = join(dir, f);
148
+ let raw: string;
149
+ try {
150
+ raw = await readFile(file, "utf-8");
151
+ } catch {
152
+ continue;
153
+ }
154
+ // De-dup: later records on the same (api, runId, deletePath, id) win.
155
+ // `removed: true` cancels the entry; non-removed records keep the
156
+ // most recent cleanup status / error.
157
+ // ARV-102 (F7): manual-only records (requires_manual_cleanup) often
158
+ // have empty deletePath / id (probe couldn't derive them), so the
159
+ // standard key would collapse all of them onto a single bucket.
160
+ // Fold method/path into the key for that branch — every distinct
161
+ // (method, path) on which probe gave up survives independently.
162
+ const byKey = new Map<string, OrphanRecord>();
163
+ for (const line of raw.split("\n")) {
164
+ const trimmed = line.trim();
165
+ if (!trimmed) continue;
166
+ try {
167
+ const r = JSON.parse(trimmed) as OrphanRecord;
168
+ const key = r.requires_manual_cleanup
169
+ ? `${r.api}|${r.runId}|manual|${r.method}|${r.path}|${r.id}`
170
+ : `${r.api}|${r.runId}|${r.deletePath}|${r.id}`;
171
+ if (r.removed) {
172
+ byKey.delete(key);
173
+ } else {
174
+ byKey.set(key, r);
175
+ }
176
+ } catch {
177
+ // Skip malformed lines — best-effort parse.
178
+ }
179
+ }
180
+ for (const r of byKey.values()) out.push(r);
181
+ }
182
+ }
183
+ return out;
184
+ }
185
+
186
+ export async function markRemoved(record: OrphanRecord): Promise<void> {
187
+ await appendOrphanRecord({ ...record, removed: true, lastCleanupStatus: 200, lastCleanupError: null, createdAt: new Date().toISOString() });
188
+ }
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Path-param fixture auto-discovery (TASK-92).
3
+ *
4
+ * Live probe runtime hook: before marking an endpoint as skipped because
5
+ * `.env.yaml` doesn't supply a value for a path placeholder
6
+ * (e.g. `/domains/{domain_id}` without `domain_id` in env), try to find a
7
+ * sibling list endpoint (`GET /domains`), call it once per run, and extract
8
+ * `data[0].id` (or compatible shape). Result cached per `GET listPath`.
9
+ *
10
+ * Failure modes (returned as `miss`, not exception):
11
+ * • no `GET <listPath>` in spec
12
+ * • list returned non-2xx
13
+ * • list response has no extractable id
14
+ * • list returned an empty array
15
+ * • listPath itself depends on unresolved params we couldn't discover
16
+ */
17
+ import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
18
+ import { executeRequest } from "../runner/http-client.ts";
19
+ import { substituteString } from "../parser/variables.ts";
20
+ import { convertPath, captureFieldFor, liveAuthHeaders } from "./shared.ts";
21
+
22
+ export type DiscoveryHit = { kind: "hit"; values: Record<string, string> };
23
+ export type DiscoveryMiss = { kind: "miss"; reason: string };
24
+ export type DiscoveryResult = DiscoveryHit | DiscoveryMiss;
25
+
26
+ export interface DiscoveryCache {
27
+ /** key = `GET ${listPath}` (raw, with {placeholders}) */
28
+ results: Map<string, DiscoveryResult>;
29
+ /** keys currently being resolved — used to break cycles. */
30
+ inFlight: Set<string>;
31
+ }
32
+
33
+ export function createDiscoveryCache(): DiscoveryCache {
34
+ return { results: new Map(), inFlight: new Set() };
35
+ }
36
+
37
+ export interface DiscoverOptions {
38
+ ep: EndpointInfo;
39
+ unresolved: string[];
40
+ allEndpoints: EndpointInfo[];
41
+ schemes: SecuritySchemeInfo[];
42
+ vars: Record<string, string>;
43
+ cache: DiscoveryCache;
44
+ timeoutMs?: number;
45
+ }
46
+
47
+ export async function discoverPathParams(opts: DiscoverOptions): Promise<DiscoveryResult> {
48
+ const discovered: Record<string, string> = {};
49
+ for (const name of opts.unresolved) {
50
+ const listPath = parentCollectionPath(opts.ep.path, name);
51
+ if (!listPath) {
52
+ return { kind: "miss", reason: `cannot derive list-path for {${name}} from ${opts.ep.path}` };
53
+ }
54
+ const result = await discoverFromList({
55
+ paramName: name,
56
+ listPath,
57
+ allEndpoints: opts.allEndpoints,
58
+ schemes: opts.schemes,
59
+ vars: { ...opts.vars, ...discovered },
60
+ cache: opts.cache,
61
+ timeoutMs: opts.timeoutMs,
62
+ });
63
+ if (result.kind === "miss") return result;
64
+ Object.assign(discovered, result.values);
65
+ }
66
+ return { kind: "hit", values: discovered };
67
+ }
68
+
69
+ interface DiscoverFromListOpts {
70
+ paramName: string;
71
+ listPath: string;
72
+ allEndpoints: EndpointInfo[];
73
+ schemes: SecuritySchemeInfo[];
74
+ vars: Record<string, string>;
75
+ cache: DiscoveryCache;
76
+ timeoutMs?: number;
77
+ }
78
+
79
+ async function discoverFromList(opts: DiscoverFromListOpts): Promise<DiscoveryResult> {
80
+ const cacheKey = `GET ${opts.listPath}`;
81
+ const cached = opts.cache.results.get(cacheKey);
82
+ if (cached) {
83
+ if (cached.kind === "hit") {
84
+ const v = pickValue(cached.values);
85
+ if (v !== undefined) return { kind: "hit", values: { [opts.paramName]: v } };
86
+ return { kind: "miss", reason: `cached list ${opts.listPath} has no usable id` };
87
+ }
88
+ return cached;
89
+ }
90
+ if (opts.cache.inFlight.has(cacheKey)) {
91
+ return { kind: "miss", reason: `cycle detected resolving ${opts.listPath}` };
92
+ }
93
+
94
+ const listEp = opts.allEndpoints.find(
95
+ e => e.method.toUpperCase() === "GET" && e.path === opts.listPath && !e.deprecated,
96
+ );
97
+ if (!listEp) {
98
+ const miss: DiscoveryMiss = { kind: "miss", reason: `no GET ${opts.listPath} in spec` };
99
+ opts.cache.results.set(cacheKey, miss);
100
+ return miss;
101
+ }
102
+
103
+ opts.cache.inFlight.add(cacheKey);
104
+ try {
105
+ // Resolve listEp's own path placeholders (nested-collection case).
106
+ const baseUrl = (opts.vars["base_url"] ?? "").replace(/\/+$/, "");
107
+ const templated = `${baseUrl}${convertPath(listEp.path)}`;
108
+ let urlVars = opts.vars;
109
+ let urlSubstituted = String(substituteString(templated, urlVars));
110
+ let stillUnresolved = collectUnresolved(urlSubstituted);
111
+ if (stillUnresolved.length > 0) {
112
+ const inner = await discoverPathParams({
113
+ ep: listEp,
114
+ unresolved: stillUnresolved,
115
+ allEndpoints: opts.allEndpoints,
116
+ schemes: opts.schemes,
117
+ vars: opts.vars,
118
+ cache: opts.cache,
119
+ timeoutMs: opts.timeoutMs,
120
+ });
121
+ if (inner.kind === "miss") {
122
+ const miss: DiscoveryMiss = {
123
+ kind: "miss",
124
+ reason: `parent of {${opts.paramName}} unresolved (${inner.reason})`,
125
+ };
126
+ opts.cache.results.set(cacheKey, miss);
127
+ return miss;
128
+ }
129
+ urlVars = { ...opts.vars, ...inner.values };
130
+ urlSubstituted = String(substituteString(templated, urlVars));
131
+ stillUnresolved = collectUnresolved(urlSubstituted);
132
+ if (stillUnresolved.length > 0) {
133
+ const miss: DiscoveryMiss = {
134
+ kind: "miss",
135
+ reason: `parent of {${opts.paramName}} unresolved after discovery: ${stillUnresolved.join(", ")}`,
136
+ };
137
+ opts.cache.results.set(cacheKey, miss);
138
+ return miss;
139
+ }
140
+ }
141
+
142
+ const url = appendLimitOne(urlSubstituted, listEp);
143
+ const headers: Record<string, string> = {
144
+ accept: "application/json",
145
+ ...liveAuthHeaders(listEp, opts.schemes, urlVars),
146
+ };
147
+
148
+ let resp;
149
+ try {
150
+ resp = await executeRequest(
151
+ { method: "GET", url, headers },
152
+ { timeout: opts.timeoutMs ?? 30000, retries: 0 },
153
+ );
154
+ } catch (err) {
155
+ const miss: DiscoveryMiss = {
156
+ kind: "miss",
157
+ reason: `GET ${opts.listPath} network error: ${err instanceof Error ? err.message : String(err)}`,
158
+ };
159
+ opts.cache.results.set(cacheKey, miss);
160
+ return miss;
161
+ }
162
+ if (resp.status < 200 || resp.status >= 300) {
163
+ const miss: DiscoveryMiss = {
164
+ kind: "miss",
165
+ reason: `GET ${opts.listPath} returned ${resp.status}`,
166
+ };
167
+ opts.cache.results.set(cacheKey, miss);
168
+ return miss;
169
+ }
170
+ const id = extractFirstId(resp.body_parsed ?? resp.body, listEp);
171
+ if (id === undefined) {
172
+ // Distinguish empty-list from has-items-but-no-id-shape.
173
+ const empty = isEmptyList(resp.body_parsed ?? resp.body);
174
+ const miss: DiscoveryMiss = {
175
+ kind: "miss",
176
+ reason: empty
177
+ ? `auto-discovery: GET ${opts.listPath} returned empty list`
178
+ : `GET ${opts.listPath} response has no extractable id`,
179
+ };
180
+ opts.cache.results.set(cacheKey, miss);
181
+ return miss;
182
+ }
183
+ const hit: DiscoveryHit = { kind: "hit", values: { [opts.paramName]: id } };
184
+ opts.cache.results.set(cacheKey, hit);
185
+ return hit;
186
+ } finally {
187
+ opts.cache.inFlight.delete(cacheKey);
188
+ }
189
+ }
190
+
191
+ /** Walk segments of `path`, return everything before the first `{paramName}` segment. */
192
+ export function parentCollectionPath(path: string, paramName: string): string | undefined {
193
+ const segments = path.split("/");
194
+ const idx = segments.findIndex(seg => seg === `{${paramName}}`);
195
+ if (idx <= 0) return undefined;
196
+ return segments.slice(0, idx).join("/") || "/";
197
+ }
198
+
199
+ function collectUnresolved(url: string): string[] {
200
+ return Array.from(url.matchAll(/\{\{([^}]+)\}\}/g)).map(m => m[1]!);
201
+ }
202
+
203
+ function appendLimitOne(url: string, listEp: EndpointInfo): string {
204
+ const hasLimitParam = listEp.parameters.some(
205
+ p => p.in === "query" && (p.name === "limit" || p.name === "per_page" || p.name === "page_size"),
206
+ );
207
+ if (!hasLimitParam) return url;
208
+ const sep = url.includes("?") ? "&" : "?";
209
+ const name = listEp.parameters.find(
210
+ p => p.in === "query" && (p.name === "limit" || p.name === "per_page" || p.name === "page_size"),
211
+ )!.name;
212
+ return `${url}${sep}${name}=1`;
213
+ }
214
+
215
+ /**
216
+ * Body-FK auto-discovery (TASK-137).
217
+ *
218
+ * Mirror of `discoverPathParams` for body-level FK fields. probe-mass-assignment
219
+ * builds its baseline body from the spec; required FK fields like
220
+ * `audience_id` / `project_slug` get filled with `{{$randomString}}` and the
221
+ * server returns 4xx → INCONCLUSIVE-baseline. We can do better: scan the
222
+ * request schema for FK-named fields, find a likely list endpoint
223
+ * (`GET /audiences` for `audience_id`), call it once, cache the id.
224
+ *
225
+ * The map returned is **suffix-aware**: `*_slug` → `slug`, `*_uuid` → `uuid`,
226
+ * else `id`. This matches what the env-var name implies — the same rule
227
+ * `zond discover` (TASK-136) uses for its CLI flow.
228
+ */
229
+ const BODY_FK_SUFFIXES = ["_id", "_uuid", "_slug", "_key"] as const;
230
+
231
+ interface BodyFkField {
232
+ /** Variable / body-field name (e.g. `audience_id`). */
233
+ name: string;
234
+ /** Resource stem (e.g. `audience`). */
235
+ stem: string;
236
+ /** Field expected on response item (`id`/`slug`/`uuid`/`key`). */
237
+ preferredField: string;
238
+ }
239
+
240
+ function collectBodyFkFields(
241
+ schema: import("openapi-types").OpenAPIV3.SchemaObject | undefined,
242
+ ): BodyFkField[] {
243
+ if (!schema || !schema.properties) return [];
244
+ const required = new Set(schema.required ?? []);
245
+ const out: BodyFkField[] = [];
246
+ for (const name of Object.keys(schema.properties)) {
247
+ if (!required.has(name)) continue;
248
+ for (const suffix of BODY_FK_SUFFIXES) {
249
+ if (name.endsWith(suffix)) {
250
+ const stem = name.slice(0, -suffix.length);
251
+ if (!stem) break;
252
+ const preferredField = suffix === "_id" ? "id" : suffix.slice(1);
253
+ out.push({ name, stem, preferredField });
254
+ break;
255
+ }
256
+ }
257
+ }
258
+ return out;
259
+ }
260
+
261
+ /** Walk the spec for a likely list endpoint owning the given stem. We accept
262
+ * collection-level GET on `/<stem>s`, `/<stem>`, or any path ending in those.
263
+ * Skips path-templated lists (need parent context). */
264
+ function findOwnerListEndpoint(
265
+ stem: string,
266
+ endpoints: EndpointInfo[],
267
+ ): EndpointInfo | undefined {
268
+ const candidates = [`${stem}s`, stem, stem.replace(/y$/, "ies"), stem.replace(/s$/, "")];
269
+ for (const candidate of candidates) {
270
+ if (!candidate) continue;
271
+ const re = new RegExp(`/${candidate}/?$`, "i");
272
+ const ep = endpoints.find(
273
+ e =>
274
+ e.method.toUpperCase() === "GET" &&
275
+ !e.deprecated &&
276
+ !e.path.includes("{") &&
277
+ re.test(e.path),
278
+ );
279
+ if (ep) return ep;
280
+ }
281
+ return undefined;
282
+ }
283
+
284
+ export interface BodyFkDiscoveryOptions {
285
+ ep: EndpointInfo;
286
+ allEndpoints: EndpointInfo[];
287
+ schemes: SecuritySchemeInfo[];
288
+ vars: Record<string, string>;
289
+ cache: DiscoveryCache;
290
+ timeoutMs?: number;
291
+ }
292
+
293
+ export interface BodyFkDiscoveryResult {
294
+ /** Discovered values to merge into vars (only when missing in vars). */
295
+ values: Record<string, string>;
296
+ /** Field names that look like FK but couldn't be resolved (with reason). */
297
+ misses: Array<{ field: string; reason: string }>;
298
+ }
299
+
300
+ export async function discoverBodyFkVars(
301
+ opts: BodyFkDiscoveryOptions,
302
+ ): Promise<BodyFkDiscoveryResult> {
303
+ const fields = collectBodyFkFields(opts.ep.requestBodySchema);
304
+ const values: Record<string, string> = {};
305
+ const misses: Array<{ field: string; reason: string }> = [];
306
+
307
+ for (const field of fields) {
308
+ if (opts.vars[field.name] !== undefined && opts.vars[field.name] !== "") {
309
+ // already supplied by the user — nothing to do.
310
+ continue;
311
+ }
312
+ const listEp = findOwnerListEndpoint(field.stem, opts.allEndpoints);
313
+ if (!listEp) {
314
+ misses.push({ field: field.name, reason: `no GET /<${field.stem}s> in spec` });
315
+ continue;
316
+ }
317
+
318
+ const cacheKey = `GET ${listEp.path}#${field.preferredField}`;
319
+ const cached = opts.cache.results.get(cacheKey);
320
+ if (cached) {
321
+ if (cached.kind === "hit" && cached.values[field.name] !== undefined) {
322
+ values[field.name] = cached.values[field.name]!;
323
+ } else if (cached.kind === "miss") {
324
+ misses.push({ field: field.name, reason: cached.reason });
325
+ }
326
+ continue;
327
+ }
328
+
329
+ const baseUrl = (opts.vars["base_url"] ?? "").replace(/\/+$/, "");
330
+ const url = `${baseUrl}${listEp.path}`;
331
+ const headers: Record<string, string> = {
332
+ accept: "application/json",
333
+ ...liveAuthHeaders(listEp, opts.schemes, opts.vars),
334
+ };
335
+
336
+ let resp;
337
+ try {
338
+ resp = await executeRequest(
339
+ { method: "GET", url, headers },
340
+ { timeout: opts.timeoutMs ?? 30000, retries: 0 },
341
+ );
342
+ } catch (err) {
343
+ const reason = `network error on GET ${listEp.path}: ${err instanceof Error ? err.message : String(err)}`;
344
+ opts.cache.results.set(cacheKey, { kind: "miss", reason });
345
+ misses.push({ field: field.name, reason });
346
+ continue;
347
+ }
348
+ if (resp.status < 200 || resp.status >= 300) {
349
+ const reason = `GET ${listEp.path} → ${resp.status}`;
350
+ opts.cache.results.set(cacheKey, { kind: "miss", reason });
351
+ misses.push({ field: field.name, reason });
352
+ continue;
353
+ }
354
+ const id = pickFieldFromBody(
355
+ resp.body_parsed ?? resp.body,
356
+ field.preferredField,
357
+ );
358
+ if (id === undefined) {
359
+ const reason = `GET ${listEp.path} response has no usable ${field.preferredField} field`;
360
+ opts.cache.results.set(cacheKey, { kind: "miss", reason });
361
+ misses.push({ field: field.name, reason });
362
+ continue;
363
+ }
364
+ opts.cache.results.set(cacheKey, { kind: "hit", values: { [field.name]: id } });
365
+ values[field.name] = id;
366
+ }
367
+
368
+ return { values, misses };
369
+ }
370
+
371
+ function pickFieldFromBody(body: unknown, preferred: string): string | undefined {
372
+ const tryItem = (item: unknown): string | undefined => {
373
+ if (!item || typeof item !== "object") return undefined;
374
+ const obj = item as Record<string, unknown>;
375
+ for (const key of [preferred, "id", "slug", "uuid", "key"]) {
376
+ if (key in obj) {
377
+ const v = obj[key];
378
+ if (typeof v === "string" || typeof v === "number") return String(v);
379
+ }
380
+ }
381
+ return undefined;
382
+ };
383
+ if (Array.isArray(body)) return tryItem(body[0]);
384
+ if (body && typeof body === "object") {
385
+ const obj = body as Record<string, unknown>;
386
+ for (const wrap of ["data", "items", "results", "records"]) {
387
+ const arr = obj[wrap];
388
+ if (Array.isArray(arr) && arr.length > 0) return tryItem(arr[0]);
389
+ }
390
+ }
391
+ return undefined;
392
+ }
393
+
394
+ /** Try several common SaaS list-response shapes. */
395
+ function extractFirstId(body: unknown, listEp: EndpointInfo): string | undefined {
396
+ if (Array.isArray(body)) {
397
+ return idFromItem(body[0], listEp);
398
+ }
399
+ if (body && typeof body === "object") {
400
+ const obj = body as Record<string, unknown>;
401
+ for (const key of ["data", "items", "results", "records"]) {
402
+ const arr = obj[key];
403
+ if (Array.isArray(arr) && arr.length > 0) {
404
+ return idFromItem(arr[0], listEp);
405
+ }
406
+ }
407
+ }
408
+ return undefined;
409
+ }
410
+
411
+ function idFromItem(item: unknown, listEp: EndpointInfo): string | undefined {
412
+ if (!item || typeof item !== "object") return undefined;
413
+ const obj = item as Record<string, unknown>;
414
+ // Prefer "id"; fall back to first uuid-shaped field hinted by spec.
415
+ if (typeof obj["id"] === "string" || typeof obj["id"] === "number") {
416
+ return String(obj["id"]);
417
+ }
418
+ const hinted = captureFieldFor(listEp);
419
+ if (hinted in obj && (typeof obj[hinted] === "string" || typeof obj[hinted] === "number")) {
420
+ return String(obj[hinted]);
421
+ }
422
+ return undefined;
423
+ }
424
+
425
+ function isEmptyList(body: unknown): boolean {
426
+ if (Array.isArray(body)) return body.length === 0;
427
+ if (body && typeof body === "object") {
428
+ const obj = body as Record<string, unknown>;
429
+ for (const key of ["data", "items", "results", "records"]) {
430
+ const arr = obj[key];
431
+ if (Array.isArray(arr)) return arr.length === 0;
432
+ }
433
+ }
434
+ return false;
435
+ }
436
+
437
+ function pickValue(values: Record<string, string>): string | undefined {
438
+ for (const v of Object.values(values)) if (typeof v === "string") return v;
439
+ return undefined;
440
+ }