@kirrosh/zond 0.22.0 → 0.26.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 (282) hide show
  1. package/CHANGELOG.md +811 -0
  2. package/README.md +59 -6
  3. package/package.json +9 -7
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/argv.ts +122 -0
  6. package/src/cli/commands/add-api.ts +146 -0
  7. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  8. package/src/cli/commands/api/annotate/index.ts +880 -0
  9. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  10. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  11. package/src/cli/commands/api/annotate/pagination.ts +64 -0
  12. package/src/cli/commands/api/annotate/prompts.ts +220 -0
  13. package/src/cli/commands/api/annotate/readback.ts +58 -0
  14. package/src/cli/commands/api/annotate/resources.ts +91 -0
  15. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  16. package/src/cli/commands/audit.ts +786 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +361 -0
  19. package/src/cli/commands/checks.ts +1072 -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 +236 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +823 -132
  25. package/src/cli/commands/db.ts +486 -12
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1356 -0
  28. package/src/cli/commands/doctor.ts +661 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +438 -47
  31. package/src/cli/commands/init/bootstrap.ts +34 -2
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +99 -3
  34. package/src/cli/commands/init/templates/agents.md +77 -64
  35. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  36. package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
  37. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +802 -125
  40. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  41. package/src/cli/commands/prepare-fixtures.ts +97 -0
  42. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  43. package/src/cli/commands/probe/mass-assignment.ts +594 -0
  44. package/src/cli/commands/probe/security.ts +537 -0
  45. package/src/cli/commands/probe/static.ts +255 -0
  46. package/src/cli/commands/probe/webhooks.ts +163 -0
  47. package/src/cli/commands/probe.ts +535 -0
  48. package/src/cli/commands/reference.ts +87 -0
  49. package/src/cli/commands/refresh-api.ts +227 -0
  50. package/src/cli/commands/remove-api.ts +150 -0
  51. package/src/cli/commands/report-bundle.ts +310 -0
  52. package/src/cli/commands/report.ts +241 -0
  53. package/src/cli/commands/request.ts +495 -4
  54. package/src/cli/commands/run.ts +870 -53
  55. package/src/cli/commands/schema-from-runs.ts +128 -0
  56. package/src/cli/commands/secrets.ts +133 -0
  57. package/src/cli/commands/session.ts +244 -0
  58. package/src/cli/commands/use.ts +18 -1
  59. package/src/cli/index.ts +20 -3
  60. package/src/cli/json-envelope.ts +92 -3
  61. package/src/cli/json-schemas.ts +314 -0
  62. package/src/cli/output.ts +17 -1
  63. package/src/cli/program.ts +199 -635
  64. package/src/cli/resolve.ts +105 -0
  65. package/src/cli/safe-live.ts +24 -0
  66. package/src/cli/status-filter.ts +114 -0
  67. package/src/cli/util/api-context.ts +85 -0
  68. package/src/cli/version.ts +5 -0
  69. package/src/core/audit/persist.ts +183 -0
  70. package/src/core/checks/budget.ts +59 -0
  71. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  72. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  73. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  74. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  75. package/src/core/checks/checks/cross_call_references.ts +147 -0
  76. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  77. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  78. package/src/core/checks/checks/idempotency_replay.ts +242 -0
  79. package/src/core/checks/checks/ignored_auth.ts +254 -0
  80. package/src/core/checks/checks/index.ts +68 -0
  81. package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
  82. package/src/core/checks/checks/missing_required_header.ts +40 -0
  83. package/src/core/checks/checks/negative_data_rejection.ts +148 -0
  84. package/src/core/checks/checks/not_a_server_error.ts +35 -0
  85. package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
  86. package/src/core/checks/checks/pagination_invariants.ts +419 -0
  87. package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
  88. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  89. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  90. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  91. package/src/core/checks/checks/status_code_conformance.ts +132 -0
  92. package/src/core/checks/checks/unsupported_method.ts +63 -0
  93. package/src/core/checks/checks/use_after_free.ts +78 -0
  94. package/src/core/checks/index.ts +30 -0
  95. package/src/core/checks/mode.ts +82 -0
  96. package/src/core/checks/recommended-action.ts +68 -0
  97. package/src/core/checks/registry.ts +78 -0
  98. package/src/core/checks/runner.ts +1461 -0
  99. package/src/core/checks/sarif.ts +230 -0
  100. package/src/core/checks/spec-findings.ts +308 -0
  101. package/src/core/checks/stateful.ts +121 -0
  102. package/src/core/checks/types.ts +305 -0
  103. package/src/core/checks/zond-extensions.ts +73 -0
  104. package/src/core/classifier/recommended-action.ts +251 -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 +216 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +293 -59
  110. package/src/core/diagnostics/failure-class.ts +140 -0
  111. package/src/core/diagnostics/failure-hints.ts +88 -89
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +155 -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 +38 -19
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/data-factory.ts +586 -22
  124. package/src/core/generator/describe.ts +1 -1
  125. package/src/core/generator/fixtures-builder.ts +332 -0
  126. package/src/core/generator/index.ts +5 -5
  127. package/src/core/generator/openapi-reader.ts +135 -7
  128. package/src/core/generator/path-param-disambig.ts +140 -0
  129. package/src/core/generator/resources-builder.ts +898 -0
  130. package/src/core/generator/schema-utils.ts +33 -3
  131. package/src/core/generator/serializer.ts +103 -13
  132. package/src/core/generator/suite-generator.ts +583 -122
  133. package/src/core/generator/types.ts +14 -0
  134. package/src/core/identity/identity-file.ts +0 -0
  135. package/src/core/lint/affects.ts +28 -0
  136. package/src/core/lint/config.ts +96 -0
  137. package/src/core/lint/format.ts +42 -0
  138. package/src/core/lint/index.ts +94 -0
  139. package/src/core/lint/reporter.ts +128 -0
  140. package/src/core/lint/rules/consistency.ts +158 -0
  141. package/src/core/lint/rules/heuristics.ts +97 -0
  142. package/src/core/lint/rules/strictness.ts +109 -0
  143. package/src/core/lint/types.ts +96 -0
  144. package/src/core/lint/walker.ts +248 -0
  145. package/src/core/meta/meta-store.ts +6 -73
  146. package/src/core/output/README.md +73 -0
  147. package/src/core/output/index.ts +13 -0
  148. package/src/core/output/run.ts +91 -0
  149. package/src/core/output/types.ts +122 -0
  150. package/src/core/parser/dynamic-values.ts +160 -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 +61 -0
  159. package/src/core/probe/mass-assignment/classify.ts +175 -0
  160. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  161. package/src/core/probe/mass-assignment/digest.ts +114 -0
  162. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  163. package/src/core/probe/mass-assignment/regression.ts +141 -0
  164. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  165. package/src/core/probe/mass-assignment/types.ts +135 -0
  166. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  167. package/src/core/probe/mass-assignment-probe.ts +27 -0
  168. package/src/core/probe/mass-assignment-template.ts +240 -0
  169. package/src/core/probe/method-probe.ts +43 -76
  170. package/src/core/probe/method-shared.ts +69 -0
  171. package/src/core/probe/negative-probe.ts +183 -149
  172. package/src/core/probe/orphan-tracker.ts +188 -0
  173. package/src/core/probe/path-discovery.ts +439 -0
  174. package/src/core/probe/probe-harness.ts +119 -0
  175. package/src/core/probe/registry.ts +89 -0
  176. package/src/core/probe/runner.ts +136 -0
  177. package/src/core/probe/security/baseline.ts +174 -0
  178. package/src/core/probe/security/classify.ts +341 -0
  179. package/src/core/probe/security/cleanup.ts +125 -0
  180. package/src/core/probe/security/detectors.ts +71 -0
  181. package/src/core/probe/security/digest.ts +104 -0
  182. package/src/core/probe/security/orchestrator.ts +398 -0
  183. package/src/core/probe/security/regression.ts +103 -0
  184. package/src/core/probe/security/types.ts +151 -0
  185. package/src/core/probe/security-probe-class.ts +207 -0
  186. package/src/core/probe/security-probe.ts +32 -0
  187. package/src/core/probe/shared.ts +531 -0
  188. package/src/core/probe/static-probe-class.ts +125 -0
  189. package/src/core/probe/types.ts +165 -0
  190. package/src/core/probe/verdict-aggregator.ts +33 -0
  191. package/src/core/probe/webhooks-probe.ts +282 -0
  192. package/src/core/reporter/console.ts +41 -2
  193. package/src/core/reporter/index.ts +2 -3
  194. package/src/core/reporter/json.ts +11 -1
  195. package/src/core/reporter/junit.ts +27 -12
  196. package/src/core/reporter/ndjson.ts +37 -0
  197. package/src/core/reporter/types.ts +3 -0
  198. package/src/core/runner/assertions.ts +59 -2
  199. package/src/core/runner/async-pool.ts +108 -0
  200. package/src/core/runner/auth-path.ts +8 -0
  201. package/src/core/runner/ci-context.ts +72 -0
  202. package/src/core/runner/executor.ts +265 -36
  203. package/src/core/runner/form-encode.ts +41 -0
  204. package/src/core/runner/http-client.ts +112 -2
  205. package/src/core/runner/learn-drift.ts +293 -0
  206. package/src/core/runner/preflight-vars.ts +153 -0
  207. package/src/core/runner/progress-tracker.ts +73 -0
  208. package/src/core/runner/rate-limiter.ts +87 -33
  209. package/src/core/runner/run-kind.ts +45 -0
  210. package/src/core/runner/schema-validator.ts +308 -0
  211. package/src/core/runner/send-request.ts +158 -20
  212. package/src/core/runner/types.ts +44 -0
  213. package/src/core/secrets/registry.ts +164 -0
  214. package/src/core/secrets/secrets-file.ts +115 -0
  215. package/src/core/selectors/operation-filter.ts +144 -0
  216. package/src/core/setup-api.ts +457 -20
  217. package/src/core/severity/category.ts +94 -0
  218. package/src/core/severity/index.ts +58 -0
  219. package/src/core/spec/infer-schema.ts +102 -0
  220. package/src/core/spec/layers.ts +154 -0
  221. package/src/core/spec/merge-specs.ts +156 -0
  222. package/src/core/spec/schema-from-runs.ts +117 -0
  223. package/src/core/spec/schema-overlay.ts +130 -0
  224. package/src/core/util/ajv.ts +13 -0
  225. package/src/core/util/format-eta.ts +21 -0
  226. package/src/core/util/headers.ts +9 -0
  227. package/src/core/util/url.ts +24 -0
  228. package/src/core/utils.ts +5 -1
  229. package/src/core/workspace/config.ts +129 -0
  230. package/src/core/workspace/fixture-gap-report.ts +84 -0
  231. package/src/core/workspace/fixture-gaps.ts +71 -0
  232. package/src/core/workspace/manifest.ts +283 -0
  233. package/src/core/workspace/output-rotation.ts +62 -0
  234. package/src/core/workspace/root.ts +13 -11
  235. package/src/core/workspace/triage-path.ts +87 -0
  236. package/src/db/lint-runs.ts +47 -0
  237. package/src/db/migrate.ts +128 -0
  238. package/src/db/migrations/0001_run_kind.sql +25 -0
  239. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  240. package/src/db/migrations/sql.d.ts +4 -0
  241. package/src/db/queries/collections.ts +133 -0
  242. package/src/db/queries/coverage.ts +9 -0
  243. package/src/db/queries/dashboard.ts +59 -0
  244. package/src/db/queries/results.ts +216 -0
  245. package/src/db/queries/runs.ts +289 -0
  246. package/src/db/queries/sessions.ts +42 -0
  247. package/src/db/queries/settings.ts +28 -0
  248. package/src/db/queries/types.ts +172 -0
  249. package/src/db/queries.ts +75 -802
  250. package/src/db/schema.ts +178 -50
  251. package/src/cli/commands/export.ts +0 -144
  252. package/src/cli/commands/guide.ts +0 -127
  253. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  254. package/src/cli/commands/probe-methods.ts +0 -108
  255. package/src/cli/commands/probe-validation.ts +0 -124
  256. package/src/cli/commands/serve.ts +0 -114
  257. package/src/cli/commands/sync.ts +0 -268
  258. package/src/cli/commands/update.ts +0 -189
  259. package/src/cli/commands/validate.ts +0 -34
  260. package/src/core/diagnostics/render-md.ts +0 -112
  261. package/src/core/exporter/postman.ts +0 -963
  262. package/src/core/generator/guide-builder.ts +0 -253
  263. package/src/core/meta/types.ts +0 -19
  264. package/src/core/parser/index.ts +0 -21
  265. package/src/core/runner/execute-run.ts +0 -132
  266. package/src/core/runner/index.ts +0 -12
  267. package/src/core/sync/spec-differ.ts +0 -38
  268. package/src/web/data/collection-state.ts +0 -362
  269. package/src/web/routes/api.ts +0 -314
  270. package/src/web/routes/dashboard.ts +0 -350
  271. package/src/web/routes/runs.ts +0 -64
  272. package/src/web/schemas.ts +0 -121
  273. package/src/web/server.ts +0 -134
  274. package/src/web/static/htmx.min.cjs +0 -1
  275. package/src/web/static/style.css +0 -1148
  276. package/src/web/views/endpoints-tab.ts +0 -174
  277. package/src/web/views/explorer-tab.ts +0 -402
  278. package/src/web/views/health-strip.ts +0 -92
  279. package/src/web/views/layout.ts +0 -48
  280. package/src/web/views/results.ts +0 -210
  281. package/src/web/views/runs-tab.ts +0 -126
  282. 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,85 @@ 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/ARV-361: overwrite hand-edited suite files (those whose
72
+ * auto-gen header was removed). Default false = preserve them; files that
73
+ * still carry the header are regenerated regardless. */
74
+ force?: boolean;
29
75
  json?: boolean;
76
+ /** ARV-9 unified filter: path:<regex> / method:<csv> / tag:<csv> /
77
+ * operation-id:<regex>. Multiple flags combine with OR; --exclude
78
+ * always removes. Stacks with --tag for back-compat. */
79
+ include?: string[];
80
+ exclude?: string[];
81
+ /** ARV-212 (R13/F16, R14): the explicit --api name. Lets generate read
82
+ * apis/<name>/.env.yaml directly even when --output points outside the
83
+ * apis/<name>/ tree (e.g. /tmp/<scratch>), where resolveApiRoot /
84
+ * inferApiName cannot recover the name from the path. */
85
+ apiName?: string;
86
+ /** ARV-212: explicit override for apis/<name>/ root. Caller pre-resolved
87
+ * it through the DB (base_dir column) for the case where the API was
88
+ * registered in a non-standard layout. */
89
+ apiDir?: string;
30
90
  }
31
91
 
32
92
  export async function generateCommand(options: GenerateOptions): Promise<number> {
@@ -35,9 +95,97 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
35
95
  const allEndpoints = extractEndpoints(doc);
36
96
  let endpoints = allEndpoints;
37
97
  const securitySchemes = extractSecuritySchemes(doc);
98
+
99
+ // --explain short-circuits: print the CRUD detection table and exit.
100
+ if (options.explain) {
101
+ let scope = endpoints;
102
+ if (options.tag) scope = filterByTag(scope, options.tag);
103
+ const { groups, diagnostics } = detectCrudGroupsWithDiagnostics(scope);
104
+ // Per-field body sources (TASK-269) — same scope as the table below.
105
+ const bodyFieldSources = scope
106
+ .filter(ep => ep.requestBodySchema && (ep.requestBodySchema as any).type === "object" &&
107
+ (ep.requestBodySchema as any).properties)
108
+ .map(ep => {
109
+ const props = (ep.requestBodySchema as any).properties as Record<string, any>;
110
+ const fields = Object.entries(props)
111
+ .filter(([k, s]) => !(s.readOnly === true) && k !== "id")
112
+ .map(([key, s]) => ({
113
+ field: key,
114
+ type: Array.isArray(s.type)
115
+ ? (s.type as string[]).find(x => x !== "null") ?? "any"
116
+ : (s.type ?? "any"),
117
+ value: generateFromSchema(s, key),
118
+ source: classifyFieldSource(s, key),
119
+ }));
120
+ return { method: ep.method.toUpperCase(), path: ep.path, fields };
121
+ })
122
+ .filter(e => e.fields.length > 0);
123
+
124
+ if (options.json) {
125
+ printJson(jsonOk("generate", {
126
+ mode: "explain",
127
+ totalCandidates: diagnostics.length,
128
+ chains: groups.length,
129
+ diagnostics,
130
+ bodyFieldSources,
131
+ }));
132
+ } else {
133
+ if (diagnostics.length === 0) {
134
+ console.log("No POST endpoints in scope — nothing to evaluate.");
135
+ } else {
136
+ const chains = diagnostics.filter(d => d.verdict === "chain").length;
137
+ console.log(`CRUD detection: ${chains}/${diagnostics.length} POST endpoints became chain candidates.\n`);
138
+ const headers = ["resource", "post", "get/{id}", "put/patch", "delete", "list", "verdict", "reason"];
139
+ const rows = diagnostics.map(d => [
140
+ d.resource,
141
+ d.postPath,
142
+ d.hasGetById ? "✓" : "—",
143
+ d.hasUpdate ? "✓" : "—",
144
+ d.hasDelete ? "✓" : "—",
145
+ d.hasList ? "✓" : "—",
146
+ d.verdict,
147
+ d.reason,
148
+ ]);
149
+ const widths = headers.map((h, i) =>
150
+ Math.max(h.length, ...rows.map(r => r[i]!.length)),
151
+ );
152
+ const fmt = (cells: string[]) =>
153
+ cells.map((c, i) => c.padEnd(widths[i]!)).join(" ");
154
+ console.log(fmt(headers));
155
+ console.log(widths.map(w => "─".repeat(w)).join(" "));
156
+ for (const row of rows) console.log(fmt(row));
157
+ }
158
+
159
+ // TASK-269: per-field provenance for endpoints carrying a request
160
+ // body. Helps debug "why did the API 400 on field X?" without
161
+ // re-running with --json and inspecting the generated suite.
162
+ if (bodyFieldSources.length > 0) {
163
+ console.log("");
164
+ console.log("Body field sources:");
165
+ for (const ep of bodyFieldSources) {
166
+ console.log(` ${ep.method} ${ep.path}`);
167
+ const fHeaders = ["field", "type", "value", "source"];
168
+ const rows2 = ep.fields.map(f => [
169
+ f.field,
170
+ String(f.type),
171
+ typeof f.value === "string" ? f.value : JSON.stringify(f.value),
172
+ `[${f.source}]`,
173
+ ]);
174
+ const fAll = [fHeaders, ...rows2];
175
+ const fWidths = fHeaders.map((h, i) =>
176
+ Math.max(h.length, ...fAll.map(r => r[i]!.length)),
177
+ );
178
+ const ffmt = (cells: string[]) =>
179
+ cells.map((c, i) => c.padEnd(fWidths[i]!)).join(" ");
180
+ console.log(" " + ffmt(fHeaders));
181
+ console.log(" " + fWidths.map(w => "─".repeat(w)).join(" "));
182
+ for (const r of rows2) console.log(" " + ffmt(r));
183
+ }
184
+ }
185
+ }
186
+ return 0;
187
+ }
38
188
  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
189
  const warnings: string[] = [];
42
190
 
43
191
  // Filter to uncovered only
@@ -51,62 +199,213 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
51
199
  }
52
200
  }
53
201
 
202
+ // ARV-9: unified --include/--exclude filter (applied before --tag so
203
+ // --tag stays a thin alias when both are passed; usually only one is).
204
+ if (options.include?.length || options.exclude?.length) {
205
+ const compiled = compileOperationFilter({ includes: options.include, excludes: options.exclude });
206
+ if (compiled.errors.length > 0) {
207
+ const message = compiled.errors.join("\n");
208
+ if (options.json) printJson(jsonOk("generate", { files: [], message }, compiled.errors));
209
+ else printError(message);
210
+ return 2;
211
+ }
212
+ endpoints = endpoints.filter(compiled.filter);
213
+ }
214
+
54
215
  // Filter by tag
216
+ let tagDiagnostic: string | undefined;
55
217
  if (options.tag) {
218
+ const beforeTag = endpoints;
56
219
  endpoints = filterByTag(endpoints, options.tag);
220
+ // TASK-232: when --tag matches nothing, show the available tags so the
221
+ // user can tell "typo" apart from "spec really has no endpoints here".
222
+ // Cheap nearest-match: pick the first tag containing or contained-by the
223
+ // requested string (case-insensitive); covers "Members" → "Member".
224
+ if (endpoints.length === 0 && beforeTag.length > 0) {
225
+ const available = collectTags(beforeTag);
226
+ const wanted = options.tag.trim().toLowerCase();
227
+ const closest = available.find(t => {
228
+ const tl = t.toLowerCase();
229
+ return tl.includes(wanted) || wanted.includes(tl);
230
+ });
231
+ const hint = closest ? ` (did you mean ${closest}?)` : "";
232
+ const list = available.length > 0 ? available.join(", ") : "(none)";
233
+ tagDiagnostic = `No endpoints with tag '${options.tag}'${hint}. Available tags: ${list}`;
234
+ }
57
235
  }
58
236
 
59
237
  if (endpoints.length === 0) {
238
+ const message = tagDiagnostic ?? "No endpoints to generate tests for";
60
239
  if (options.json) {
61
- printJson(jsonOk("generate", { files: [], message: "No endpoints to generate tests for" }, warnings));
240
+ printJson(jsonOk("generate", { files: [], message }, warnings));
62
241
  } else {
63
- console.log("No endpoints to generate tests for.");
242
+ console.log(`${message}.`);
64
243
  }
65
244
  return 0;
66
245
  }
67
246
 
247
+ // Count deprecated endpoints before generateSuites filters them — we
248
+ // surface the count as a warning so deprecated-by-design and
249
+ // accidentally-dropped look different in stdout.
250
+ const deprecatedSkipped = options.includeDeprecated
251
+ ? []
252
+ : endpoints.filter(ep => ep.deprecated).map(ep => `${ep.method} ${ep.path}`);
253
+
254
+ // ARV-212 (R13/F16, R14): peek at .env.yaml *before* generating suites so
255
+ // we can pass `defaultAuthVar` into generateSuites when the spec has no
256
+ // securitySchemes but the workspace is wired for Bearer auth (the
257
+ // ARV-201 seed in setup-api.ts). Without this, GitHub-style suites go
258
+ // unauth and brick on the first rate-limited 60 requests.
259
+ //
260
+ // R14 fix: do NOT rely on resolveApiRoot(options.output) — when the
261
+ // user passes --output to a scratch directory outside apis/<name>/
262
+ // (e.g. /tmp/foo), the resolver returns undefined and we fall back to
263
+ // the output dir, which has no .env.yaml. Prefer the explicit --api
264
+ // name to construct apis/<name>/ inside the workspace.
265
+ const envForWarnings: Record<string, unknown> = {};
266
+ try {
267
+ let envDir: string | undefined = options.apiDir;
268
+ if (!envDir && options.apiName) {
269
+ const ws = findWorkspaceRoot();
270
+ envDir = resolvePath(ws.root, "apis", options.apiName);
271
+ }
272
+ if (!envDir) envDir = resolveApiRoot(options.output, baseUrl) ?? options.output;
273
+ Object.assign(envForWarnings, await loadEnvironment(undefined, envDir));
274
+ } catch { /* env load failures stay silent — original behaviour for missing files */ }
275
+
276
+ let defaultAuthVar: string | undefined;
277
+ if (securitySchemes.length === 0 && "auth_token" in envForWarnings) {
278
+ // Presence-not-value: an empty .secrets.yaml.auth_token resolves to ""
279
+ // here, but the .env.yaml wiring is what matters. Once the user fills
280
+ // .secrets.yaml the generated suite picks up the Bearer header without
281
+ // a regenerate.
282
+ defaultAuthVar = "auth_token";
283
+ }
284
+
68
285
  // Generate suites
69
- const suites = generateSuites({ endpoints, securitySchemes });
286
+ const suites = generateSuites({
287
+ endpoints,
288
+ securitySchemes,
289
+ specPath: options.specPath,
290
+ includeDeprecated: options.includeDeprecated,
291
+ defaultAuthVar,
292
+ });
293
+
294
+ const missingPathParams = new Set<string>();
295
+ let endpointsMissingPathExamples = 0;
296
+ for (const ep of endpoints) {
297
+ let epHadMiss = false;
298
+ for (const p of ep.parameters) {
299
+ if (p.in !== "path" || !p.required) continue;
300
+ const hasExample =
301
+ p.example !== undefined ||
302
+ (p.schema && (p.schema as any).example !== undefined) ||
303
+ (p.schema && (p.schema as any).default !== undefined);
304
+ const filledInEnv = (() => {
305
+ const v = envForWarnings[p.name];
306
+ return typeof v === "string" && v.length > 0 && !v.startsWith("{{");
307
+ })();
308
+ if (!hasExample && !filledInEnv) {
309
+ missingPathParams.add(p.name);
310
+ epHadMiss = true;
311
+ }
312
+ }
313
+ if (epHadMiss) endpointsMissingPathExamples++;
314
+ }
315
+ if (missingPathParams.size > 0) {
316
+ const sample = [...missingPathParams].sort().slice(0, 3).join(", ");
317
+ const more = missingPathParams.size > 3 ? `, +${missingPathParams.size - 3} more` : "";
318
+ warnings.push(
319
+ `${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`,
320
+ );
321
+ }
322
+
323
+ if (deprecatedSkipped.length > 0) {
324
+ const head = deprecatedSkipped.slice(0, 3).join(", ");
325
+ const more = deprecatedSkipped.length > 3 ? `, +${deprecatedSkipped.length - 3} more` : "";
326
+ warnings.push(
327
+ `Skipped ${deprecatedSkipped.length} deprecated endpoint(s): ${head}${more} — pass --include-deprecated to include`,
328
+ );
329
+ }
330
+
331
+ // ARV-15: warn when in-scope endpoints will create/modify real resources.
332
+ // POST/PUT/PATCH/DELETE on a live API send real traffic — e.g. an
333
+ // email API's `POST /emails` literally sends mail; deleting a record
334
+ // is irreversible.
335
+ // Generation is harmless (just YAML), but `zond run` against the suite
336
+ // is not, so the warning fires here so the user sees it before they grep
337
+ // the output for what to run next.
338
+ const unsafeOps = endpoints.filter(
339
+ ep => ep.method !== "GET" && ep.method !== "HEAD" && ep.method !== "OPTIONS",
340
+ );
341
+ if (unsafeOps.length > 0) {
342
+ const byMethod = new Map<string, number>();
343
+ for (const ep of unsafeOps) {
344
+ byMethod.set(ep.method, (byMethod.get(ep.method) ?? 0) + 1);
345
+ }
346
+ const breakdown = [...byMethod.entries()]
347
+ .sort(([a], [b]) => a.localeCompare(b))
348
+ .map(([m, n]) => `${n} ${m}`)
349
+ .join(", ");
350
+ warnings.push(
351
+ `${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.`,
352
+ );
353
+ }
70
354
 
71
355
  // Ensure output directory exists
72
356
  await mkdir(options.output, { recursive: true });
73
357
 
74
358
  // 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> = {};
359
+ // ARV-15: tag each created file as safe/unsafe based on suite tags so the
360
+ // stdout summary can group them and the user can tell at a glance which
361
+ // suites send writes/deletes vs. read-only smoke.
362
+ const UNSAFE_TAGS = new Set(["unsafe", "crud", "system", "reset", "cleanup"]);
363
+ const isUnsafeSuite = (s: typeof suites[number]) =>
364
+ (s.tags ?? []).some(t => UNSAFE_TAGS.has(t));
365
+ const createdFiles: Array<{ file: string; suite: string; tests: number; safety: "safe" | "unsafe" }> = [];
366
+ const manifestEntries: RecordInput[] = [];
367
+ const inferredApi = inferApiName(options.output);
79
368
 
80
369
  for (const suite of suites) {
81
370
  const yaml = serializeSuite(suite);
82
371
  const fileName = `${suite.fileStem ?? suite.name}.yaml`;
83
372
  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
- specHash: hashSpec(specContent),
96
- files: { ...(existingMeta?.files ?? {}), ...metaFiles },
97
- });
373
+ // m-24 (ARV-361): don't silently clobber agent-edited suites. The
374
+ // auto-gen header already promises "drop the header (or rename) to
375
+ // keep changes" — honour it deterministically: a file whose first line
376
+ // is no longer the auto-gen marker is agent-owned, so preserve it
377
+ // unless --force. Files that still carry the header get overwritten,
378
+ // exactly as the header warns. Makes the previously no-op --force real.
379
+ if (!options.force && existsSync(filePath)) {
380
+ // Small yaml whole-file read is fine; we only need the first line.
381
+ const firstLine = (await Bun.file(filePath).text()).split("\n", 1)[0] ?? "";
382
+ if (!firstLine.startsWith("# Auto-generated by")) {
383
+ warnings.push(
384
+ `Preserved ${fileName} — no auto-gen header, treated as hand-edited. Pass --force to overwrite.`,
385
+ );
386
+ continue;
387
+ }
388
+ }
389
+ const header = autoGenHeader("zond generate", `zond generate --api <name> --output ${options.output}`);
390
+ await Bun.write(filePath, header + yaml);
391
+ createdFiles.push({
392
+ file: filePath,
393
+ suite: suite.name,
394
+ tests: suite.tests.length,
395
+ safety: isUnsafeSuite(suite) ? "unsafe" : "safe",
396
+ });
397
+ manifestEntries.push({
398
+ path: filePath,
399
+ by: "zond generate",
400
+ api: inferredApi,
401
+ category: "tests",
402
+ });
403
+ }
98
404
 
99
- // Generate .api-catalog.yaml (always uses full unfiltered endpoint list)
100
- const catalog = buildCatalog({
101
- endpoints: allEndpoints,
102
- securitySchemes,
103
- specSource: options.specPath,
104
- specHash: hashSpec(specContent),
105
- apiName,
106
- apiVersion,
107
- baseUrl,
108
- });
109
- await Bun.write(join(options.output, ".api-catalog.yaml"), serializeCatalog(catalog));
405
+ // TASK-157 (m-9 P1): generate no longer writes `.api-catalog.yaml` into
406
+ // options.output. The API-level catalog at `apis/<name>/.api-catalog.yaml`
407
+ // is the single source of truth — `zond add api` / `zond refresh-api`
408
+ // emit it.
110
409
 
111
410
  // Sync DB collection spec reference if one is registered for this output directory
112
411
  try {
@@ -120,8 +419,15 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
120
419
  // DB unavailable — not fatal
121
420
  }
122
421
 
123
- // Create .env.yaml with base_url and unresolved variables as placeholders
124
- const envPath = join(options.output, ".env.yaml");
422
+ // TASK-158 (m-9 P2): the API-level `apis/<name>/.env.yaml` is the only
423
+ // source of truth for runtime variables. We never write a duplicate
424
+ // `tests/.env.yaml` — it would silently override the API-level file via
425
+ // deeper-scope precedence, wiping the user's auth_token / FK ids on
426
+ // every `zond generate`. If the API-level file is missing, we create it
427
+ // there; if it already exists, we leave it alone (re-running generate
428
+ // never clobbers values the user filled in).
429
+ const envTargetDir = resolveApiRoot(options.output, baseUrl) ?? options.output;
430
+ const envPath = join(envTargetDir, ".env.yaml");
125
431
  const envFile = Bun.file(envPath);
126
432
  if (!(await envFile.exists())) {
127
433
  const unresolvedVars = new Set<string>();
@@ -134,11 +440,28 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
134
440
  lines.push(`${v}: "" # TODO: fill in`);
135
441
  }
136
442
  if (lines.length > 0) {
443
+ await mkdir(envTargetDir, { recursive: true });
137
444
  await Bun.write(envPath, lines.join("\n") + "\n");
138
445
  warnings.push(`Created ${envPath} with ${unresolvedVars.size} placeholder variable(s)`);
446
+ manifestEntries.push({
447
+ path: envPath,
448
+ by: "zond generate",
449
+ api: inferredApi,
450
+ category: "env",
451
+ });
139
452
  }
140
453
  }
141
454
 
455
+ // Record everything we wrote into .zond/manifest.json (TASK-156).
456
+ try {
457
+ const ws = findWorkspaceRoot();
458
+ if (!ws.fromFallback && manifestEntries.length > 0) {
459
+ recordGeneratedFiles(ws.root, manifestEntries);
460
+ }
461
+ } catch {
462
+ // Manifest is best-effort; never fail the generate command on it.
463
+ }
464
+
142
465
  // Validate generated files
143
466
  const validationErrors: string[] = [];
144
467
  try {
@@ -163,14 +486,32 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
163
486
  }, warnings));
164
487
  } else {
165
488
  printSuccess(`Generated ${suites.length} suite(s) with ${totalTests} test(s) in ${options.output}`);
166
- for (const f of createdFiles) {
167
- console.log(` ${f.file} (${f.tests} tests)`);
489
+ // ARV-15: split safe vs unsafe so the user can see at a glance which
490
+ // suites are read-only smoke and which will send writes/deletes.
491
+ const safeFiles = createdFiles.filter(f => f.safety === "safe");
492
+ const unsafeFiles = createdFiles.filter(f => f.safety === "unsafe");
493
+ if (safeFiles.length > 0 && unsafeFiles.length > 0) {
494
+ console.log(` Safe (read-only) — ${safeFiles.length} suite(s):`);
495
+ for (const f of safeFiles) console.log(` ${f.file} (${f.tests} tests)`);
496
+ console.log(` Unsafe (writes/deletes — hit live API) — ${unsafeFiles.length} suite(s):`);
497
+ for (const f of unsafeFiles) console.log(` ${f.file} (${f.tests} tests)`);
498
+ } else {
499
+ for (const f of createdFiles) {
500
+ console.log(` ${f.file} (${f.tests} tests)`);
501
+ }
168
502
  }
169
503
  if (warnings.length > 0) {
170
504
  for (const w of warnings) {
171
505
  console.log(` ⚠ ${w}`);
172
506
  }
173
507
  }
508
+ console.log("");
509
+ console.log("Next steps:");
510
+ console.log(" 1. Fill apis/<name>/.env.yaml with auth_token, real FK ids, verified emails, valid enums");
511
+ console.log(" (the fixture pack — without it, {{$randomString}} loses 5+ iterations to format-validation)");
512
+ console.log(" 2. zond run <output> --safe --report json # smoke (GET-only)");
513
+ console.log(` 3. zond run <output> --tag crud,setup --validate-schema --spec ${options.specPath} --report json`);
514
+ console.log(" (--validate-schema catches contract drift; recommended for every CRUD run)");
174
515
  }
175
516
 
176
517
  return 0;
@@ -179,8 +520,58 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
179
520
  if (options.json) {
180
521
  printJson(jsonError("generate", [message]));
181
522
  } else {
182
- printError(message);
523
+ // ARV-203: pass err so ZOND_DEBUG=1 dumps the stack and the user
524
+ // gets a hint about the env var when running without it.
525
+ printError(message, err);
183
526
  }
184
527
  return 2;
185
528
  }
186
529
  }
530
+
531
+ import type { Command } from "commander";
532
+ import { globalJson, resolveSpecArg } from "../resolve.ts";
533
+ import { getApi } from "../util/api-context.ts";
534
+
535
+ export function registerGenerate(program: Command): void {
536
+ program
537
+ .command("generate [spec]")
538
+ .description("Generate test suites from OpenAPI spec. Re-run regenerates files that still carry the auto-gen header; hand-edited suites (header removed) are preserved unless --force (ARV-361). 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).")
539
+ .option("--api <name>", "Use the registered API's spec (apis/<name>/spec.json)")
540
+ .option("--db <path>", "Path to SQLite database file")
541
+ .option("--output <dir>", "Output directory for generated test files (required unless --explain)")
542
+ .option("--tag <tag>", "Generate only for endpoints with this tag (accepts comma-separated list, e.g. --tag Releases,Events,Alerts — TASK-239)")
543
+ .option(
544
+ "--include <spec...>",
545
+ "ARV-9: keep only operations matching <selector>:<value>. Selectors: path:<regex>, method:<csv>, tag:<csv>, operation-id:<regex>. Repeat the flag for OR semantics.",
546
+ )
547
+ .option(
548
+ "--exclude <spec...>",
549
+ "ARV-9: drop operations matching <selector>:<value>. Same grammar as --include.",
550
+ )
551
+ .option("--uncovered-only", "Skip endpoints already covered by existing tests")
552
+ .option("--include-deprecated", "Generate suites for deprecated endpoints too (filtered out by default)")
553
+ .option("--explain", "Print the CRUD detection table (which resources became chain candidates and why) without writing files (TASK-139)")
554
+ .option("--force, --overwrite", "ARV-361: overwrite even hand-edited suite files (those whose auto-gen header was removed). Default: preserve them and warn — files that still carry the header are always regenerated.")
555
+ .action(async (specPos: string | undefined, opts, cmd: Command) => {
556
+ const resolved = resolveSpecArg(specPos, opts.api, opts.db);
557
+ if ("error" in resolved) { printError(resolved.error); process.exitCode = 2; return; }
558
+ if (!opts.explain && !opts.output) {
559
+ printError("--output <dir> is required (omit only when running with --explain).");
560
+ process.exitCode = 2;
561
+ return;
562
+ }
563
+ process.exitCode = await generateCommand({
564
+ specPath: resolved.spec,
565
+ output: opts.output ?? "",
566
+ tag: opts.tag,
567
+ uncoveredOnly: opts.uncoveredOnly === true,
568
+ includeDeprecated: opts.includeDeprecated === true,
569
+ explain: opts.explain === true,
570
+ force: opts.force === true || opts.overwrite === true,
571
+ json: globalJson(cmd),
572
+ include: Array.isArray(opts.include) ? opts.include : undefined,
573
+ exclude: Array.isArray(opts.exclude) ? opts.exclude : undefined,
574
+ apiName: getApi(cmd, opts),
575
+ });
576
+ });
577
+ }
@@ -2,7 +2,13 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
 
4
4
  import { upsertAgentsBlock, type AgentsBlockResult } from "./agents-md.ts";
5
- import { upsertSkills, type SkillResult } from "./skills.ts";
5
+ import {
6
+ detectStaleSkills,
7
+ pruneStaleSkills,
8
+ upsertSkills,
9
+ type SkillResult,
10
+ type StaleSkill,
11
+ } from "./skills.ts";
6
12
  import zondConfigTemplate from "./templates/zond-config.yml" with { type: "text" };
7
13
 
8
14
  export interface BootstrapOptions {
@@ -11,6 +17,8 @@ export interface BootstrapOptions {
11
17
  writeAgents?: boolean;
12
18
  /** Whether to write Claude Code skills under .claude/skills/. Defaults to true. */
13
19
  writeSkills?: boolean;
20
+ /** Remove legacy skill dirs (zond-base, zond-scenarios, …) instead of just warning. */
21
+ pruneStaleSkills?: boolean;
14
22
  /** Override $HOME — used by tests and intentional overrides. */
15
23
  home?: string;
16
24
  dryRun?: boolean;
@@ -24,6 +32,10 @@ export interface BootstrapResult {
24
32
  apisAction: "created" | "noop";
25
33
  agents: AgentsBlockResult | null;
26
34
  skills: SkillResult[];
35
+ /** Legacy skill dirs that exist on disk but are no longer in `SKILLS`. */
36
+ staleSkills: StaleSkill[];
37
+ /** Subset of `staleSkills` that were actually removed (empty unless `pruneStaleSkills`). */
38
+ prunedSkills: StaleSkill[];
27
39
  warnings: string[];
28
40
  }
29
41
 
@@ -32,7 +44,10 @@ export interface BootstrapResult {
32
44
  * (unless `writeAgents` is false) `AGENTS.md`.
33
45
  */
34
46
  export function bootstrapWorkspace(opts: BootstrapOptions = {}): BootstrapResult {
35
- const cwd = resolve(opts.cwd ?? process.cwd());
47
+ // Honor ZOND_WORKSPACE so `zond init` writes the workspace at the same root
48
+ // that findWorkspaceRoot() reads from (headless/CI runs anchor both here).
49
+ // Explicit opts.cwd (tests) still wins.
50
+ const cwd = resolve(opts.cwd ?? process.env.ZOND_WORKSPACE?.trim() ?? process.cwd());
36
51
  const warnings: string[] = [];
37
52
  const writeAgents = opts.writeAgents ?? true;
38
53
  const writeSkills = opts.writeSkills ?? true;
@@ -66,6 +81,21 @@ export function bootstrapWorkspace(opts: BootstrapOptions = {}): BootstrapResult
66
81
  // 4. .claude/skills/zond-*/SKILL.md
67
82
  const skills: SkillResult[] = writeSkills ? upsertSkills(cwd, { dryRun: opts.dryRun }) : [];
68
83
 
84
+ // 5. Detect (and optionally prune) legacy skill dirs left over from
85
+ // retired templates. User-authored skill dirs are NOT touched —
86
+ // only names in `LEGACY_SKILL_NAMES` are considered.
87
+ const staleSkills = writeSkills ? detectStaleSkills(cwd) : [];
88
+ let prunedSkills: StaleSkill[] = [];
89
+ if (writeSkills && opts.pruneStaleSkills && staleSkills.length > 0) {
90
+ prunedSkills = pruneStaleSkills(cwd, { dryRun: opts.dryRun });
91
+ } else if (staleSkills.length > 0) {
92
+ for (const { name } of staleSkills) {
93
+ warnings.push(
94
+ `stale skill detected: .claude/skills/${name}/ — re-run with --prune-stale-skills to remove`,
95
+ );
96
+ }
97
+ }
98
+
69
99
  return {
70
100
  cwd,
71
101
  configPath,
@@ -74,6 +104,8 @@ export function bootstrapWorkspace(opts: BootstrapOptions = {}): BootstrapResult
74
104
  apisAction,
75
105
  agents,
76
106
  skills,
107
+ staleSkills,
108
+ prunedSkills,
77
109
  warnings,
78
110
  };
79
111
  }