@kirrosh/zond 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/CHANGELOG.md +758 -3
  2. package/README.md +78 -15
  3. package/package.json +17 -10
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -0
  20. package/src/cli/commands/ci-init.ts +55 -6
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +262 -0
  23. package/src/cli/commands/completions.ts +192 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +180 -8
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -47
  31. package/src/cli/commands/init/agents-md.ts +61 -0
  32. package/src/cli/commands/init/bootstrap.ts +108 -0
  33. package/src/cli/commands/init/index.ts +244 -0
  34. package/src/cli/commands/init/skills.ts +98 -0
  35. package/src/cli/commands/init/templates/agents.md +77 -0
  36. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  37. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +651 -0
  40. package/src/cli/commands/init/templates/zond-config.yml +14 -0
  41. package/src/cli/commands/prepare-fixtures.ts +135 -0
  42. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  43. package/src/cli/commands/probe/security.ts +454 -0
  44. package/src/cli/commands/probe/static.ts +255 -0
  45. package/src/cli/commands/probe/webhooks.ts +161 -0
  46. package/src/cli/commands/probe.ts +459 -0
  47. package/src/cli/commands/reference.ts +87 -0
  48. package/src/cli/commands/refresh-api.ts +169 -0
  49. package/src/cli/commands/remove-api.ts +150 -0
  50. package/src/cli/commands/report-bundle.ts +318 -0
  51. package/src/cli/commands/report.ts +241 -0
  52. package/src/cli/commands/request.ts +379 -4
  53. package/src/cli/commands/run.ts +911 -33
  54. package/src/cli/commands/session.ts +244 -0
  55. package/src/cli/commands/use.ts +74 -0
  56. package/src/cli/index.ts +36 -607
  57. package/src/cli/json-envelope.ts +112 -3
  58. package/src/cli/json-schemas.ts +263 -0
  59. package/src/cli/program.ts +218 -0
  60. package/src/cli/resolve.ts +105 -0
  61. package/src/cli/status-filter.ts +124 -0
  62. package/src/cli/util/api-context.ts +85 -0
  63. package/src/cli/version.ts +8 -0
  64. package/src/core/anti-fp/bootstrap.ts +34 -0
  65. package/src/core/anti-fp/index.ts +33 -0
  66. package/src/core/anti-fp/registry.ts +44 -0
  67. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  68. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  69. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  70. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  71. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  72. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  73. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  74. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  75. package/src/core/anti-fp/types.ts +68 -0
  76. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  77. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  78. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  79. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  80. package/src/core/checks/checks/cross_call_references.ts +134 -0
  81. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  82. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  83. package/src/core/checks/checks/ignored_auth.ts +211 -0
  84. package/src/core/checks/checks/index.ts +65 -0
  85. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  86. package/src/core/checks/checks/missing_required_header.ts +40 -0
  87. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  88. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  89. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  90. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  91. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  92. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  93. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  94. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  95. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  96. package/src/core/checks/checks/unsupported_method.ts +63 -0
  97. package/src/core/checks/checks/use_after_free.ts +78 -0
  98. package/src/core/checks/index.ts +30 -0
  99. package/src/core/checks/mode.ts +79 -0
  100. package/src/core/checks/recommended-action.ts +64 -0
  101. package/src/core/checks/registry.ts +78 -0
  102. package/src/core/checks/runner.ts +874 -0
  103. package/src/core/checks/sarif.ts +230 -0
  104. package/src/core/checks/stateful.ts +121 -0
  105. package/src/core/checks/types.ts +189 -0
  106. package/src/core/classifier/recommended-action.ts +222 -0
  107. package/src/core/context/current.ts +51 -0
  108. package/src/core/context/session.ts +78 -0
  109. package/src/core/coverage/loader.ts +185 -0
  110. package/src/core/coverage/reasons.ts +300 -0
  111. package/src/core/diagnostics/db-analysis.ts +161 -12
  112. package/src/core/diagnostics/failure-class.ts +120 -0
  113. package/src/core/diagnostics/failure-hints.ts +212 -9
  114. package/src/core/diagnostics/spec-pointer.ts +99 -0
  115. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  116. package/src/core/exporter/case-study/index.ts +270 -0
  117. package/src/core/exporter/curl.ts +40 -0
  118. package/src/core/exporter/exporter.ts +48 -0
  119. package/src/core/exporter/html-report/escape.ts +24 -0
  120. package/src/core/exporter/html-report/index.ts +479 -0
  121. package/src/core/exporter/html-report/script.ts +100 -0
  122. package/src/core/exporter/html-report/styles.ts +408 -0
  123. package/src/core/generator/chunker.ts +53 -15
  124. package/src/core/generator/coverage-phase.ts +0 -0
  125. package/src/core/generator/create-body.ts +89 -0
  126. package/src/core/generator/data-factory.ts +490 -33
  127. package/src/core/generator/describe.ts +1 -1
  128. package/src/core/generator/fixtures-builder.ts +325 -0
  129. package/src/core/generator/index.ts +7 -5
  130. package/src/core/generator/openapi-reader.ts +55 -3
  131. package/src/core/generator/path-param-disambig.ts +114 -0
  132. package/src/core/generator/resources-builder.ts +648 -0
  133. package/src/core/generator/schema-utils.ts +11 -3
  134. package/src/core/generator/serializer.ts +114 -15
  135. package/src/core/generator/suite-generator.ts +484 -77
  136. package/src/core/generator/types.ts +8 -0
  137. package/src/core/identity/identity-file.ts +129 -0
  138. package/src/core/lint/affects.ts +28 -0
  139. package/src/core/lint/config.ts +96 -0
  140. package/src/core/lint/format.ts +42 -0
  141. package/src/core/lint/index.ts +94 -0
  142. package/src/core/lint/reporter.ts +128 -0
  143. package/src/core/lint/rules/consistency.ts +158 -0
  144. package/src/core/lint/rules/heuristics.ts +97 -0
  145. package/src/core/lint/rules/strictness.ts +109 -0
  146. package/src/core/lint/types.ts +96 -0
  147. package/src/core/lint/walker.ts +248 -0
  148. package/src/core/meta/meta-store.ts +6 -73
  149. package/src/core/output/README.md +91 -0
  150. package/src/core/output/index.ts +13 -0
  151. package/src/core/output/run.ts +126 -0
  152. package/src/core/output/types.ts +129 -0
  153. package/src/core/parser/env-interpolation.ts +104 -0
  154. package/src/core/parser/filter.ts +57 -0
  155. package/src/core/parser/schema.ts +132 -5
  156. package/src/core/parser/types.ts +29 -2
  157. package/src/core/parser/variables.ts +0 -0
  158. package/src/core/parser/yaml-parser.ts +108 -13
  159. package/src/core/probe/bootstrap.ts +34 -0
  160. package/src/core/probe/dry-run-envelope.ts +57 -0
  161. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  162. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  163. package/src/core/probe/mass-assignment-template.ts +212 -0
  164. package/src/core/probe/method-probe.ts +164 -0
  165. package/src/core/probe/method-shared.ts +69 -0
  166. package/src/core/probe/negative-probe.ts +691 -0
  167. package/src/core/probe/orphan-tracker.ts +188 -0
  168. package/src/core/probe/path-discovery.ts +440 -0
  169. package/src/core/probe/probe-harness.ts +120 -0
  170. package/src/core/probe/registry.ts +89 -0
  171. package/src/core/probe/runner.ts +136 -0
  172. package/src/core/probe/security-probe-class.ts +201 -0
  173. package/src/core/probe/security-probe.ts +1453 -0
  174. package/src/core/probe/shared.ts +505 -0
  175. package/src/core/probe/static-probe-class.ts +125 -0
  176. package/src/core/probe/types.ts +165 -0
  177. package/src/core/probe/verdict-aggregator.ts +33 -0
  178. package/src/core/probe/webhooks-probe.ts +284 -0
  179. package/src/core/reporter/console.ts +69 -4
  180. package/src/core/reporter/index.ts +2 -3
  181. package/src/core/reporter/json.ts +15 -2
  182. package/src/core/reporter/junit.ts +27 -12
  183. package/src/core/reporter/ndjson.ts +37 -0
  184. package/src/core/reporter/types.ts +3 -0
  185. package/src/core/runner/assertions.ts +62 -2
  186. package/src/core/runner/async-pool.ts +108 -0
  187. package/src/core/runner/auth-path.ts +8 -0
  188. package/src/core/runner/ci-context.ts +72 -0
  189. package/src/core/runner/executor.ts +391 -52
  190. package/src/core/runner/form-encode.ts +51 -0
  191. package/src/core/runner/http-client.ts +115 -7
  192. package/src/core/runner/learn-drift.ts +293 -0
  193. package/src/core/runner/preflight-vars.ts +149 -0
  194. package/src/core/runner/progress-tracker.ts +73 -0
  195. package/src/core/runner/rate-limiter.ts +203 -0
  196. package/src/core/runner/run-kind.ts +39 -0
  197. package/src/core/runner/schema-validator.ts +312 -0
  198. package/src/core/runner/send-request.ts +153 -20
  199. package/src/core/runner/types.ts +38 -0
  200. package/src/core/secrets/registry.ts +164 -0
  201. package/src/core/secrets/secrets-file.ts +115 -0
  202. package/src/core/selectors/operation-filter.ts +144 -0
  203. package/src/core/setup-api.ts +419 -17
  204. package/src/core/severity/category.ts +94 -0
  205. package/src/core/severity/index.ts +121 -0
  206. package/src/core/spec/layers.ts +154 -0
  207. package/src/core/util/format-eta.ts +21 -0
  208. package/src/core/utils.ts +5 -1
  209. package/src/core/workspace/config.ts +129 -0
  210. package/src/core/workspace/manifest.ts +283 -0
  211. package/src/core/workspace/output-rotation.ts +62 -0
  212. package/src/core/workspace/root.ts +94 -0
  213. package/src/core/workspace/triage-path.ts +87 -0
  214. package/src/db/lint-runs.ts +47 -0
  215. package/src/db/migrate.ts +126 -0
  216. package/src/db/migrations/0001_run_kind.sql +25 -0
  217. package/src/db/migrations/sql.d.ts +4 -0
  218. package/src/db/queries/collections.ts +133 -0
  219. package/src/db/queries/coverage.ts +9 -0
  220. package/src/db/queries/dashboard.ts +59 -0
  221. package/src/db/queries/results.ts +128 -0
  222. package/src/db/queries/runs.ts +235 -0
  223. package/src/db/queries/sessions.ts +42 -0
  224. package/src/db/queries/settings.ts +28 -0
  225. package/src/db/queries/types.ts +172 -0
  226. package/src/db/queries.ts +72 -802
  227. package/src/db/schema.ts +179 -48
  228. package/src/cli/commands/export.ts +0 -144
  229. package/src/cli/commands/guide.ts +0 -127
  230. package/src/cli/commands/init.ts +0 -57
  231. package/src/cli/commands/serve.ts +0 -81
  232. package/src/cli/commands/sync.ts +0 -269
  233. package/src/cli/commands/update.ts +0 -189
  234. package/src/cli/commands/validate.ts +0 -34
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -21
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,607 @@
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
+
38
+ export interface DoctorOptions {
39
+ api?: string;
40
+ json?: boolean;
41
+ dbPath?: string;
42
+ /** TASK-145: hide rows that are already healthy. Required fixtures with
43
+ * values, optional fixtures, fresh artifacts disappear from output;
44
+ * only the items doctor wants the user to fix remain. Applies to both
45
+ * text and `--json` shapes. */
46
+ missingOnly?: boolean;
47
+ /** TASK-145: dot-path into the report payload (e.g. `fixtures.required`).
48
+ * When set, doctor emits the resolved subtree as JSON to stdout (no
49
+ * envelope) — pipe-friendly without `jq`. */
50
+ query?: string;
51
+ }
52
+
53
+ interface FixtureRow {
54
+ name: string;
55
+ source: string;
56
+ required: boolean;
57
+ description: string;
58
+ defaultValue?: string;
59
+ affectedEndpoints: string[];
60
+ }
61
+
62
+ interface FixtureManifestShape {
63
+ generatedAt?: string;
64
+ specHash?: string;
65
+ fixtures: FixtureRow[];
66
+ }
67
+
68
+ interface ArtifactStaleness {
69
+ file: string;
70
+ expected: string | null;
71
+ actual: string | null;
72
+ fresh: boolean;
73
+ }
74
+
75
+ /** TASK-172 (m-10): per-fixture metadata returned by doctor. Secrets
76
+ * carry no value (only set/length/secret:true); identity values are
77
+ * visible because that's the whole point of `.identity.yaml` (locally
78
+ * triagable, opt-in redaction with --redact-identity). */
79
+ export interface FixtureMetaRow {
80
+ name: string;
81
+ set: boolean;
82
+ /** UTF-16 length (string.length). Useful for "is the right token
83
+ * pasted, is it the 64-char one or the 32-char one?" */
84
+ length: number;
85
+ source: string;
86
+ description: string;
87
+ affectedEndpoints: string[];
88
+ /** True when the value came from `.secrets.yaml` or is otherwise
89
+ * registered in the SecretRegistry. `value` is omitted for secrets. */
90
+ secret?: true;
91
+ /** True when the value came from `.identity.yaml`. */
92
+ identity?: true;
93
+ /** Resolved value — present for env / identity entries, omitted for
94
+ * secrets so doctor never echoes a token. */
95
+ value?: string;
96
+ /** True when the value looks like a synthetic placeholder ("example",
97
+ * "string", "1") rather than a real fixture. Doctor still treats the
98
+ * fixture as `set` (it has a value) but flags it as suspicious so the
99
+ * user knows positive/CRUD suites will hit fake IDs (TASK-216). */
100
+ placeholder?: true;
101
+ }
102
+
103
+ /** Synthetic stub values that cannot identify a real resource. Path-source
104
+ * fixtures sitting on these strings would route to non-existent IDs and
105
+ * return 404/422, so doctor treats them as "needs filling" rather than OK. */
106
+ const PLACEHOLDER_VALUES = new Set(["example", "string", "1", "0"]);
107
+
108
+ function looksLikePlaceholder(source: string, value: string | undefined): boolean {
109
+ if (source !== "path") return false;
110
+ if (typeof value !== "string") return false;
111
+ return PLACEHOLDER_VALUES.has(value.trim().toLowerCase());
112
+ }
113
+
114
+ interface DoctorReport {
115
+ api: string;
116
+ mode: "spec" | "run-only";
117
+ baseDir: string;
118
+ spec: {
119
+ path: string;
120
+ exists: boolean;
121
+ sha: string | null;
122
+ };
123
+ fixtures: {
124
+ required: FixtureMetaRow[];
125
+ optional: FixtureMetaRow[];
126
+ extraInEnv: string[]; // keys in .env.yaml that aren't in the manifest (informational)
127
+ };
128
+ staleArtifacts: ArtifactStaleness[];
129
+ blockedRequired: number;
130
+ warnings: string[];
131
+ }
132
+
133
+ interface DoctorRunOnlyReport {
134
+ api: string;
135
+ mode: "run-only";
136
+ baseDir: string;
137
+ envVars: Record<string, string>;
138
+ recommendation: string;
139
+ }
140
+
141
+ /** Read & parse a YAML artifact, returning null if missing. */
142
+ function readYamlIfExists<T>(path: string): T | null {
143
+ if (!existsSync(path)) return null;
144
+ try {
145
+ return YAML.parse(readFileSync(path, "utf-8")) as T;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ function readArtifactSpecHash(path: string): string | null {
152
+ if (!existsSync(path)) return null;
153
+ try {
154
+ // Scan only the first 25 lines for the specHash field — it always appears
155
+ // near the top of artifact files. Avoids YAML-parsing the entire file,
156
+ // which can fail or be very slow for large catalogs (150KB+).
157
+ const raw = readFileSync(path, "utf-8");
158
+ const lines = raw.split("\n", 25);
159
+ for (const line of lines) {
160
+ const m = line.match(/^specHash:\s*["']?([0-9a-f]{64})["']?/);
161
+ if (m) return m[1]!;
162
+ }
163
+ // Fallback for non-standard layouts.
164
+ const doc = readYamlIfExists<{ specHash?: unknown }>(path);
165
+ return typeof doc?.specHash === "string" ? doc.specHash : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ function maskSecret(value: string): string {
172
+ if (value.length <= 6) return "***";
173
+ return `${"*".repeat(Math.max(value.length - 4, 4))}set`;
174
+ }
175
+
176
+ function isLikelySecret(name: string): boolean {
177
+ return /token|secret|key|password|pwd|api_key/i.test(name);
178
+ }
179
+
180
+ export async function doctorCommand(opts: DoctorOptions): Promise<number> {
181
+ try {
182
+ getDb(opts.dbPath);
183
+ } catch (err) {
184
+ const message = `DB unavailable: ${(err as Error).message}`;
185
+ if (opts.json) printJson(jsonError("doctor", [message]));
186
+ else printError(message);
187
+ return 2;
188
+ }
189
+
190
+ // Resolve target API
191
+ let apiName = opts.api;
192
+ if (!apiName) {
193
+ const cols = listCollections();
194
+ if (cols.length === 0) {
195
+ const message = "No API registered. Run `zond add api <name> --spec <path>` first.";
196
+ if (opts.json) printJson(jsonError("doctor", [message]));
197
+ else printError(message);
198
+ return 2;
199
+ }
200
+ if (cols.length > 1) {
201
+ const message = `Multiple APIs registered (${cols.map(c => c.name).join(", ")}). Pass --api <name>.`;
202
+ if (opts.json) printJson(jsonError("doctor", [message]));
203
+ else printError(message);
204
+ return 2;
205
+ }
206
+ apiName = cols[0]!.name;
207
+ }
208
+
209
+ const collection = findCollectionByNameOrId(apiName);
210
+ if (!collection) {
211
+ const message = `API '${apiName}' not found.`;
212
+ if (opts.json) printJson(jsonError("doctor", [message]));
213
+ else printError(message);
214
+ return 2;
215
+ }
216
+
217
+ const baseDir = collection.base_dir
218
+ ?? join(findWorkspaceRoot().root, "apis", apiName);
219
+
220
+ // Spec-less API: short-circuit. Such APIs are registered with --base-url
221
+ // only and have no .api-catalog/.api-resources/.api-fixtures to check. We
222
+ // surface what we have (env vars, base_dir) and tell the user how to upgrade.
223
+ if (!collection.openapi_spec) {
224
+ const envVars = await loadEnvironment(undefined, baseDir);
225
+ const recommendation =
226
+ `This API has no OpenAPI spec — generate/probe/validate-schema are disabled. ` +
227
+ `Run \`zond refresh-api ${apiName} --spec <path|url>\` to attach one.`;
228
+ const report: DoctorRunOnlyReport = {
229
+ api: apiName,
230
+ mode: "run-only",
231
+ baseDir,
232
+ envVars,
233
+ recommendation,
234
+ };
235
+ if (opts.json) {
236
+ printJson(jsonOk("doctor", report));
237
+ } else {
238
+ printRunOnlyHuman(report);
239
+ }
240
+ return 0;
241
+ }
242
+
243
+ // 1. Spec snapshot
244
+ let specAbsPath: string | null = null;
245
+ let specSha: string | null = null;
246
+ let specExists = false;
247
+ if (collection.openapi_spec) {
248
+ try {
249
+ specAbsPath = resolveCollectionSpec(collection.openapi_spec);
250
+ if (!isAbsolute(specAbsPath)) {
251
+ specAbsPath = resolve(findWorkspaceRoot().root, specAbsPath);
252
+ }
253
+ specExists = existsSync(specAbsPath);
254
+ if (specExists) {
255
+ try {
256
+ // Hash the file bytes directly — matches what setup-api / refresh-api
257
+ // record in the artifact specHash fields (TASK-215). Re-parsing and
258
+ // re-stringifying drops shared $ref identity and yields a different
259
+ // hash than the producer recorded.
260
+ specSha = hashSpec(readFileSync(specAbsPath, "utf-8"));
261
+ } catch {
262
+ // unreadable — leave sha null
263
+ }
264
+ }
265
+ } catch (err) {
266
+ // resolveCollectionSpec throws on legacy/stale workspace — that's
267
+ // exactly what doctor should report, just without crashing.
268
+ const m = (err as Error).message;
269
+ if (opts.json) printJson(jsonError("doctor", [m]));
270
+ else printError(m);
271
+ return 2;
272
+ }
273
+ }
274
+
275
+ // 2. Artifact staleness — compare each artifact's specHash to spec.json sha
276
+ const staleArtifacts: ArtifactStaleness[] = [];
277
+ for (const [file, label] of [
278
+ [".api-catalog.yaml", "catalog"],
279
+ [".api-resources.yaml", "resources"],
280
+ [".api-fixtures.yaml", "fixtures"],
281
+ ] as const) {
282
+ const path = join(baseDir, file);
283
+ if (!existsSync(path)) {
284
+ staleArtifacts.push({ file: label, expected: specSha, actual: null, fresh: false });
285
+ continue;
286
+ }
287
+ const actual = readArtifactSpecHash(path);
288
+ const fresh = !!specSha && actual === specSha;
289
+ staleArtifacts.push({ file: label, expected: specSha, actual, fresh });
290
+ }
291
+
292
+ // 3. Fixtures manifest vs .env.yaml
293
+ const manifestPath = join(baseDir, ".api-fixtures.yaml");
294
+ const manifest = readYamlIfExists<FixtureManifestShape>(manifestPath);
295
+ const envVars = await loadEnvironment(undefined, baseDir);
296
+
297
+ // TASK-172 (m-10): classify each fixture as secret / identity / plain
298
+ // env so doctor never echoes a raw secret. The secret registry was
299
+ // populated by loadEnvironment above (which loads .secrets.yaml as a
300
+ // side-effect); identity comes from `.secrets`'s sibling file.
301
+ const secretRaw = loadSecretsFromAncestor(baseDir);
302
+ const identityRaw = loadIdentityFromAncestor(baseDir);
303
+ const secretKeys = new Set(secretRaw ? Object.keys(secretRaw.values) : []);
304
+ const identityKeys = new Set(identityRaw ? Object.keys(identityRaw.values) : []);
305
+
306
+ const requiredOut: DoctorReport["fixtures"]["required"] = [];
307
+ const optionalOut: DoctorReport["fixtures"]["optional"] = [];
308
+ const declaredVars = new Set<string>();
309
+
310
+ if (manifest?.fixtures) {
311
+ for (const f of manifest.fixtures) {
312
+ declaredVars.add(f.name);
313
+ const value = envVars[f.name];
314
+ const set = typeof value === "string" && value.length > 0;
315
+ const isSecret = secretKeys.has(f.name);
316
+ const isIdentity = identityKeys.has(f.name);
317
+ const placeholder = !isSecret && looksLikePlaceholder(f.source, value);
318
+ const row: FixtureMetaRow = {
319
+ name: f.name,
320
+ set,
321
+ length: typeof value === "string" ? value.length : 0,
322
+ source: f.source,
323
+ description: f.description,
324
+ affectedEndpoints: f.affectedEndpoints ?? [],
325
+ ...(isSecret ? { secret: true as const } : {}),
326
+ ...(isIdentity ? { identity: true as const } : {}),
327
+ // Identity values stay visible (mental model: identity is for
328
+ // locally-triagable but personally-identifying data; `--redact-
329
+ // identity` swaps it for placeholders only at outbound time).
330
+ ...(!isSecret && set ? { value } : {}),
331
+ ...(placeholder ? { placeholder: true as const } : {}),
332
+ };
333
+ if (f.required) requiredOut.push(row);
334
+ else optionalOut.push(row);
335
+ }
336
+ }
337
+
338
+ const extraInEnv = Object.keys(envVars).filter(k => !declaredVars.has(k)).sort();
339
+ const blockedRequired = requiredOut.filter(r => !r.set).length;
340
+
341
+ const warnings: string[] = [];
342
+ if (!specExists) warnings.push(`spec.json not found at ${specAbsPath}`);
343
+ if (!manifest) warnings.push(`.api-fixtures.yaml missing — run \`zond refresh-api ${apiName}\``);
344
+ const placeholderRows = [...requiredOut, ...optionalOut].filter(r => r.placeholder);
345
+ if (placeholderRows.length > 0) {
346
+ warnings.push(
347
+ `${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`,
348
+ );
349
+ }
350
+
351
+ const report: DoctorReport = {
352
+ api: apiName,
353
+ mode: "spec",
354
+ baseDir,
355
+ spec: {
356
+ path: specAbsPath ?? "",
357
+ exists: specExists,
358
+ sha: specSha,
359
+ },
360
+ fixtures: {
361
+ required: requiredOut,
362
+ optional: optionalOut,
363
+ extraInEnv,
364
+ },
365
+ staleArtifacts,
366
+ blockedRequired,
367
+ warnings,
368
+ };
369
+
370
+ // TASK-145: --missing-only filters out healthy rows so the report only
371
+ // contains things the user has to fix. Applies symmetrically to text and
372
+ // JSON so `--json | jq '.data.fixtures.required'` and the stdout view
373
+ // agree on what's "noise".
374
+ const presented = opts.missingOnly ? applyMissingOnly(report) : report;
375
+
376
+ // TASK-145: --query <dotpath> short-circuits the envelope and emits the
377
+ // resolved subtree as raw JSON, no `jq` required.
378
+ if (opts.query) {
379
+ const resolved = resolveDotPath(presented, opts.query);
380
+ if (resolved === undefined) {
381
+ const message = `--query path '${opts.query}' did not resolve in the doctor report (canonical paths: api, spec, fixtures.required, fixtures.optional, fixtures.extraInEnv, staleArtifacts, warnings)`;
382
+ if (opts.json) printJson(jsonError("doctor", [message]));
383
+ else printError(message);
384
+ return 2;
385
+ }
386
+ process.stdout.write(JSON.stringify(resolved, null, 2) + "\n");
387
+ if (blockedRequired > 0) return 1;
388
+ if (staleArtifacts.some(s => !s.fresh) || !specExists || !manifest) return 2;
389
+ return 0;
390
+ }
391
+
392
+ // ── Output ──
393
+ if (opts.json) {
394
+ printJson(jsonOk("doctor", presented));
395
+ } else {
396
+ printHuman(presented, envVars, { missingOnly: opts.missingOnly === true });
397
+ }
398
+
399
+ if (blockedRequired > 0) return 1;
400
+ if (staleArtifacts.some(s => !s.fresh) || !specExists || !manifest) return 2;
401
+ return 0;
402
+ }
403
+
404
+ /** TASK-145: produce a copy of the doctor report containing only items the
405
+ * user still has to address. Filters: required fixtures with `set: false`,
406
+ * artifacts where `fresh: false`, missing spec, missing manifest. Optional
407
+ * fixtures and `extraInEnv` are dropped wholesale — they're never "missing"
408
+ * by definition. `warnings` is kept intact. */
409
+ function applyMissingOnly(r: DoctorReport): DoctorReport {
410
+ return {
411
+ ...r,
412
+ fixtures: {
413
+ required: r.fixtures.required.filter((f) => !f.set),
414
+ optional: [],
415
+ extraInEnv: [],
416
+ },
417
+ staleArtifacts: r.staleArtifacts.filter((s) => !s.fresh),
418
+ };
419
+ }
420
+
421
+ /** TASK-145: resolve a dot-path like `fixtures.required` against the report.
422
+ * Numeric segments index into arrays. Returns `undefined` when any segment
423
+ * is missing — the caller surfaces that as a CLI error. */
424
+ function resolveDotPath(value: unknown, path: string): unknown {
425
+ const parts = path.split(".").filter((p) => p.length > 0);
426
+ let cur: unknown = value;
427
+ for (const p of parts) {
428
+ if (cur === null || cur === undefined) return undefined;
429
+ if (Array.isArray(cur)) {
430
+ const idx = Number.parseInt(p, 10);
431
+ if (Number.isNaN(idx)) return undefined;
432
+ cur = cur[idx];
433
+ continue;
434
+ }
435
+ if (typeof cur !== "object") return undefined;
436
+ cur = (cur as Record<string, unknown>)[p];
437
+ }
438
+ return cur;
439
+ }
440
+
441
+ function printHuman(
442
+ r: DoctorReport,
443
+ envVars: Record<string, string>,
444
+ opts: { missingOnly: boolean } = { missingOnly: false },
445
+ ): void {
446
+ const out = process.stdout;
447
+ out.write(`API: ${r.api}\n`);
448
+ out.write(`Workspace dir: ${r.baseDir}\n\n`);
449
+
450
+ // Spec snapshot
451
+ out.write(`Spec snapshot (${r.spec.path}):\n`);
452
+ if (!r.spec.exists) {
453
+ out.write(` ✗ MISSING — run \`zond refresh-api ${r.api}\`\n\n`);
454
+ } else {
455
+ out.write(` ✓ present, sha ${r.spec.sha?.slice(0, 12) ?? "?"}…\n\n`);
456
+ }
457
+
458
+ // Artifacts
459
+ if (opts.missingOnly && r.staleArtifacts.length === 0) {
460
+ // nothing to report — skip the section
461
+ } else {
462
+ out.write(`Artifact freshness:\n`);
463
+ for (const s of r.staleArtifacts) {
464
+ if (!s.actual) {
465
+ out.write(` ✗ ${s.file}: missing\n`);
466
+ } else if (s.fresh) {
467
+ out.write(` ✓ ${s.file}: fresh\n`);
468
+ } else {
469
+ out.write(` ⚠ ${s.file}: STALE (artifact specHash ${s.actual.slice(0, 12)}… ≠ spec.json ${s.expected?.slice(0, 12)}…)\n`);
470
+ }
471
+ }
472
+ out.write("\n");
473
+ }
474
+
475
+ // Required fixtures
476
+ if (opts.missingOnly && r.fixtures.required.length === 0) {
477
+ // skip — nothing missing
478
+ } else {
479
+ out.write(`Required fixtures (${r.fixtures.required.length}):\n`);
480
+ if (r.fixtures.required.length === 0) {
481
+ out.write(` (none)\n`);
482
+ } else {
483
+ for (const f of r.fixtures.required) {
484
+ const icon = !f.set ? "✗" : f.placeholder ? "⚠" : "✓";
485
+ // TASK-172 (m-10): secrets show metadata only (set + length); identity
486
+ // is visible because the user owns those values; plain env shows raw.
487
+ const value = !f.set
488
+ ? "UNSET"
489
+ : f.secret
490
+ ? `set (${f.length} chars, secret)`
491
+ : f.identity
492
+ ? `${envVars[f.name]} (identity)`
493
+ : f.placeholder
494
+ ? `${envVars[f.name]} (placeholder — fill with a real id)`
495
+ : envVars[f.name];
496
+ const detail = f.set ? "" : ` (${f.affectedEndpoints.length === 1 && f.affectedEndpoints[0] === "*" ? "all endpoints" : `blocks ${f.affectedEndpoints.length} endpoint${f.affectedEndpoints.length === 1 ? "" : "s"}`})`;
497
+ out.write(` ${icon} ${f.name.padEnd(20)} ${String(value).padEnd(40)} [${f.source}]${detail}\n`);
498
+ }
499
+ }
500
+ out.write("\n");
501
+ }
502
+
503
+ // Optional fixtures (suppressed entirely under --missing-only — they are
504
+ // by definition never "missing" in the actionable sense).
505
+ if (!opts.missingOnly) {
506
+ out.write(`Optional fixtures (${r.fixtures.optional.length}):\n`);
507
+ if (r.fixtures.optional.length === 0) {
508
+ out.write(` (none)\n`);
509
+ } else {
510
+ for (const f of r.fixtures.optional) {
511
+ const icon = f.set ? "✓" : "⚠";
512
+ out.write(` ${icon} ${f.name.padEnd(20)} ${(f.set ? "set" : "unset").padEnd(40)} [${f.source}]\n`);
513
+ }
514
+ }
515
+ out.write("\n");
516
+ }
517
+
518
+ if (!opts.missingOnly && r.fixtures.extraInEnv.length > 0) {
519
+ out.write(`Other variables in .env.yaml (not in manifest, informational):\n`);
520
+ for (const k of r.fixtures.extraInEnv) out.write(` • ${k}\n`);
521
+ out.write("\n");
522
+ }
523
+
524
+ // Suggested next
525
+ if (opts.missingOnly && r.blockedRequired === 0 && r.staleArtifacts.length === 0) {
526
+ out.write(`No missing items. Workspace is ready.\n`);
527
+ } else if (r.blockedRequired > 0) {
528
+ // ARV-16: align with `zond coverage`, which points users at the same
529
+ // remedy. `prepare-fixtures` auto-seeds from list endpoints; manual edit
530
+ // is the fallback for fields prepare-fixtures can't infer.
531
+ 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`);
532
+ } else if (r.staleArtifacts.some(s => !s.fresh)) {
533
+ out.write(`Next: artifacts are out of sync — run \`zond refresh-api ${r.api}\`.\n`);
534
+ } else {
535
+ out.write(`All checks passed. Workspace is ready.\n`);
536
+ }
537
+
538
+ for (const w of r.warnings) out.write(`Warning: ${w}\n`);
539
+ }
540
+
541
+ function printRunOnlyHuman(r: DoctorRunOnlyReport): void {
542
+ const out = process.stdout;
543
+ out.write(`API: ${r.api}\n`);
544
+ out.write(`Mode: run-only (no OpenAPI spec)\n`);
545
+ out.write(`Workspace dir: ${r.baseDir}\n\n`);
546
+ const keys = Object.keys(r.envVars);
547
+ out.write(`Environment variables (${keys.length}):\n`);
548
+ if (keys.length === 0) {
549
+ out.write(` (none) — write \`base_url: ...\` into ${r.baseDir}/.env.yaml\n`);
550
+ } else {
551
+ for (const k of keys) {
552
+ const v = isLikelySecret(k) ? maskSecret(r.envVars[k]!) : r.envVars[k];
553
+ out.write(` • ${k.padEnd(20)} ${v}\n`);
554
+ }
555
+ }
556
+ out.write(`\n${r.recommendation}\n`);
557
+ }
558
+
559
+ import type { Command } from "commander";
560
+ import { globalJson as globalJsonResolver } from "../resolve.ts";
561
+
562
+ export function registerDoctor(program: Command): void {
563
+ program
564
+ .command("doctor")
565
+ .description("Diagnose registered API: fixture gaps in .env.yaml + artifact freshness vs spec.json")
566
+ .addHelpText(
567
+ "after",
568
+ [
569
+ "",
570
+ "JSON shape (canonical, TASK-145):",
571
+ " --json envelope is { ok, command, data, warnings, errors }. The",
572
+ " doctor payload sits under .data:",
573
+ " .data.api string",
574
+ " .data.spec.{path,exists,sha} OpenAPI snapshot",
575
+ " .data.fixtures.required[] FixtureMetaRow — each has .set",
576
+ " .data.fixtures.optional[] same shape",
577
+ " .data.fixtures.extraInEnv[] keys present in .env.yaml only",
578
+ " .data.staleArtifacts[] { file, expected, actual, fresh }",
579
+ " .data.blockedRequired number of unset required fixtures",
580
+ " .data.warnings[] advisory strings",
581
+ "",
582
+ "Tips:",
583
+ " --missing-only hide healthy rows (text + json)",
584
+ " --query fixtures.required emit one subtree as raw JSON, no jq",
585
+ ].join("\n"),
586
+ )
587
+ .option("--api <name>", "API collection name (defaults to the only registered one)")
588
+ .option("--db <path>", "Path to SQLite database file")
589
+ .option("--missing-only", "Show only missing/stale items (hide rows that are already healthy). Applies to both text and --json output.")
590
+ .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).")
591
+ .action(async (opts, cmd: Command) => {
592
+ // ARV-96: resolve --api via the shared chain (local opt > ancestor opt
593
+ // > ZOND_API_GLOBAL > ZOND_API > .zond/current-api). Without this,
594
+ // `zond --api X doctor` and `zond doctor --api X` on a multi-API
595
+ // workspace both fell through to the "Multiple APIs registered" branch
596
+ // because the global --api option (program.ts) absorbs the flag and
597
+ // leaves opts.api undefined for the subcommand.
598
+ const resolvedApi = getApi(cmd, opts);
599
+ process.exitCode = await doctorCommand({
600
+ api: resolvedApi,
601
+ dbPath: typeof opts.db === "string" ? opts.db : undefined,
602
+ json: globalJsonResolver(cmd),
603
+ missingOnly: opts.missingOnly === true,
604
+ query: typeof opts.query === "string" ? opts.query : undefined,
605
+ });
606
+ });
607
+ }