@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
@@ -0,0 +1,169 @@
1
+ /**
2
+ * `zond refresh-api <name>` — re-fetch the OpenAPI spec and regenerate
3
+ * the four artifacts (spec.json, .api-catalog.yaml, .api-resources.yaml,
4
+ * .api-fixtures.yaml).
5
+ *
6
+ * Without --spec, refresh re-reads from the source recorded at register
7
+ * time (treating workspace-relative paths as the local snapshot — which
8
+ * is a no-op refresh useful only for re-emitting derived artifacts after
9
+ * a builder change). With --spec, the new source is fetched, dereferenced,
10
+ * and replaces the local snapshot.
11
+ */
12
+
13
+ import { readFileSync, existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { getDb } from "../../db/schema.ts";
16
+ import { findCollectionByNameOrId, updateCollection } from "../../db/queries.ts";
17
+ import { findWorkspaceRoot } from "../../core/workspace/root.ts";
18
+ import { readOpenApiSpec } from "../../core/generator/openapi-reader.ts";
19
+ import {
20
+ resolveCollectionSpec,
21
+ writeArtifactsFromDoc,
22
+ SPEC_SNAPSHOT_FILENAME,
23
+ } from "../../core/setup-api.ts";
24
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
25
+ import { printError, printSuccess } from "../output.ts";
26
+
27
+ export interface RefreshApiOptions {
28
+ api: string;
29
+ /** When provided, fetch this source and replace spec.json. Otherwise
30
+ * re-read the existing local snapshot and just rebuild artifacts. */
31
+ spec?: string;
32
+ insecure?: boolean;
33
+ json?: boolean;
34
+ dbPath?: string;
35
+ }
36
+
37
+ export async function refreshApiCommand(opts: RefreshApiOptions): Promise<number> {
38
+ try {
39
+ getDb(opts.dbPath);
40
+ } catch (err) {
41
+ const m = `DB unavailable: ${(err as Error).message}`;
42
+ if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
43
+ return 2;
44
+ }
45
+
46
+ const collection = findCollectionByNameOrId(opts.api);
47
+ if (!collection) {
48
+ const m = `API '${opts.api}' not found.`;
49
+ if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
50
+ return 2;
51
+ }
52
+
53
+ if (!collection.base_dir) {
54
+ const m = `API '${opts.api}' has no base_dir recorded — cannot refresh artifacts.`;
55
+ if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
56
+ return 2;
57
+ }
58
+
59
+ const workspaceRoot = findWorkspaceRoot().root;
60
+ const baseDir = collection.base_dir;
61
+
62
+ // 1. Pick spec source
63
+ let specSource: string;
64
+ let usedExternal: boolean;
65
+ if (opts.spec) {
66
+ specSource = opts.spec;
67
+ usedExternal = true;
68
+ } else if (collection.openapi_spec) {
69
+ specSource = resolveCollectionSpec(collection.openapi_spec);
70
+ usedExternal = false;
71
+ if (!existsSync(specSource) && !/^https?:\/\//i.test(specSource)) {
72
+ const m = `Local spec snapshot missing at ${specSource}. Pass --spec <path|url> to re-pull from upstream.`;
73
+ if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
74
+ return 2;
75
+ }
76
+ } else {
77
+ const m = `API '${opts.api}' has no spec recorded. Pass --spec <path|url>.`;
78
+ if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
79
+ return 2;
80
+ }
81
+
82
+ // 2. Dereference (fetch if URL)
83
+ let doc: unknown;
84
+ try {
85
+ doc = await readOpenApiSpec(specSource, { insecure: opts.insecure });
86
+ } catch (err) {
87
+ const m = `Failed to read spec ${specSource}: ${(err as Error).message}`;
88
+ if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
89
+ return 2;
90
+ }
91
+
92
+ // 3. Determine baseUrl from the doc
93
+ const baseUrl = ((doc as any).servers?.[0]?.url as string | undefined) ?? "";
94
+
95
+ // 4. Write spec.json + 3 artifacts
96
+ writeArtifactsFromDoc({
97
+ doc,
98
+ baseDir,
99
+ apiName: collection.name,
100
+ baseUrl,
101
+ workspaceRoot,
102
+ });
103
+
104
+ // 5. If we pulled fresh from external, ensure the DB points to the local snapshot
105
+ const expectedDbSpec = `apis/${collection.name}/${SPEC_SNAPSHOT_FILENAME}`;
106
+ if (collection.openapi_spec !== expectedDbSpec) {
107
+ updateCollection(collection.id, { openapi_spec: expectedDbSpec });
108
+ }
109
+
110
+ // 6. Surface result
111
+ const localSpec = join(baseDir, SPEC_SNAPSHOT_FILENAME);
112
+ const endpointCount = readEndpointCount(localSpec);
113
+ const result = {
114
+ api: collection.name,
115
+ baseDir,
116
+ spec: localSpec,
117
+ pulledFrom: usedExternal ? specSource : null,
118
+ endpointCount,
119
+ artifacts: [".api-catalog.yaml", ".api-resources.yaml", ".api-fixtures.yaml"],
120
+ };
121
+
122
+ if (opts.json) {
123
+ printJson(jsonOk("refresh-api", result));
124
+ } else {
125
+ printSuccess(`Refreshed '${collection.name}' (${endpointCount} endpoints)${usedExternal ? ` from ${specSource}` : ""}`);
126
+ process.stdout.write(` spec: ${localSpec}\n`);
127
+ process.stdout.write(` artifacts: ${result.artifacts.join(", ")}\n`);
128
+ process.stdout.write(` Run \`zond doctor --api ${collection.name}\` to verify fixtures.\n`);
129
+ }
130
+ return 0;
131
+ }
132
+
133
+ function readEndpointCount(specPath: string): number {
134
+ try {
135
+ const doc = JSON.parse(readFileSync(specPath, "utf-8")) as any;
136
+ let count = 0;
137
+ for (const item of Object.values(doc.paths ?? {})) {
138
+ if (item && typeof item === "object") {
139
+ for (const k of Object.keys(item as object)) {
140
+ if (["get", "post", "put", "patch", "delete", "head", "options"].includes(k.toLowerCase())) count++;
141
+ }
142
+ }
143
+ }
144
+ return count;
145
+ } catch {
146
+ return 0;
147
+ }
148
+ }
149
+
150
+ import type { Command } from "commander";
151
+ import { globalJson } from "../resolve.ts";
152
+
153
+ export function registerRefreshApi(program: Command): void {
154
+ program
155
+ .command("refresh-api <name>")
156
+ .description("Re-snapshot the OpenAPI spec into apis/<name>/spec.json and regenerate the 3 artifacts (catalog/resources/fixtures)")
157
+ .option("--spec <path>", "Pull fresh from this path or URL (overrides registered source)")
158
+ .option("--insecure", "Allow self-signed TLS when --spec is an https URL")
159
+ .option("--db <path>", "Path to SQLite database file")
160
+ .action(async (name: string, opts, cmd: Command) => {
161
+ process.exitCode = await refreshApiCommand({
162
+ api: name,
163
+ spec: opts.spec,
164
+ insecure: opts.insecure === true,
165
+ dbPath: typeof opts.db === "string" ? opts.db : undefined,
166
+ json: globalJson(cmd),
167
+ });
168
+ });
169
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `zond remove api <name>` — unregister an API from the workspace.
3
+ *
4
+ * Mirrors `zond add api`. Removes the `collections` row, optionally the
5
+ * associated `runs`/`results`, and (by default) the `apis/<name>/`
6
+ * directory on disk. If the removed API was the active one
7
+ * (`.zond/current-api`), the marker is cleared.
8
+ *
9
+ * Without `--purge`, runs that referenced the collection are detached
10
+ * (`collection_id = NULL`) so historical run data survives the removal.
11
+ * `--purge` deletes them outright. `--keep-files` leaves the directory
12
+ * on disk and only drops the DB row, useful when the user wants to
13
+ * snapshot the artifacts elsewhere first.
14
+ */
15
+
16
+ import { existsSync, rmSync } from "node:fs";
17
+ import { relative, resolve } from "node:path";
18
+ import { getDb } from "../../db/schema.ts";
19
+ import {
20
+ deleteCollection,
21
+ findCollectionByNameOrId,
22
+ } from "../../db/queries.ts";
23
+ import { findWorkspaceRoot } from "../../core/workspace/root.ts";
24
+ import { clearCurrentApi, readCurrentApi } from "../../core/context/current.ts";
25
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
26
+ import { printError, printSuccess } from "../output.ts";
27
+
28
+ export interface RemoveApiOptions {
29
+ api: string;
30
+ purge?: boolean;
31
+ keepFiles?: boolean;
32
+ yes?: boolean;
33
+ dbPath?: string;
34
+ json?: boolean;
35
+ }
36
+
37
+ export interface RemoveApiResult {
38
+ api: string;
39
+ collectionId: number;
40
+ removedDir: string | null;
41
+ detachedRuns: number;
42
+ deletedRuns: number;
43
+ clearedCurrent: boolean;
44
+ }
45
+
46
+ export async function removeApiCommand(opts: RemoveApiOptions): Promise<number> {
47
+ try {
48
+ getDb(opts.dbPath);
49
+ } catch (err) {
50
+ const m = `DB unavailable: ${(err as Error).message}`;
51
+ if (opts.json) printJson(jsonError("remove-api", [m])); else printError(m);
52
+ return 2;
53
+ }
54
+
55
+ const collection = findCollectionByNameOrId(opts.api);
56
+ if (!collection) {
57
+ const m = `API '${opts.api}' not found.`;
58
+ if (opts.json) printJson(jsonError("remove-api", [m])); else printError(m);
59
+ return 2;
60
+ }
61
+
62
+ const db = getDb();
63
+ const runsCount = (db
64
+ .query("SELECT COUNT(*) AS c FROM runs WHERE collection_id = ?")
65
+ .get(collection.id) as { c: number }).c;
66
+
67
+ const workspaceRoot = findWorkspaceRoot().root;
68
+ const dirAbs = collection.base_dir
69
+ ? resolve(workspaceRoot, collection.base_dir)
70
+ : null;
71
+ const willRemoveDir = !opts.keepFiles && dirAbs !== null && existsSync(dirAbs);
72
+ const dirRel = dirAbs ? relative(workspaceRoot, dirAbs).replace(/\\/g, "/") : null;
73
+
74
+ if (!opts.yes && !opts.json) {
75
+ const parts = [
76
+ `Removing API '${collection.name}' (id=${collection.id})`,
77
+ willRemoveDir ? ` • directory: ${dirRel}` : ` • directory: kept`,
78
+ opts.purge
79
+ ? ` • runs: ${runsCount} runs + their results will be DELETED`
80
+ : ` • runs: ${runsCount} runs will be detached (collection_id=NULL)`,
81
+ ];
82
+ process.stderr.write(parts.join("\n") + "\nPass --yes to confirm.\n");
83
+ return 1;
84
+ }
85
+
86
+ const detachedRuns = opts.purge ? 0 : runsCount;
87
+ const deletedRuns = opts.purge ? runsCount : 0;
88
+ deleteCollection(collection.id, opts.purge === true);
89
+
90
+ let removedDir: string | null = null;
91
+ if (willRemoveDir && dirAbs) {
92
+ rmSync(dirAbs, { recursive: true, force: true });
93
+ removedDir = relative(workspaceRoot, dirAbs).replace(/\\/g, "/");
94
+ }
95
+
96
+ let clearedCurrent = false;
97
+ const current = readCurrentApi(workspaceRoot);
98
+ if (current === collection.name) {
99
+ clearedCurrent = clearCurrentApi(workspaceRoot);
100
+ }
101
+
102
+ const result: RemoveApiResult = {
103
+ api: collection.name,
104
+ collectionId: collection.id,
105
+ removedDir,
106
+ detachedRuns,
107
+ deletedRuns,
108
+ clearedCurrent,
109
+ };
110
+
111
+ if (opts.json) {
112
+ printJson(jsonOk("remove-api", result));
113
+ } else {
114
+ printSuccess(`Removed API '${collection.name}' (id=${collection.id})`);
115
+ if (removedDir) process.stdout.write(` Directory: ${removedDir} (deleted)\n`);
116
+ else if (opts.keepFiles) process.stdout.write(` Directory: ${dirRel ?? "<unknown>"} (kept by --keep-files)\n`);
117
+ if (opts.purge) process.stdout.write(` Runs: ${deletedRuns} deleted (--purge)\n`);
118
+ else if (detachedRuns > 0) process.stdout.write(` Runs: ${detachedRuns} detached (collection_id=NULL)\n`);
119
+ if (clearedCurrent) process.stdout.write(` Cleared .zond/current-api marker (was '${collection.name}')\n`);
120
+ }
121
+ return 0;
122
+ }
123
+
124
+ import type { Command } from "commander";
125
+ import { globalJson } from "../resolve.ts";
126
+
127
+ export function registerRemove(program: Command): void {
128
+ const remove = program
129
+ .command("remove")
130
+ .alias("rm")
131
+ .description("Unregister objects from the workspace");
132
+ remove
133
+ .command("api <name>")
134
+ .description("Unregister an API: drops collections row, removes apis/<name>/, optionally purges run history")
135
+ .option("--purge", "Also delete the runs+results that referenced this API (default: detach to NULL)")
136
+ .option("--keep-files", "Leave apis/<name>/ on disk; only remove the DB record")
137
+ .option("--yes", "Skip the interactive confirmation prompt")
138
+ .option("--db <path>", "Path to SQLite database file")
139
+ .action(async (name: string, opts, cmd: Command) => {
140
+ const json = globalJson(cmd);
141
+ process.exitCode = await removeApiCommand({
142
+ api: name,
143
+ purge: opts.purge === true,
144
+ keepFiles: opts.keepFiles === true,
145
+ yes: opts.yes === true || json,
146
+ dbPath: typeof opts.db === "string" ? opts.db : undefined,
147
+ json,
148
+ });
149
+ });
150
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * TASK-143: `zond report bundle <range> [--output <dir>] [--include ...]`.
3
+ *
4
+ * Batch triage exporter — collects case-study + HTML report + diagnose JSON
5
+ * for a range of run-ids in one command, plus a top-level index.md with a
6
+ * table of run-id, totals, and links.
7
+ *
8
+ * Range forms:
9
+ * A..B inclusive numeric range, e.g. "135..142"
10
+ * A,B,C comma-separated list
11
+ * --session <id> all runs for a CLI session (DB column `session_id`)
12
+ */
13
+ import { join } from "path";
14
+ import { mkdir } from "fs/promises";
15
+ import { getDb } from "../../db/schema.ts";
16
+ import { getRunById, getResultsByRunId, getCollectionById } from "../../db/queries.ts";
17
+ import type { RunRecord, StoredStepResult } from "../../db/queries/types.ts";
18
+ import { renderHtmlReport } from "../../core/exporter/html-report/index.ts";
19
+ import { renderCaseStudy } from "../../core/exporter/case-study/index.ts";
20
+ import { diagnoseRun } from "../../core/diagnostics/db-analysis.ts";
21
+ import { applySanitizer } from "../../core/exporter/exporter.ts";
22
+ import { resolveCollectionSpec } from "../../core/setup-api.ts";
23
+ import { readOpenApiSpec } from "../../core/generator/openapi-reader.ts";
24
+ import { printError, printWarning } from "../output.ts";
25
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
26
+ import { VERSION } from "../version.ts";
27
+
28
+ export type BundleArtifact = "case-study" | "export" | "diagnose";
29
+
30
+ export interface ReportBundleOptions {
31
+ /** "A..B" range, "A,B,C" list, or unused when sessionId is set. */
32
+ range?: string;
33
+ /** Resolve runs by session_id instead of explicit ids. */
34
+ sessionId?: string;
35
+ output?: string;
36
+ include?: BundleArtifact[];
37
+ bodyCapBytes?: number;
38
+ dbPath?: string;
39
+ json?: boolean;
40
+ }
41
+
42
+ const DEFAULT_BODY_CAP_BYTES = 8192;
43
+ const ALL_ARTIFACTS: BundleArtifact[] = ["case-study", "export", "diagnose"];
44
+
45
+ interface BundleEntry {
46
+ runId: number;
47
+ spec: string | null;
48
+ totals: { total: number; passed: number; failed: number; skipped: number };
49
+ caseStudy?: string;
50
+ htmlReport?: string;
51
+ diagnose?: string;
52
+ agentDirective?: string | null;
53
+ }
54
+
55
+ export function parseBundleRange(input: string): number[] | { error: string } {
56
+ const trimmed = input.trim();
57
+ if (!trimmed) return { error: "empty range" };
58
+
59
+ const rangeMatch = trimmed.match(/^(\d+)\.\.(\d+)$/);
60
+ if (rangeMatch) {
61
+ const a = parseInt(rangeMatch[1]!, 10);
62
+ const b = parseInt(rangeMatch[2]!, 10);
63
+ if (a > b) return { error: `range start ${a} is greater than end ${b}` };
64
+ const out: number[] = [];
65
+ for (let i = a; i <= b; i++) out.push(i);
66
+ return out;
67
+ }
68
+
69
+ if (trimmed.includes(",")) {
70
+ const parts = trimmed.split(",").map(s => s.trim()).filter(Boolean);
71
+ const ids: number[] = [];
72
+ for (const p of parts) {
73
+ const n = parseInt(p, 10);
74
+ if (!Number.isFinite(n) || n <= 0) return { error: `not a positive integer: ${p}` };
75
+ ids.push(n);
76
+ }
77
+ return Array.from(new Set(ids)).sort((a, b) => a - b);
78
+ }
79
+
80
+ const n = parseInt(trimmed, 10);
81
+ if (!Number.isFinite(n) || n <= 0) return { error: `not a recognised range: ${trimmed}` };
82
+ return [n];
83
+ }
84
+
85
+ export async function reportBundleCommand(options: ReportBundleOptions): Promise<number> {
86
+ try {
87
+ getDb(options.dbPath);
88
+ } catch (err) {
89
+ const msg = `Failed to open database: ${(err as Error).message}`;
90
+ if (options.json) printJson(jsonError("report bundle", [msg]));
91
+ else printError(msg);
92
+ return 2;
93
+ }
94
+
95
+ let runIds: number[];
96
+ if (options.sessionId) {
97
+ runIds = listRunIdsBySession(options.sessionId);
98
+ if (runIds.length === 0) {
99
+ const msg = `No runs found for --session ${options.sessionId}`;
100
+ if (options.json) printJson(jsonError("report bundle", [msg]));
101
+ else printError(msg);
102
+ return 1;
103
+ }
104
+ } else {
105
+ if (!options.range) {
106
+ const msg = "report bundle requires <range> (A..B / A,B,C) or --session <id>";
107
+ if (options.json) printJson(jsonError("report bundle", [msg]));
108
+ else printError(msg);
109
+ return 2;
110
+ }
111
+ const parsed = parseBundleRange(options.range);
112
+ if (!Array.isArray(parsed)) {
113
+ const msg = `Invalid range: ${parsed.error}. Examples: 135..142, 135,137,141`;
114
+ if (options.json) printJson(jsonError("report bundle", [msg]));
115
+ else printError(msg);
116
+ return 2;
117
+ }
118
+ runIds = parsed;
119
+ }
120
+
121
+ const include = options.include && options.include.length > 0 ? options.include : ALL_ARTIFACTS;
122
+ const outDir = options.output ?? join("triage", "bundle", new Date().toISOString().replace(/[:.]/g, "-"));
123
+ await mkdir(outDir, { recursive: true });
124
+
125
+ const entries: BundleEntry[] = [];
126
+ const skipped: Array<{ runId: number; reason: string }> = [];
127
+ const bodyCap = options.bodyCapBytes ?? DEFAULT_BODY_CAP_BYTES;
128
+
129
+ for (const runId of runIds) {
130
+ const run = getRunById(runId);
131
+ if (!run) {
132
+ skipped.push({ runId, reason: "not found" });
133
+ continue;
134
+ }
135
+ const results = getResultsByRunId(runId);
136
+ const runDir = join(outDir, String(runId));
137
+ await mkdir(runDir, { recursive: true });
138
+
139
+ const entry: BundleEntry = {
140
+ runId,
141
+ spec: await loadSpecTitle(run),
142
+ totals: {
143
+ total: run.total ?? 0,
144
+ passed: run.passed ?? 0,
145
+ failed: run.failed ?? 0,
146
+ skipped: run.skipped ?? 0,
147
+ },
148
+ };
149
+
150
+ if (include.includes("export")) {
151
+ const html = renderHtmlReport({
152
+ run,
153
+ results,
154
+ zondVersion: VERSION,
155
+ generatedAt: new Date(),
156
+ collectionName: run.collection_id != null ? getCollectionById(run.collection_id)?.name ?? null : null,
157
+ bodyCapBytes: bodyCap,
158
+ });
159
+ const file = join(runDir, "report.html");
160
+ await Bun.write(file, html);
161
+ entry.htmlReport = file;
162
+ }
163
+
164
+ if (include.includes("case-study")) {
165
+ const failure = pickPrimaryFailure(results);
166
+ if (failure) {
167
+ const specDoc = await loadSpecDoc(run);
168
+ const md = applySanitizer(renderCaseStudy({
169
+ result: failure,
170
+ run,
171
+ specTitle: entry.spec,
172
+ specVersion: null,
173
+ zondVersion: VERSION,
174
+ bodyCapBytes: bodyCap,
175
+ apiName: collectionApiName(run),
176
+ specDoc: specDoc as Parameters<typeof renderCaseStudy>[0]["specDoc"],
177
+ }));
178
+ const file = join(runDir, "case-study.md");
179
+ await Bun.write(file, md);
180
+ entry.caseStudy = file;
181
+ }
182
+ }
183
+
184
+ if (include.includes("diagnose")) {
185
+ const diag = diagnoseRun(runId, false, options.dbPath, 5);
186
+ const file = join(runDir, "diagnose.json");
187
+ await Bun.write(file, JSON.stringify(diag, null, 2));
188
+ entry.diagnose = file;
189
+ entry.agentDirective = (diag as unknown as { agent_directive?: string }).agent_directive ?? null;
190
+ }
191
+
192
+ entries.push(entry);
193
+ }
194
+
195
+ if (entries.length === 0) {
196
+ const msg = `No runs in [${runIds.join(", ")}] resolved to existing rows`;
197
+ if (options.json) printJson(jsonError("report bundle", [msg]));
198
+ else printError(msg);
199
+ return 1;
200
+ }
201
+
202
+ const indexPath = join(outDir, "index.md");
203
+ await Bun.write(indexPath, renderIndex(entries, skipped));
204
+
205
+ if (options.json) {
206
+ printJson(
207
+ jsonOk("report bundle", {
208
+ outputDir: outDir,
209
+ index: indexPath,
210
+ entries: entries.map(e => ({
211
+ runId: e.runId,
212
+ spec: e.spec,
213
+ totals: e.totals,
214
+ caseStudy: e.caseStudy ?? null,
215
+ htmlReport: e.htmlReport ?? null,
216
+ diagnose: e.diagnose ?? null,
217
+ agentDirective: e.agentDirective ?? null,
218
+ })),
219
+ skipped,
220
+ }),
221
+ );
222
+ } else {
223
+ if (skipped.length > 0) {
224
+ for (const s of skipped) printWarning(`Run #${s.runId} skipped: ${s.reason}`);
225
+ }
226
+ // TASK-241: status → stderr; stdout carries only the bundle dir path.
227
+ process.stderr.write(`zond: bundle written (${entries.length} run(s), index: ${indexPath})\n`,);
228
+ process.stdout.write(`${outDir}\n`);
229
+ }
230
+ return 0;
231
+ }
232
+
233
+ function listRunIdsBySession(sessionId: string): number[] {
234
+ const db = getDb();
235
+ const rows = db.query("SELECT id FROM runs WHERE session_id = ? ORDER BY started_at ASC")
236
+ .all(sessionId) as Array<{ id: number }>;
237
+ return rows.map(r => r.id);
238
+ }
239
+
240
+ async function loadSpecTitle(run: RunRecord): Promise<string | null> {
241
+ if (run.collection_id == null) return null;
242
+ const collection = getCollectionById(run.collection_id);
243
+ if (!collection?.openapi_spec) return collection?.name ?? null;
244
+ try {
245
+ const doc = await readOpenApiSpec(resolveCollectionSpec(collection.openapi_spec));
246
+ return doc.info?.title ?? collection.name ?? null;
247
+ } catch {
248
+ return collection.name ?? null;
249
+ }
250
+ }
251
+
252
+ /** ARV-107: load the parsed spec document for the run's collection so the
253
+ * case-study renderer can auto-extract the operation slice. */
254
+ async function loadSpecDoc(run: RunRecord): Promise<unknown | null> {
255
+ if (run.collection_id == null) return null;
256
+ const collection = getCollectionById(run.collection_id);
257
+ if (!collection?.openapi_spec) return null;
258
+ try {
259
+ return await readOpenApiSpec(resolveCollectionSpec(collection.openapi_spec));
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+
265
+ /** ARV-106/107: short registry slug (`--api <name>` form) for the run's
266
+ * collection, used to fill the case-study `apiName` option. */
267
+ function collectionApiName(run: RunRecord): string | null {
268
+ if (run.collection_id == null) return null;
269
+ return getCollectionById(run.collection_id)?.name ?? null;
270
+ }
271
+
272
+ function pickPrimaryFailure(results: StoredStepResult[]): StoredStepResult | null {
273
+ // Prefer a result classified as a real bug.
274
+ const bugFirst = results.find(
275
+ r => r.status !== "pass" && (r.failure_class === "definitely_bug" || r.failure_class === "likely_bug"),
276
+ );
277
+ if (bugFirst) return bugFirst;
278
+ return results.find(r => r.status === "fail" || r.status === "5xx") ?? null;
279
+ }
280
+
281
+ function renderIndex(entries: BundleEntry[], skipped: Array<{ runId: number; reason: string }>): string {
282
+ const lines: string[] = [];
283
+ lines.push("# Bundle index", "");
284
+ lines.push(`Generated: ${new Date().toISOString()}`);
285
+ lines.push(`Runs: ${entries.length}` + (skipped.length > 0 ? ` (skipped: ${skipped.length})` : ""));
286
+ lines.push("");
287
+ lines.push("| Run | Spec | Total | Pass | Fail | Skip | Artefacts | Directive |");
288
+ lines.push("|----:|------|------:|-----:|-----:|-----:|-----------|-----------|");
289
+
290
+ for (const e of entries) {
291
+ const links: string[] = [];
292
+ if (e.caseStudy) links.push(`[case-study](${rel(e.caseStudy)})`);
293
+ if (e.htmlReport) links.push(`[html](${rel(e.htmlReport)})`);
294
+ if (e.diagnose) links.push(`[diagnose](${rel(e.diagnose)})`);
295
+ lines.push(
296
+ `| ${e.runId} | ${e.spec ?? "—"} | ${e.totals.total} | ${e.totals.passed} | ${e.totals.failed} | ${e.totals.skipped} | ${links.join(" · ") || "—"} | ${truncate(e.agentDirective ?? "", 80)} |`,
297
+ );
298
+ }
299
+
300
+ if (skipped.length > 0) {
301
+ lines.push("", "## Skipped", "");
302
+ for (const s of skipped) lines.push(`- run #${s.runId} — ${s.reason}`);
303
+ }
304
+ return lines.join("\n") + "\n";
305
+ }
306
+
307
+ function rel(p: string): string {
308
+ // Index lives at <dir>/index.md, run files at <dir>/<id>/<file> — strip the
309
+ // shared parent so relative links still work after the dir is moved.
310
+ const seg = p.split(/[/\\]/);
311
+ const tail = seg.slice(-2);
312
+ return tail.join("/");
313
+ }
314
+
315
+ function truncate(s: string, n: number): string {
316
+ if (s.length <= n) return s.replace(/\n/g, " ");
317
+ return s.slice(0, n - 1).replace(/\n/g, " ") + "…";
318
+ }