@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
@@ -0,0 +1,661 @@
1
+ /**
2
+ * `zond doctor` — operator-friendly health check for a registered API.
3
+ *
4
+ * Surfaces three things the skill (and the user) need before running tests:
5
+ * 1. Which `.env.yaml` variables are missing relative to `.api-fixtures.yaml`.
6
+ * Required gaps are blockers; optional gaps are warnings.
7
+ * 2. Whether the artifact snapshots (`.api-catalog.yaml`,
8
+ * `.api-resources.yaml`, `.api-fixtures.yaml`) are in sync with the
9
+ * local `spec.json` (specHash match).
10
+ * 3. The local `spec.json` itself — present? readable? matches what's
11
+ * registered in the DB?
12
+ *
13
+ * Output:
14
+ * - human form: structured, three sections.
15
+ * - --json envelope: { fixtures: { required, optional }, stale: [...], spec: { ... } }
16
+ *
17
+ * Exit codes:
18
+ * 0 — all required fixtures present + artifacts fresh.
19
+ * 1 — required fixture missing (the user must edit `.env.yaml`).
20
+ * 2 — workspace problem (no API, missing artifact, stale).
21
+ */
22
+
23
+ import { readFileSync, existsSync } from "node:fs";
24
+ import { join, resolve, isAbsolute } from "node:path";
25
+ import YAML from "yaml";
26
+ import { getDb } from "../../db/schema.ts";
27
+ import { findCollectionByNameOrId, listCollections } from "../../db/queries.ts";
28
+ import { findWorkspaceRoot } from "../../core/workspace/root.ts";
29
+ import { resolveCollectionSpec } from "../../core/setup-api.ts";
30
+ import { loadEnvironment } from "../../core/parser/variables.ts";
31
+ import { loadSecretsFromAncestor } from "../../core/secrets/secrets-file.ts";
32
+ import { loadIdentityFromAncestor } from "../../core/identity/identity-file.ts";
33
+ import { hashSpec } from "../../core/meta/meta-store.ts";
34
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
35
+ import { printError } from "../output.ts";
36
+ import { getApi } from "../util/api-context.ts";
37
+ import { detectSkillDrift } from "./init/skills.ts";
38
+
39
+ export interface DoctorOptions {
40
+ api?: string;
41
+ json?: boolean;
42
+ dbPath?: string;
43
+ /** TASK-145: hide rows that are already healthy. Required fixtures with
44
+ * values, optional fixtures, fresh artifacts disappear from output;
45
+ * only the items doctor wants the user to fix remain. Applies to both
46
+ * text and `--json` shapes. */
47
+ missingOnly?: boolean;
48
+ /** TASK-145: dot-path into the report payload (e.g. `fixtures.required`).
49
+ * When set, doctor emits the resolved subtree as JSON to stdout (no
50
+ * envelope) — pipe-friendly without `jq`. */
51
+ query?: string;
52
+ }
53
+
54
+ interface FixtureRow {
55
+ name: string;
56
+ source: string;
57
+ required: boolean;
58
+ description: string;
59
+ defaultValue?: string;
60
+ affectedEndpoints: string[];
61
+ }
62
+
63
+ interface FixtureManifestShape {
64
+ generatedAt?: string;
65
+ specHash?: string;
66
+ fixtures: FixtureRow[];
67
+ }
68
+
69
+ interface ArtifactStaleness {
70
+ file: string;
71
+ expected: string | null;
72
+ actual: string | null;
73
+ fresh: boolean;
74
+ }
75
+
76
+ /** TASK-172 (m-10): per-fixture metadata returned by doctor. Secrets
77
+ * carry no value (only set/length/secret:true); identity values are
78
+ * visible because that's the whole point of `.identity.yaml` (locally
79
+ * triagable, opt-in redaction with --redact-identity). */
80
+ export interface FixtureMetaRow {
81
+ name: string;
82
+ set: boolean;
83
+ /** UTF-16 length (string.length). Useful for "is the right token
84
+ * pasted, is it the 64-char one or the 32-char one?" */
85
+ length: number;
86
+ source: string;
87
+ description: string;
88
+ affectedEndpoints: string[];
89
+ /** True when the value came from `.secrets.yaml` or is otherwise
90
+ * registered in the SecretRegistry. `value` is omitted for secrets. */
91
+ secret?: true;
92
+ /** True when the value came from `.identity.yaml`. */
93
+ identity?: true;
94
+ /** Resolved value — present for env / identity entries, omitted for
95
+ * secrets so doctor never echoes a token. */
96
+ value?: string;
97
+ /** True when the value looks like a synthetic placeholder ("example",
98
+ * "string", "1") rather than a real fixture. Doctor still treats the
99
+ * fixture as `set` (it has a value) but flags it as suspicious so the
100
+ * user knows positive/CRUD suites will hit fake IDs (TASK-216). */
101
+ placeholder?: true;
102
+ }
103
+
104
+ /** Synthetic stub values that cannot identify a real resource. Path-source
105
+ * fixtures sitting on these strings would route to non-existent IDs and
106
+ * return 404/422, so doctor treats them as "needs filling" rather than OK. */
107
+ const PLACEHOLDER_VALUES = new Set(["example", "string", "1", "0"]);
108
+
109
+ function looksLikePlaceholder(source: string, value: string | undefined): boolean {
110
+ if (source !== "path") return false;
111
+ if (typeof value !== "string") return false;
112
+ return PLACEHOLDER_VALUES.has(value.trim().toLowerCase());
113
+ }
114
+
115
+ interface DoctorReport {
116
+ api: string;
117
+ mode: "spec" | "run-only";
118
+ baseDir: string;
119
+ spec: {
120
+ path: string;
121
+ exists: boolean;
122
+ sha: string | null;
123
+ };
124
+ endpoints: {
125
+ total: number;
126
+ by_method: Record<string, number>;
127
+ } | null;
128
+ fixtures: {
129
+ required: FixtureMetaRow[];
130
+ optional: FixtureMetaRow[];
131
+ extraInEnv: string[]; // keys in .env.yaml that aren't in the manifest (informational)
132
+ };
133
+ staleArtifacts: ArtifactStaleness[];
134
+ blockedRequired: number;
135
+ warnings: string[];
136
+ }
137
+
138
+ interface DoctorRunOnlyReport {
139
+ api: string;
140
+ mode: "run-only";
141
+ baseDir: string;
142
+ envVars: Record<string, string>;
143
+ recommendation: string;
144
+ }
145
+
146
+ /** Read & parse a YAML artifact, returning null if missing. */
147
+ function readYamlIfExists<T>(path: string): T | null {
148
+ if (!existsSync(path)) return null;
149
+ try {
150
+ return YAML.parse(readFileSync(path, "utf-8")) as T;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function readArtifactSpecHash(path: string): string | null {
157
+ if (!existsSync(path)) return null;
158
+ try {
159
+ // Scan only the first 25 lines for the specHash field — it always appears
160
+ // near the top of artifact files. Avoids YAML-parsing the entire file,
161
+ // which can fail or be very slow for large catalogs (150KB+).
162
+ const raw = readFileSync(path, "utf-8");
163
+ const lines = raw.split("\n", 25);
164
+ for (const line of lines) {
165
+ const m = line.match(/^specHash:\s*["']?([0-9a-f]{64})["']?/);
166
+ if (m) return m[1]!;
167
+ }
168
+ // Fallback for non-standard layouts.
169
+ const doc = readYamlIfExists<{ specHash?: unknown }>(path);
170
+ return typeof doc?.specHash === "string" ? doc.specHash : null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ function maskSecret(value: string): string {
177
+ if (value.length <= 6) return "***";
178
+ return `${"*".repeat(Math.max(value.length - 4, 4))}set`;
179
+ }
180
+
181
+ function isLikelySecret(name: string): boolean {
182
+ return /token|secret|key|password|pwd|api_key/i.test(name);
183
+ }
184
+
185
+ export async function doctorCommand(opts: DoctorOptions): Promise<number> {
186
+ try {
187
+ getDb(opts.dbPath);
188
+ } catch (err) {
189
+ const message = `DB unavailable: ${(err as Error).message}`;
190
+ if (opts.json) printJson(jsonError("doctor", [message]));
191
+ else printError(message);
192
+ return 2;
193
+ }
194
+
195
+ // Resolve target API
196
+ let apiName = opts.api;
197
+ if (!apiName) {
198
+ const cols = listCollections();
199
+ if (cols.length === 0) {
200
+ const message = "No API registered. Run `zond add api <name> --spec <path>` first.";
201
+ if (opts.json) printJson(jsonError("doctor", [message]));
202
+ else printError(message);
203
+ return 2;
204
+ }
205
+ if (cols.length > 1) {
206
+ const message = `Multiple APIs registered (${cols.map(c => c.name).join(", ")}). Pass --api <name>.`;
207
+ if (opts.json) printJson(jsonError("doctor", [message]));
208
+ else printError(message);
209
+ return 2;
210
+ }
211
+ apiName = cols[0]!.name;
212
+ }
213
+
214
+ const collection = findCollectionByNameOrId(apiName);
215
+ if (!collection) {
216
+ const message = `API '${apiName}' not found.`;
217
+ if (opts.json) printJson(jsonError("doctor", [message]));
218
+ else printError(message);
219
+ return 2;
220
+ }
221
+
222
+ const baseDir = collection.base_dir
223
+ ?? join(findWorkspaceRoot().root, "apis", apiName);
224
+
225
+ // Spec-less API: short-circuit. Such APIs are registered with --base-url
226
+ // only and have no .api-catalog/.api-resources/.api-fixtures to check. We
227
+ // surface what we have (env vars, base_dir) and tell the user how to upgrade.
228
+ if (!collection.openapi_spec) {
229
+ const envVars = await loadEnvironment(undefined, baseDir);
230
+ const recommendation =
231
+ `This API has no OpenAPI spec — generate/probe/validate-schema are disabled. ` +
232
+ `Run \`zond refresh-api ${apiName} --spec <path|url>\` to attach one.`;
233
+ const report: DoctorRunOnlyReport = {
234
+ api: apiName,
235
+ mode: "run-only",
236
+ baseDir,
237
+ envVars,
238
+ recommendation,
239
+ };
240
+ if (opts.json) {
241
+ printJson(jsonOk("doctor", report));
242
+ } else {
243
+ printRunOnlyHuman(report);
244
+ }
245
+ return 0;
246
+ }
247
+
248
+ // 1. Spec snapshot
249
+ let specAbsPath: string | null = null;
250
+ let specSha: string | null = null;
251
+ let specExists = false;
252
+ if (collection.openapi_spec) {
253
+ try {
254
+ specAbsPath = resolveCollectionSpec(collection.openapi_spec);
255
+ if (!isAbsolute(specAbsPath)) {
256
+ specAbsPath = resolve(findWorkspaceRoot().root, specAbsPath);
257
+ }
258
+ specExists = existsSync(specAbsPath);
259
+ if (specExists) {
260
+ try {
261
+ // Hash the file bytes directly — matches what setup-api / refresh-api
262
+ // record in the artifact specHash fields (TASK-215). Re-parsing and
263
+ // re-stringifying drops shared $ref identity and yields a different
264
+ // hash than the producer recorded.
265
+ specSha = hashSpec(readFileSync(specAbsPath, "utf-8"));
266
+ } catch {
267
+ // unreadable — leave sha null
268
+ }
269
+ }
270
+ } catch (err) {
271
+ // resolveCollectionSpec throws on legacy/stale workspace — that's
272
+ // exactly what doctor should report, just without crashing.
273
+ const m = (err as Error).message;
274
+ if (opts.json) printJson(jsonError("doctor", [m]));
275
+ else printError(m);
276
+ return 2;
277
+ }
278
+ }
279
+
280
+ // 2. Artifact staleness — compare each artifact's specHash to spec.json sha
281
+ const staleArtifacts: ArtifactStaleness[] = [];
282
+ for (const [file, label] of [
283
+ [".api-catalog.yaml", "catalog"],
284
+ [".api-resources.yaml", "resources"],
285
+ [".api-fixtures.yaml", "fixtures"],
286
+ ] as const) {
287
+ const path = join(baseDir, file);
288
+ if (!existsSync(path)) {
289
+ staleArtifacts.push({ file: label, expected: specSha, actual: null, fresh: false });
290
+ continue;
291
+ }
292
+ const actual = readArtifactSpecHash(path);
293
+ const fresh = !!specSha && actual === specSha;
294
+ staleArtifacts.push({ file: label, expected: specSha, actual, fresh });
295
+ }
296
+
297
+ // 3. Fixtures manifest vs .env.yaml
298
+ const manifestPath = join(baseDir, ".api-fixtures.yaml");
299
+ const manifest = readYamlIfExists<FixtureManifestShape>(manifestPath);
300
+ const envVars = await loadEnvironment(undefined, baseDir);
301
+
302
+ // TASK-172 (m-10): classify each fixture as secret / identity / plain
303
+ // env so doctor never echoes a raw secret. The secret registry was
304
+ // populated by loadEnvironment above (which loads .secrets.yaml as a
305
+ // side-effect); identity comes from `.secrets`'s sibling file.
306
+ const secretRaw = loadSecretsFromAncestor(baseDir);
307
+ const identityRaw = loadIdentityFromAncestor(baseDir);
308
+ const secretKeys = new Set(secretRaw ? Object.keys(secretRaw.values) : []);
309
+ const identityKeys = new Set(identityRaw ? Object.keys(identityRaw.values) : []);
310
+
311
+ const requiredOut: DoctorReport["fixtures"]["required"] = [];
312
+ const optionalOut: DoctorReport["fixtures"]["optional"] = [];
313
+ const declaredVars = new Set<string>();
314
+
315
+ if (manifest?.fixtures) {
316
+ for (const f of manifest.fixtures) {
317
+ declaredVars.add(f.name);
318
+ const value = envVars[f.name];
319
+ const set = typeof value === "string" && value.length > 0;
320
+ const isSecret = secretKeys.has(f.name);
321
+ const isIdentity = identityKeys.has(f.name);
322
+ const placeholder = !isSecret && looksLikePlaceholder(f.source, value);
323
+ const row: FixtureMetaRow = {
324
+ name: f.name,
325
+ set,
326
+ length: typeof value === "string" ? value.length : 0,
327
+ source: f.source,
328
+ description: f.description,
329
+ affectedEndpoints: f.affectedEndpoints ?? [],
330
+ ...(isSecret ? { secret: true as const } : {}),
331
+ ...(isIdentity ? { identity: true as const } : {}),
332
+ // Identity values stay visible (mental model: identity is for
333
+ // locally-triagable but personally-identifying data; `--redact-
334
+ // identity` swaps it for placeholders only at outbound time).
335
+ ...(!isSecret && set ? { value } : {}),
336
+ ...(placeholder ? { placeholder: true as const } : {}),
337
+ };
338
+ if (f.required) requiredOut.push(row);
339
+ else optionalOut.push(row);
340
+ }
341
+ }
342
+
343
+ const extraInEnv = Object.keys(envVars).filter(k => !declaredVars.has(k)).sort();
344
+ const blockedRequired = requiredOut.filter(r => !r.set).length;
345
+
346
+ const warnings: string[] = [];
347
+ if (!specExists) warnings.push(`spec.json not found at ${specAbsPath}`);
348
+ if (!manifest) warnings.push(`.api-fixtures.yaml missing — run \`zond refresh-api ${apiName}\``);
349
+
350
+ // ARV-237: warn when workspace .claude/skills/ copy drifts from the
351
+ // in-binary template (binary upgraded but `zond init` not re-run).
352
+ // `missing` is silent — user may have opted out via `--no-skills`.
353
+ try {
354
+ const wsRoot = findWorkspaceRoot().root;
355
+ const outdated = detectSkillDrift(wsRoot).filter(d => d.status === "outdated");
356
+ if (outdated.length > 0) {
357
+ warnings.push(
358
+ `workspace skills outdated (${outdated.map(d => d.name).join(", ")}) — run \`zond init\` to refresh .claude/skills/`,
359
+ );
360
+ }
361
+ } catch {
362
+ // not in a workspace — skip
363
+ }
364
+ const placeholderRows = [...requiredOut, ...optionalOut].filter(r => r.placeholder);
365
+ if (placeholderRows.length > 0) {
366
+ warnings.push(
367
+ `${placeholderRows.length} path fixture${placeholderRows.length === 1 ? "" : "s"} hold placeholder values (${placeholderRows.map(r => r.name).join(", ")}); positive/CRUD suites will hit fake ids — replace with real values in .env.yaml`,
368
+ );
369
+ }
370
+
371
+ // Endpoint counts from .api-catalog.yaml — read what `zond add api` already
372
+ // computed, so `--json` consumers (e.g. /zond-scan PF2 budget estimate)
373
+ // don't have to walk raw spec.json themselves (which would violate the
374
+ // "never read raw OpenAPI" iron rule in the zond skill).
375
+ let endpoints: DoctorReport["endpoints"] = null;
376
+ const catalogPath = join(baseDir, ".api-catalog.yaml");
377
+ if (existsSync(catalogPath)) {
378
+ const catalog = readYamlIfExists<{
379
+ endpointCount?: number;
380
+ endpoints?: Array<{ method?: string }>;
381
+ }>(catalogPath);
382
+ if (catalog) {
383
+ const list = Array.isArray(catalog.endpoints) ? catalog.endpoints : [];
384
+ const byMethod: Record<string, number> = {};
385
+ for (const ep of list) {
386
+ const m = (ep.method ?? "").toUpperCase();
387
+ if (!m) continue;
388
+ byMethod[m] = (byMethod[m] ?? 0) + 1;
389
+ }
390
+ endpoints = {
391
+ total: typeof catalog.endpointCount === "number" ? catalog.endpointCount : list.length,
392
+ by_method: byMethod,
393
+ };
394
+ }
395
+ }
396
+
397
+ const report: DoctorReport = {
398
+ api: apiName,
399
+ mode: "spec",
400
+ baseDir,
401
+ spec: {
402
+ path: specAbsPath ?? "",
403
+ exists: specExists,
404
+ sha: specSha,
405
+ },
406
+ endpoints,
407
+ fixtures: {
408
+ required: requiredOut,
409
+ optional: optionalOut,
410
+ extraInEnv,
411
+ },
412
+ staleArtifacts,
413
+ blockedRequired,
414
+ warnings,
415
+ };
416
+
417
+ // TASK-145: --missing-only filters out healthy rows so the report only
418
+ // contains things the user has to fix. Applies symmetrically to text and
419
+ // JSON so `--json | jq '.data.fixtures.required'` and the stdout view
420
+ // agree on what's "noise".
421
+ const presented = opts.missingOnly ? applyMissingOnly(report) : report;
422
+
423
+ // TASK-145: --query <dotpath> short-circuits the envelope and emits the
424
+ // resolved subtree as raw JSON, no `jq` required.
425
+ if (opts.query) {
426
+ const resolved = resolveDotPath(presented, opts.query);
427
+ if (resolved === undefined) {
428
+ const message = `--query path '${opts.query}' did not resolve in the doctor report (canonical paths: api, spec, endpoints, fixtures.required, fixtures.optional, fixtures.extraInEnv, staleArtifacts, warnings)`;
429
+ if (opts.json) printJson(jsonError("doctor", [message]));
430
+ else printError(message);
431
+ return 2;
432
+ }
433
+ process.stdout.write(JSON.stringify(resolved, null, 2) + "\n");
434
+ // ARV-274: --json (incl. --query) returns 0 on successful emit.
435
+ // Fixture/staleness state is *data*, not command failure — non-zero is
436
+ // reserved for command failure (missing api, parse error). Text mode
437
+ // still surfaces state via exit code so `if zond doctor; then ...`
438
+ // remains a useful gate.
439
+ if (opts.json) return 0;
440
+ if (blockedRequired > 0) return 1;
441
+ if (staleArtifacts.some(s => !s.fresh) || !specExists || !manifest) return 2;
442
+ return 0;
443
+ }
444
+
445
+ // ── Output ──
446
+ if (opts.json) {
447
+ printJson(jsonOk("doctor", presented));
448
+ return 0; // ARV-274: JSON envelope emit success → exit 0
449
+ }
450
+
451
+ printHuman(presented, envVars, { missingOnly: opts.missingOnly === true });
452
+
453
+ if (blockedRequired > 0) return 1;
454
+ if (staleArtifacts.some(s => !s.fresh) || !specExists || !manifest) return 2;
455
+ return 0;
456
+ }
457
+
458
+ /** TASK-145: produce a copy of the doctor report containing only items the
459
+ * user still has to address. Filters: required fixtures with `set: false`,
460
+ * artifacts where `fresh: false`, missing spec, missing manifest. Optional
461
+ * fixtures and `extraInEnv` are dropped wholesale — they're never "missing"
462
+ * by definition. `warnings` is kept intact. */
463
+ function applyMissingOnly(r: DoctorReport): DoctorReport {
464
+ return {
465
+ ...r,
466
+ fixtures: {
467
+ required: r.fixtures.required.filter((f) => !f.set),
468
+ optional: [],
469
+ extraInEnv: [],
470
+ },
471
+ staleArtifacts: r.staleArtifacts.filter((s) => !s.fresh),
472
+ };
473
+ }
474
+
475
+ /** TASK-145: resolve a dot-path like `fixtures.required` against the report.
476
+ * Numeric segments index into arrays. Returns `undefined` when any segment
477
+ * is missing — the caller surfaces that as a CLI error. */
478
+ function resolveDotPath(value: unknown, path: string): unknown {
479
+ const parts = path.split(".").filter((p) => p.length > 0);
480
+ let cur: unknown = value;
481
+ for (const p of parts) {
482
+ if (cur === null || cur === undefined) return undefined;
483
+ if (Array.isArray(cur)) {
484
+ const idx = Number.parseInt(p, 10);
485
+ if (Number.isNaN(idx)) return undefined;
486
+ cur = cur[idx];
487
+ continue;
488
+ }
489
+ if (typeof cur !== "object") return undefined;
490
+ cur = (cur as Record<string, unknown>)[p];
491
+ }
492
+ return cur;
493
+ }
494
+
495
+ function printHuman(
496
+ r: DoctorReport,
497
+ envVars: Record<string, string>,
498
+ opts: { missingOnly: boolean } = { missingOnly: false },
499
+ ): void {
500
+ const out = process.stdout;
501
+ out.write(`API: ${r.api}\n`);
502
+ out.write(`Workspace dir: ${r.baseDir}\n\n`);
503
+
504
+ // Spec snapshot
505
+ out.write(`Spec snapshot (${r.spec.path}):\n`);
506
+ if (!r.spec.exists) {
507
+ out.write(` ✗ MISSING — run \`zond refresh-api ${r.api}\`\n\n`);
508
+ } else {
509
+ out.write(` ✓ present, sha ${r.spec.sha?.slice(0, 12) ?? "?"}…\n\n`);
510
+ }
511
+
512
+ // Artifacts
513
+ if (opts.missingOnly && r.staleArtifacts.length === 0) {
514
+ // nothing to report — skip the section
515
+ } else {
516
+ out.write(`Artifact freshness:\n`);
517
+ for (const s of r.staleArtifacts) {
518
+ if (!s.actual) {
519
+ out.write(` ✗ ${s.file}: missing\n`);
520
+ } else if (s.fresh) {
521
+ out.write(` ✓ ${s.file}: fresh\n`);
522
+ } else {
523
+ out.write(` ⚠ ${s.file}: STALE (artifact specHash ${s.actual.slice(0, 12)}… ≠ spec.json ${s.expected?.slice(0, 12)}…)\n`);
524
+ }
525
+ }
526
+ out.write("\n");
527
+ }
528
+
529
+ // Required fixtures
530
+ if (opts.missingOnly && r.fixtures.required.length === 0) {
531
+ // skip — nothing missing
532
+ } else {
533
+ out.write(`Required fixtures (${r.fixtures.required.length}):\n`);
534
+ if (r.fixtures.required.length === 0) {
535
+ out.write(` (none)\n`);
536
+ } else {
537
+ for (const f of r.fixtures.required) {
538
+ const icon = !f.set ? "✗" : f.placeholder ? "⚠" : "✓";
539
+ // TASK-172 (m-10): secrets show metadata only (set + length); identity
540
+ // is visible because the user owns those values; plain env shows raw.
541
+ const value = !f.set
542
+ ? "UNSET"
543
+ : f.secret
544
+ ? `set (${f.length} chars, secret)`
545
+ : f.identity
546
+ ? `${envVars[f.name]} (identity)`
547
+ : f.placeholder
548
+ ? `${envVars[f.name]} (placeholder — fill with a real id)`
549
+ : envVars[f.name];
550
+ const detail = f.set ? "" : ` (${f.affectedEndpoints.length === 1 && f.affectedEndpoints[0] === "*" ? "all endpoints" : `blocks ${f.affectedEndpoints.length} endpoint${f.affectedEndpoints.length === 1 ? "" : "s"}`})`;
551
+ out.write(` ${icon} ${f.name.padEnd(20)} ${String(value).padEnd(40)} [${f.source}]${detail}\n`);
552
+ }
553
+ }
554
+ out.write("\n");
555
+ }
556
+
557
+ // Optional fixtures (suppressed entirely under --missing-only — they are
558
+ // by definition never "missing" in the actionable sense).
559
+ if (!opts.missingOnly) {
560
+ out.write(`Optional fixtures (${r.fixtures.optional.length}):\n`);
561
+ if (r.fixtures.optional.length === 0) {
562
+ out.write(` (none)\n`);
563
+ } else {
564
+ for (const f of r.fixtures.optional) {
565
+ const icon = f.set ? "✓" : "⚠";
566
+ out.write(` ${icon} ${f.name.padEnd(20)} ${(f.set ? "set" : "unset").padEnd(40)} [${f.source}]\n`);
567
+ }
568
+ }
569
+ out.write("\n");
570
+ }
571
+
572
+ if (!opts.missingOnly && r.fixtures.extraInEnv.length > 0) {
573
+ out.write(`Other variables in .env.yaml (not in manifest, informational):\n`);
574
+ for (const k of r.fixtures.extraInEnv) out.write(` • ${k}\n`);
575
+ out.write("\n");
576
+ }
577
+
578
+ // Suggested next
579
+ if (opts.missingOnly && r.blockedRequired === 0 && r.staleArtifacts.length === 0) {
580
+ out.write(`No missing items. Workspace is ready.\n`);
581
+ } else if (r.blockedRequired > 0) {
582
+ // ARV-16: align with `zond coverage`, which points users at the same
583
+ // remedy. `prepare-fixtures` auto-seeds from list endpoints; manual edit
584
+ // is the fallback for fields prepare-fixtures can't infer.
585
+ out.write(`Next: run \`zond prepare-fixtures --api ${r.api}\` to auto-seed from list endpoints, or edit ${r.baseDir}/.env.yaml and fill the ${r.blockedRequired} required value${r.blockedRequired === 1 ? "" : "s"} manually. Then re-run \`zond doctor --api ${r.api}\`.\n`);
586
+ } else if (r.staleArtifacts.some(s => !s.fresh)) {
587
+ out.write(`Next: artifacts are out of sync — run \`zond refresh-api ${r.api}\`.\n`);
588
+ } else {
589
+ out.write(`All checks passed. Workspace is ready.\n`);
590
+ }
591
+
592
+ for (const w of r.warnings) out.write(`Warning: ${w}\n`);
593
+ }
594
+
595
+ function printRunOnlyHuman(r: DoctorRunOnlyReport): void {
596
+ const out = process.stdout;
597
+ out.write(`API: ${r.api}\n`);
598
+ out.write(`Mode: run-only (no OpenAPI spec)\n`);
599
+ out.write(`Workspace dir: ${r.baseDir}\n\n`);
600
+ const keys = Object.keys(r.envVars);
601
+ out.write(`Environment variables (${keys.length}):\n`);
602
+ if (keys.length === 0) {
603
+ out.write(` (none) — write \`base_url: ...\` into ${r.baseDir}/.env.yaml\n`);
604
+ } else {
605
+ for (const k of keys) {
606
+ const v = isLikelySecret(k) ? maskSecret(r.envVars[k]!) : r.envVars[k];
607
+ out.write(` • ${k.padEnd(20)} ${v}\n`);
608
+ }
609
+ }
610
+ out.write(`\n${r.recommendation}\n`);
611
+ }
612
+
613
+ import type { Command } from "commander";
614
+ import { globalJson as globalJsonResolver } from "../resolve.ts";
615
+
616
+ export function registerDoctor(program: Command): void {
617
+ program
618
+ .command("doctor")
619
+ .description("Diagnose registered API: fixture gaps in .env.yaml + artifact freshness vs spec.json")
620
+ .addHelpText(
621
+ "after",
622
+ [
623
+ "",
624
+ "JSON shape (canonical, TASK-145):",
625
+ " --json envelope is { ok, command, data, warnings, errors }. The",
626
+ " doctor payload sits under .data:",
627
+ " .data.api string",
628
+ " .data.spec.{path,exists,sha} OpenAPI snapshot",
629
+ " .data.fixtures.required[] FixtureMetaRow — each has .set",
630
+ " .data.fixtures.optional[] same shape",
631
+ " .data.fixtures.extraInEnv[] keys present in .env.yaml only",
632
+ " .data.staleArtifacts[] { file, expected, actual, fresh }",
633
+ " .data.blockedRequired number of unset required fixtures",
634
+ " .data.warnings[] advisory strings",
635
+ "",
636
+ "Tips:",
637
+ " --missing-only hide healthy rows (text + json)",
638
+ " --query fixtures.required emit one subtree as raw JSON, no jq",
639
+ ].join("\n"),
640
+ )
641
+ .option("--api <name>", "API collection name (defaults to the only registered one)")
642
+ .option("--db <path>", "Path to SQLite database file")
643
+ .option("--missing-only", "Show only missing/stale items (hide rows that are already healthy). Applies to both text and --json output.")
644
+ .option("--query <dotpath>", "Resolve a dot-path inside the doctor report and emit just that subtree as JSON (e.g. fixtures.required, staleArtifacts, spec.sha).")
645
+ .action(async (opts, cmd: Command) => {
646
+ // ARV-96: resolve --api via the shared chain (local opt > ancestor opt
647
+ // > ZOND_API_GLOBAL > ZOND_API > .zond/current-api). Without this,
648
+ // `zond --api X doctor` and `zond doctor --api X` on a multi-API
649
+ // workspace both fell through to the "Multiple APIs registered" branch
650
+ // because the global --api option (program.ts) absorbs the flag and
651
+ // leaves opts.api undefined for the subcommand.
652
+ const resolvedApi = getApi(cmd, opts);
653
+ process.exitCode = await doctorCommand({
654
+ api: resolvedApi,
655
+ dbPath: typeof opts.db === "string" ? opts.db : undefined,
656
+ json: globalJsonResolver(cmd),
657
+ missingOnly: opts.missingOnly === true,
658
+ query: typeof opts.query === "string" ? opts.query : undefined,
659
+ });
660
+ });
661
+ }