@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
@@ -1,4 +1,5 @@
1
- import { join, dirname } from "path";
1
+ import { join, resolve as resolvePath } from "path";
2
+ import { existsSync } from "fs";
2
3
  import { mkdir } from "fs/promises";
3
4
  import {
4
5
  readOpenApiSpec,
@@ -7,26 +8,86 @@ import {
7
8
  scanCoveredEndpoints,
8
9
  filterUncoveredEndpoints,
9
10
  serializeSuite,
10
- buildCatalog,
11
- serializeCatalog,
12
11
  } from "../../core/generator/index.ts";
13
- import { generateSuites, findUnresolvedVars } from "../../core/generator/suite-generator.ts";
14
- import { filterByTag } from "../../core/generator/chunker.ts";
12
+ import {
13
+ generateSuites,
14
+ findUnresolvedVars,
15
+ detectCrudGroupsWithDiagnostics,
16
+ } from "../../core/generator/suite-generator.ts";
17
+ import { generateFromSchema, classifyFieldSource } from "../../core/generator/data-factory.ts";
18
+ import { filterByTag, collectTags } from "../../core/generator/chunker.ts";
19
+ import { compileOperationFilter } from "../../core/selectors/operation-filter.ts";
15
20
  import { parse } from "../../core/parser/yaml-parser.ts";
16
- import { decycleSchema } from "../../core/generator/schema-utils.ts";
21
+ import { loadEnvironment } from "../../core/parser/variables.ts";
17
22
  import { printError, printSuccess } from "../output.ts";
18
23
  import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
19
- import { readMeta, writeMeta, hashSpec, buildFileMeta } from "../../core/meta/meta-store.ts";
20
- import { version as ZOND_VERSION } from "../../../package.json";
21
24
  import { getDb } from "../../db/schema.ts";
22
25
  import { findCollectionByTestPath, updateCollection } from "../../db/queries.ts";
26
+ import { findWorkspaceRoot } from "../../core/workspace/root.ts";
27
+ import { recordGeneratedFiles, inferApiName, autoGenHeader, type RecordInput } from "../../core/workspace/manifest.ts";
28
+
29
+ /**
30
+ * Walk up from outputDir looking for the API root — the first ancestor
31
+ * that already contains `.api-catalog.yaml` (= a directory `zond add api`
32
+ * has owned). Falls back to undefined when called from a non-conventional
33
+ * layout, in which case the caller writes `.env.yaml` next to outputDir.
34
+ *
35
+ * The walk stops at filesystem root (or HOME). The optional baseUrl is
36
+ * unused at the moment but kept on the signature so callers don't have
37
+ * to recompute the conditions for "should we even bother" — when no
38
+ * env vars are needed, the caller skips this entirely.
39
+ */
40
+ function resolveApiRoot(outputDir: string, _baseUrl: string | undefined): string | undefined {
41
+ const abs = resolvePath(outputDir);
42
+ // 1) Walk up looking for an existing `.api-catalog.yaml` — strongest signal.
43
+ let dir = abs;
44
+ for (let i = 0; i < 8; i++) {
45
+ if (existsSync(join(dir, ".api-catalog.yaml"))) return dir;
46
+ const parent = resolvePath(dir, "..");
47
+ if (parent === dir) break;
48
+ dir = parent;
49
+ }
50
+ // 2) Fall back to the conventional layout: …/apis/<name>/[anything]/. The
51
+ // API root is the directory immediately under `apis/`. Picks up the
52
+ // case where the user runs `zond generate` before `zond add api`.
53
+ const norm = abs.replace(/\\/g, "/");
54
+ const m = norm.match(/^(.*?\/apis\/[^/]+)(?:\/|$)/);
55
+ return m?.[1];
56
+ }
23
57
 
24
58
  export interface GenerateOptions {
25
59
  specPath: string;
26
60
  output: string;
27
61
  tag?: string;
28
62
  uncoveredOnly?: boolean;
63
+ /** When true, deprecated endpoints are included in generation. Default
64
+ * (false) filters them out and surfaces the count as a warning so users
65
+ * can distinguish "deprecated by design" from "accidentally dropped". */
66
+ includeDeprecated?: boolean;
67
+ /** TASK-139: dry-run that prints per-resource CRUD detection verdict and
68
+ * exits — no files written. Use to debug "why didn't generate emit a
69
+ * CRUD chain for resource X?" on real specs. */
70
+ explain?: boolean;
71
+ /** TASK-219: accepted but currently a no-op — `zond generate` already
72
+ * overwrites unconditionally. Kept on the CLI so agents passing
73
+ * `--force` / `--overwrite` don't see "unknown option" and bail. A
74
+ * future fix will gate sha-mismatched user edits behind this flag. */
75
+ force?: boolean;
29
76
  json?: boolean;
77
+ /** ARV-9 unified filter: path:<regex> / method:<csv> / tag:<csv> /
78
+ * operation-id:<regex>. Multiple flags combine with OR; --exclude
79
+ * always removes. Stacks with --tag for back-compat. */
80
+ include?: string[];
81
+ exclude?: string[];
82
+ /** ARV-212 (R13/F16, R14): the explicit --api name. Lets generate read
83
+ * apis/<name>/.env.yaml directly even when --output points outside the
84
+ * apis/<name>/ tree (e.g. /tmp/<scratch>), where resolveApiRoot /
85
+ * inferApiName cannot recover the name from the path. */
86
+ apiName?: string;
87
+ /** ARV-212: explicit override for apis/<name>/ root. Caller pre-resolved
88
+ * it through the DB (base_dir column) for the case where the API was
89
+ * registered in a non-standard layout. */
90
+ apiDir?: string;
30
91
  }
31
92
 
32
93
  export async function generateCommand(options: GenerateOptions): Promise<number> {
@@ -35,9 +96,97 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
35
96
  const allEndpoints = extractEndpoints(doc);
36
97
  let endpoints = allEndpoints;
37
98
  const securitySchemes = extractSecuritySchemes(doc);
99
+
100
+ // --explain short-circuits: print the CRUD detection table and exit.
101
+ if (options.explain) {
102
+ let scope = endpoints;
103
+ if (options.tag) scope = filterByTag(scope, options.tag);
104
+ const { groups, diagnostics } = detectCrudGroupsWithDiagnostics(scope);
105
+ // Per-field body sources (TASK-269) — same scope as the table below.
106
+ const bodyFieldSources = scope
107
+ .filter(ep => ep.requestBodySchema && (ep.requestBodySchema as any).type === "object" &&
108
+ (ep.requestBodySchema as any).properties)
109
+ .map(ep => {
110
+ const props = (ep.requestBodySchema as any).properties as Record<string, any>;
111
+ const fields = Object.entries(props)
112
+ .filter(([k, s]) => !(s.readOnly === true) && k !== "id")
113
+ .map(([key, s]) => ({
114
+ field: key,
115
+ type: Array.isArray(s.type)
116
+ ? (s.type as string[]).find(x => x !== "null") ?? "any"
117
+ : (s.type ?? "any"),
118
+ value: generateFromSchema(s, key),
119
+ source: classifyFieldSource(s, key),
120
+ }));
121
+ return { method: ep.method.toUpperCase(), path: ep.path, fields };
122
+ })
123
+ .filter(e => e.fields.length > 0);
124
+
125
+ if (options.json) {
126
+ printJson(jsonOk("generate", {
127
+ mode: "explain",
128
+ totalCandidates: diagnostics.length,
129
+ chains: groups.length,
130
+ diagnostics,
131
+ bodyFieldSources,
132
+ }));
133
+ } else {
134
+ if (diagnostics.length === 0) {
135
+ console.log("No POST endpoints in scope — nothing to evaluate.");
136
+ } else {
137
+ const chains = diagnostics.filter(d => d.verdict === "chain").length;
138
+ console.log(`CRUD detection: ${chains}/${diagnostics.length} POST endpoints became chain candidates.\n`);
139
+ const headers = ["resource", "post", "get/{id}", "put/patch", "delete", "list", "verdict", "reason"];
140
+ const rows = diagnostics.map(d => [
141
+ d.resource,
142
+ d.postPath,
143
+ d.hasGetById ? "✓" : "—",
144
+ d.hasUpdate ? "✓" : "—",
145
+ d.hasDelete ? "✓" : "—",
146
+ d.hasList ? "✓" : "—",
147
+ d.verdict,
148
+ d.reason,
149
+ ]);
150
+ const widths = headers.map((h, i) =>
151
+ Math.max(h.length, ...rows.map(r => r[i]!.length)),
152
+ );
153
+ const fmt = (cells: string[]) =>
154
+ cells.map((c, i) => c.padEnd(widths[i]!)).join(" ");
155
+ console.log(fmt(headers));
156
+ console.log(widths.map(w => "─".repeat(w)).join(" "));
157
+ for (const row of rows) console.log(fmt(row));
158
+ }
159
+
160
+ // TASK-269: per-field provenance for endpoints carrying a request
161
+ // body. Helps debug "why did the API 400 on field X?" without
162
+ // re-running with --json and inspecting the generated suite.
163
+ if (bodyFieldSources.length > 0) {
164
+ console.log("");
165
+ console.log("Body field sources:");
166
+ for (const ep of bodyFieldSources) {
167
+ console.log(` ${ep.method} ${ep.path}`);
168
+ const fHeaders = ["field", "type", "value", "source"];
169
+ const rows2 = ep.fields.map(f => [
170
+ f.field,
171
+ String(f.type),
172
+ typeof f.value === "string" ? f.value : JSON.stringify(f.value),
173
+ `[${f.source}]`,
174
+ ]);
175
+ const fAll = [fHeaders, ...rows2];
176
+ const fWidths = fHeaders.map((h, i) =>
177
+ Math.max(h.length, ...fAll.map(r => r[i]!.length)),
178
+ );
179
+ const ffmt = (cells: string[]) =>
180
+ cells.map((c, i) => c.padEnd(fWidths[i]!)).join(" ");
181
+ console.log(" " + ffmt(fHeaders));
182
+ console.log(" " + fWidths.map(w => "─".repeat(w)).join(" "));
183
+ for (const r of rows2) console.log(" " + ffmt(r));
184
+ }
185
+ }
186
+ }
187
+ return 0;
188
+ }
38
189
  const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
39
- const apiName = (doc as any).info?.title as string | undefined;
40
- const apiVersion = (doc as any).info?.version as string | undefined;
41
190
  const warnings: string[] = [];
42
191
 
43
192
  // Filter to uncovered only
@@ -51,63 +200,197 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
51
200
  }
52
201
  }
53
202
 
203
+ // ARV-9: unified --include/--exclude filter (applied before --tag so
204
+ // --tag stays a thin alias when both are passed; usually only one is).
205
+ if (options.include?.length || options.exclude?.length) {
206
+ const compiled = compileOperationFilter({ includes: options.include, excludes: options.exclude });
207
+ if (compiled.errors.length > 0) {
208
+ const message = compiled.errors.join("\n");
209
+ if (options.json) printJson(jsonOk("generate", { files: [], message }, compiled.errors));
210
+ else printError(message);
211
+ return 2;
212
+ }
213
+ endpoints = endpoints.filter(compiled.filter);
214
+ }
215
+
54
216
  // Filter by tag
217
+ let tagDiagnostic: string | undefined;
55
218
  if (options.tag) {
219
+ const beforeTag = endpoints;
56
220
  endpoints = filterByTag(endpoints, options.tag);
221
+ // TASK-232: when --tag matches nothing, show the available tags so the
222
+ // user can tell "typo" apart from "spec really has no endpoints here".
223
+ // Cheap nearest-match: pick the first tag containing or contained-by the
224
+ // requested string (case-insensitive); covers "Members" → "Member".
225
+ if (endpoints.length === 0 && beforeTag.length > 0) {
226
+ const available = collectTags(beforeTag);
227
+ const wanted = options.tag.trim().toLowerCase();
228
+ const closest = available.find(t => {
229
+ const tl = t.toLowerCase();
230
+ return tl.includes(wanted) || wanted.includes(tl);
231
+ });
232
+ const hint = closest ? ` (did you mean ${closest}?)` : "";
233
+ const list = available.length > 0 ? available.join(", ") : "(none)";
234
+ tagDiagnostic = `No endpoints with tag '${options.tag}'${hint}. Available tags: ${list}`;
235
+ }
57
236
  }
58
237
 
59
238
  if (endpoints.length === 0) {
239
+ const message = tagDiagnostic ?? "No endpoints to generate tests for";
60
240
  if (options.json) {
61
- printJson(jsonOk("generate", { files: [], message: "No endpoints to generate tests for" }, warnings));
241
+ printJson(jsonOk("generate", { files: [], message }, warnings));
62
242
  } else {
63
- console.log("No endpoints to generate tests for.");
243
+ console.log(`${message}.`);
64
244
  }
65
245
  return 0;
66
246
  }
67
247
 
248
+ // Count deprecated endpoints before generateSuites filters them — we
249
+ // surface the count as a warning so deprecated-by-design and
250
+ // accidentally-dropped look different in stdout.
251
+ const deprecatedSkipped = options.includeDeprecated
252
+ ? []
253
+ : endpoints.filter(ep => ep.deprecated).map(ep => `${ep.method} ${ep.path}`);
254
+
255
+ // ARV-212 (R13/F16, R14): peek at .env.yaml *before* generating suites so
256
+ // we can pass `defaultAuthVar` into generateSuites when the spec has no
257
+ // securitySchemes but the workspace is wired for Bearer auth (the
258
+ // ARV-201 seed in setup-api.ts). Without this, GitHub-style suites go
259
+ // unauth and brick on the first rate-limited 60 requests.
260
+ //
261
+ // R14 fix: do NOT rely on resolveApiRoot(options.output) — when the
262
+ // user passes --output to a scratch directory outside apis/<name>/
263
+ // (e.g. /tmp/foo), the resolver returns undefined and we fall back to
264
+ // the output dir, which has no .env.yaml. Prefer the explicit --api
265
+ // name to construct apis/<name>/ inside the workspace.
266
+ const envForWarnings: Record<string, unknown> = {};
267
+ try {
268
+ let envDir: string | undefined = options.apiDir;
269
+ if (!envDir && options.apiName) {
270
+ const ws = findWorkspaceRoot();
271
+ envDir = resolvePath(ws.root, "apis", options.apiName);
272
+ }
273
+ if (!envDir) envDir = resolveApiRoot(options.output, baseUrl) ?? options.output;
274
+ Object.assign(envForWarnings, await loadEnvironment(undefined, envDir));
275
+ } catch { /* env load failures stay silent — original behaviour for missing files */ }
276
+
277
+ let defaultAuthVar: string | undefined;
278
+ if (securitySchemes.length === 0 && "auth_token" in envForWarnings) {
279
+ // Presence-not-value: an empty .secrets.yaml.auth_token resolves to ""
280
+ // here, but the .env.yaml wiring is what matters. Once the user fills
281
+ // .secrets.yaml the generated suite picks up the Bearer header without
282
+ // a regenerate.
283
+ defaultAuthVar = "auth_token";
284
+ }
285
+
68
286
  // Generate suites
69
- const suites = generateSuites({ endpoints, securitySchemes });
287
+ const suites = generateSuites({
288
+ endpoints,
289
+ securitySchemes,
290
+ specPath: options.specPath,
291
+ includeDeprecated: options.includeDeprecated,
292
+ defaultAuthVar,
293
+ });
294
+
295
+ const missingPathParams = new Set<string>();
296
+ let endpointsMissingPathExamples = 0;
297
+ for (const ep of endpoints) {
298
+ let epHadMiss = false;
299
+ for (const p of ep.parameters) {
300
+ if (p.in !== "path" || !p.required) continue;
301
+ const hasExample =
302
+ p.example !== undefined ||
303
+ (p.schema && (p.schema as any).example !== undefined) ||
304
+ (p.schema && (p.schema as any).default !== undefined);
305
+ const filledInEnv = (() => {
306
+ const v = envForWarnings[p.name];
307
+ return typeof v === "string" && v.length > 0 && !v.startsWith("{{");
308
+ })();
309
+ if (!hasExample && !filledInEnv) {
310
+ missingPathParams.add(p.name);
311
+ epHadMiss = true;
312
+ }
313
+ }
314
+ if (epHadMiss) endpointsMissingPathExamples++;
315
+ }
316
+ if (missingPathParams.size > 0) {
317
+ const sample = [...missingPathParams].sort().slice(0, 3).join(", ");
318
+ const more = missingPathParams.size > 3 ? `, +${missingPathParams.size - 3} more` : "";
319
+ warnings.push(
320
+ `${missingPathParams.size} path param(s) have no examples (${sample}${more}) on ${endpointsMissingPathExamples} endpoint(s) — fill apis/<name>/.env.yaml to enable positive/smoke-positive suites`,
321
+ );
322
+ }
323
+
324
+ if (deprecatedSkipped.length > 0) {
325
+ const head = deprecatedSkipped.slice(0, 3).join(", ");
326
+ const more = deprecatedSkipped.length > 3 ? `, +${deprecatedSkipped.length - 3} more` : "";
327
+ warnings.push(
328
+ `Skipped ${deprecatedSkipped.length} deprecated endpoint(s): ${head}${more} — pass --include-deprecated to include`,
329
+ );
330
+ }
331
+
332
+ // ARV-15: warn when in-scope endpoints will create/modify real resources.
333
+ // POST/PUT/PATCH/DELETE on a live API send real traffic — e.g. an
334
+ // email API's `POST /emails` literally sends mail; deleting a record
335
+ // is irreversible.
336
+ // Generation is harmless (just YAML), but `zond run` against the suite
337
+ // is not, so the warning fires here so the user sees it before they grep
338
+ // the output for what to run next.
339
+ const unsafeOps = endpoints.filter(
340
+ ep => ep.method !== "GET" && ep.method !== "HEAD" && ep.method !== "OPTIONS",
341
+ );
342
+ if (unsafeOps.length > 0) {
343
+ const byMethod = new Map<string, number>();
344
+ for (const ep of unsafeOps) {
345
+ byMethod.set(ep.method, (byMethod.get(ep.method) ?? 0) + 1);
346
+ }
347
+ const breakdown = [...byMethod.entries()]
348
+ .sort(([a], [b]) => a.localeCompare(b))
349
+ .map(([m, n]) => `${n} ${m}`)
350
+ .join(", ");
351
+ warnings.push(
352
+ `${unsafeOps.length} write endpoint(s) in scope (${breakdown}) — \`zond run\` on the resulting *-unsafe.yaml / crud-*.yaml suites will hit the real API. Use --include 'method:GET' for read-only smoke first.`,
353
+ );
354
+ }
70
355
 
71
356
  // Ensure output directory exists
72
357
  await mkdir(options.output, { recursive: true });
73
358
 
74
359
  // Write suite files
75
- const createdFiles: Array<{ file: string; suite: string; tests: number }> = [];
76
-
77
- // Build metadata for written files
78
- const metaFiles: Record<string, import("../../core/meta/types.ts").FileMeta> = {};
360
+ // ARV-15: tag each created file as safe/unsafe based on suite tags so the
361
+ // stdout summary can group them and the user can tell at a glance which
362
+ // suites send writes/deletes vs. read-only smoke.
363
+ const UNSAFE_TAGS = new Set(["unsafe", "crud", "system", "reset", "cleanup"]);
364
+ const isUnsafeSuite = (s: typeof suites[number]) =>
365
+ (s.tags ?? []).some(t => UNSAFE_TAGS.has(t));
366
+ const createdFiles: Array<{ file: string; suite: string; tests: number; safety: "safe" | "unsafe" }> = [];
367
+ const manifestEntries: RecordInput[] = [];
368
+ const inferredApi = inferApiName(options.output);
79
369
 
80
370
  for (const suite of suites) {
81
371
  const yaml = serializeSuite(suite);
82
372
  const fileName = `${suite.fileStem ?? suite.name}.yaml`;
83
373
  const filePath = join(options.output, fileName);
84
- await Bun.write(filePath, yaml);
85
- createdFiles.push({ file: filePath, suite: suite.name, tests: suite.tests.length });
86
- metaFiles[fileName] = buildFileMeta(suite, ZOND_VERSION);
87
- }
88
-
89
- // Write .zond-meta.json (merge with existing meta to preserve info about prior files)
90
- const existingMeta = await readMeta(options.output);
91
- const specContent = typeof doc === "object" ? JSON.stringify(decycleSchema(doc)) : String(doc);
92
- await writeMeta(options.output, {
93
- zondVersion: ZOND_VERSION,
94
- lastSyncedAt: new Date().toISOString(),
95
- specUrl: options.specPath,
96
- specHash: hashSpec(specContent),
97
- files: { ...(existingMeta?.files ?? {}), ...metaFiles },
98
- });
374
+ const header = autoGenHeader("zond generate", `zond generate --api <name> --output ${options.output}`);
375
+ await Bun.write(filePath, header + yaml);
376
+ createdFiles.push({
377
+ file: filePath,
378
+ suite: suite.name,
379
+ tests: suite.tests.length,
380
+ safety: isUnsafeSuite(suite) ? "unsafe" : "safe",
381
+ });
382
+ manifestEntries.push({
383
+ path: filePath,
384
+ by: "zond generate",
385
+ api: inferredApi,
386
+ category: "tests",
387
+ });
388
+ }
99
389
 
100
- // Generate .api-catalog.yaml (always uses full unfiltered endpoint list)
101
- const catalog = buildCatalog({
102
- endpoints: allEndpoints,
103
- securitySchemes,
104
- specSource: options.specPath,
105
- specHash: hashSpec(specContent),
106
- apiName,
107
- apiVersion,
108
- baseUrl,
109
- });
110
- await Bun.write(join(options.output, ".api-catalog.yaml"), serializeCatalog(catalog));
390
+ // TASK-157 (m-9 P1): generate no longer writes `.api-catalog.yaml` into
391
+ // options.output. The API-level catalog at `apis/<name>/.api-catalog.yaml`
392
+ // is the single source of truth — `zond add api` / `zond refresh-api`
393
+ // emit it.
111
394
 
112
395
  // Sync DB collection spec reference if one is registered for this output directory
113
396
  try {
@@ -121,8 +404,15 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
121
404
  // DB unavailable — not fatal
122
405
  }
123
406
 
124
- // Create .env.yaml with base_url and unresolved variables as placeholders
125
- const envPath = join(options.output, ".env.yaml");
407
+ // TASK-158 (m-9 P2): the API-level `apis/<name>/.env.yaml` is the only
408
+ // source of truth for runtime variables. We never write a duplicate
409
+ // `tests/.env.yaml` — it would silently override the API-level file via
410
+ // deeper-scope precedence, wiping the user's auth_token / FK ids on
411
+ // every `zond generate`. If the API-level file is missing, we create it
412
+ // there; if it already exists, we leave it alone (re-running generate
413
+ // never clobbers values the user filled in).
414
+ const envTargetDir = resolveApiRoot(options.output, baseUrl) ?? options.output;
415
+ const envPath = join(envTargetDir, ".env.yaml");
126
416
  const envFile = Bun.file(envPath);
127
417
  if (!(await envFile.exists())) {
128
418
  const unresolvedVars = new Set<string>();
@@ -135,11 +425,28 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
135
425
  lines.push(`${v}: "" # TODO: fill in`);
136
426
  }
137
427
  if (lines.length > 0) {
428
+ await mkdir(envTargetDir, { recursive: true });
138
429
  await Bun.write(envPath, lines.join("\n") + "\n");
139
430
  warnings.push(`Created ${envPath} with ${unresolvedVars.size} placeholder variable(s)`);
431
+ manifestEntries.push({
432
+ path: envPath,
433
+ by: "zond generate",
434
+ api: inferredApi,
435
+ category: "env",
436
+ });
140
437
  }
141
438
  }
142
439
 
440
+ // Record everything we wrote into .zond/manifest.json (TASK-156).
441
+ try {
442
+ const ws = findWorkspaceRoot();
443
+ if (!ws.fromFallback && manifestEntries.length > 0) {
444
+ recordGeneratedFiles(ws.root, manifestEntries);
445
+ }
446
+ } catch {
447
+ // Manifest is best-effort; never fail the generate command on it.
448
+ }
449
+
143
450
  // Validate generated files
144
451
  const validationErrors: string[] = [];
145
452
  try {
@@ -164,14 +471,32 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
164
471
  }, warnings));
165
472
  } else {
166
473
  printSuccess(`Generated ${suites.length} suite(s) with ${totalTests} test(s) in ${options.output}`);
167
- for (const f of createdFiles) {
168
- console.log(` ${f.file} (${f.tests} tests)`);
474
+ // ARV-15: split safe vs unsafe so the user can see at a glance which
475
+ // suites are read-only smoke and which will send writes/deletes.
476
+ const safeFiles = createdFiles.filter(f => f.safety === "safe");
477
+ const unsafeFiles = createdFiles.filter(f => f.safety === "unsafe");
478
+ if (safeFiles.length > 0 && unsafeFiles.length > 0) {
479
+ console.log(` Safe (read-only) — ${safeFiles.length} suite(s):`);
480
+ for (const f of safeFiles) console.log(` ${f.file} (${f.tests} tests)`);
481
+ console.log(` Unsafe (writes/deletes — hit live API) — ${unsafeFiles.length} suite(s):`);
482
+ for (const f of unsafeFiles) console.log(` ${f.file} (${f.tests} tests)`);
483
+ } else {
484
+ for (const f of createdFiles) {
485
+ console.log(` ${f.file} (${f.tests} tests)`);
486
+ }
169
487
  }
170
488
  if (warnings.length > 0) {
171
489
  for (const w of warnings) {
172
490
  console.log(` ⚠ ${w}`);
173
491
  }
174
492
  }
493
+ console.log("");
494
+ console.log("Next steps:");
495
+ console.log(" 1. Fill apis/<name>/.env.yaml with auth_token, real FK ids, verified emails, valid enums");
496
+ console.log(" (the fixture pack — without it, {{$randomString}} loses 5+ iterations to format-validation)");
497
+ console.log(" 2. zond run <output> --safe --report json # smoke (GET-only)");
498
+ console.log(` 3. zond run <output> --tag crud,setup --validate-schema --spec ${options.specPath} --report json`);
499
+ console.log(" (--validate-schema catches contract drift; recommended for every CRUD run)");
175
500
  }
176
501
 
177
502
  return 0;
@@ -185,3 +510,51 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
185
510
  return 2;
186
511
  }
187
512
  }
513
+
514
+ import type { Command } from "commander";
515
+ import { globalJson, resolveSpecArg } from "../resolve.ts";
516
+ import { getApi } from "../util/api-context.ts";
517
+
518
+ export function registerGenerate(program: Command): void {
519
+ program
520
+ .command("generate [spec]")
521
+ .description("Generate test suites from OpenAPI spec (overwrites existing suite files unconditionally — re-run is safe; user-edited tests are not preserved). Body fields are filled with `{{$random*}}` helpers (slug/email/url/uuid/…) — see `zond reference random-helpers` or docs/random-helpers.md for the full list (TASK-267).")
522
+ .option("--api <name>", "Use the registered API's spec (apis/<name>/spec.json)")
523
+ .option("--db <path>", "Path to SQLite database file")
524
+ .option("--output <dir>", "Output directory for generated test files (required unless --explain)")
525
+ .option("--tag <tag>", "Generate only for endpoints with this tag (accepts comma-separated list, e.g. --tag Releases,Events,Alerts — TASK-239)")
526
+ .option(
527
+ "--include <spec...>",
528
+ "ARV-9: keep only operations matching <selector>:<value>. Selectors: path:<regex>, method:<csv>, tag:<csv>, operation-id:<regex>. Repeat the flag for OR semantics.",
529
+ )
530
+ .option(
531
+ "--exclude <spec...>",
532
+ "ARV-9: drop operations matching <selector>:<value>. Same grammar as --include.",
533
+ )
534
+ .option("--uncovered-only", "Skip endpoints already covered by existing tests")
535
+ .option("--include-deprecated", "Generate suites for deprecated endpoints too (filtered out by default)")
536
+ .option("--explain", "Print the CRUD detection table (which resources became chain candidates and why) without writing files (TASK-139)")
537
+ .option("--force, --overwrite", "Accepted for compatibility — generate already overwrites by default (TASK-219). No-op today; will gate user-edited file overwrites in a future release.")
538
+ .action(async (specPos: string | undefined, opts, cmd: Command) => {
539
+ const resolved = resolveSpecArg(specPos, opts.api, opts.db);
540
+ if ("error" in resolved) { printError(resolved.error); process.exitCode = 2; return; }
541
+ if (!opts.explain && !opts.output) {
542
+ printError("--output <dir> is required (omit only when running with --explain).");
543
+ process.exitCode = 2;
544
+ return;
545
+ }
546
+ process.exitCode = await generateCommand({
547
+ specPath: resolved.spec,
548
+ output: opts.output ?? "",
549
+ tag: opts.tag,
550
+ uncoveredOnly: opts.uncoveredOnly === true,
551
+ includeDeprecated: opts.includeDeprecated === true,
552
+ explain: opts.explain === true,
553
+ force: opts.force === true || opts.overwrite === true,
554
+ json: globalJson(cmd),
555
+ include: Array.isArray(opts.include) ? opts.include : undefined,
556
+ exclude: Array.isArray(opts.exclude) ? opts.exclude : undefined,
557
+ apiName: getApi(cmd, opts),
558
+ });
559
+ });
560
+ }
@@ -0,0 +1,61 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import agentsTemplate from "./templates/agents.md" with { type: "text" };
5
+
6
+ export const START_MARKER = "<!-- zond:start -->";
7
+ export const END_MARKER = "<!-- zond:end -->";
8
+
9
+ export interface AgentsBlockResult {
10
+ path: string;
11
+ action: "created" | "updated" | "noop";
12
+ }
13
+
14
+ function blockBody(): string {
15
+ return agentsTemplate.trim();
16
+ }
17
+
18
+ function wrap(body: string): string {
19
+ return `${START_MARKER}\n${body}\n${END_MARKER}`;
20
+ }
21
+
22
+ const BLOCK_RE = new RegExp(
23
+ `${escapeRe(START_MARKER)}[\\s\\S]*?${escapeRe(END_MARKER)}`,
24
+ );
25
+
26
+ function escapeRe(s: string): string {
27
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28
+ }
29
+
30
+ /**
31
+ * Idempotently inserts (or updates) the zond instruction block in `<cwd>/AGENTS.md`.
32
+ *
33
+ * - Missing file → create with just the block.
34
+ * - File without markers → append block at the end (preceded by `\n\n---\n\n`).
35
+ * - File with existing markers → replace the body between them.
36
+ * - File whose existing block already matches → noop.
37
+ */
38
+ export function upsertAgentsBlock(cwd: string): AgentsBlockResult {
39
+ const path = join(cwd, "AGENTS.md");
40
+ const next = wrap(blockBody());
41
+
42
+ if (!existsSync(path)) {
43
+ writeFileSync(path, next + "\n", "utf-8");
44
+ return { path, action: "created" };
45
+ }
46
+
47
+ const current = readFileSync(path, "utf-8");
48
+
49
+ if (BLOCK_RE.test(current)) {
50
+ const updated = current.replace(BLOCK_RE, next);
51
+ if (updated === current) return { path, action: "noop" };
52
+ writeFileSync(path, updated, "utf-8");
53
+ return { path, action: "updated" };
54
+ }
55
+
56
+ // Append with separator
57
+ const sep = current.endsWith("\n") ? "\n" : "\n\n";
58
+ const updated = current + sep + "---\n\n" + next + "\n";
59
+ writeFileSync(path, updated, "utf-8");
60
+ return { path, action: "updated" };
61
+ }