@kirrosh/zond 0.26.1 → 0.27.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 (261) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +22 -21
  3. package/bin/zond.mjs +23 -0
  4. package/package.json +13 -16
  5. package/scripts/npm/postinstall.mjs +76 -0
  6. package/src/CLAUDE.md +0 -112
  7. package/src/bun-types.d.ts +0 -5
  8. package/src/cli/argv.ts +0 -122
  9. package/src/cli/commands/add-api.ts +0 -146
  10. package/src/cli/commands/api/annotate/idempotency.ts +0 -59
  11. package/src/cli/commands/api/annotate/index.ts +0 -880
  12. package/src/cli/commands/api/annotate/lifecycle.ts +0 -74
  13. package/src/cli/commands/api/annotate/overlay.ts +0 -206
  14. package/src/cli/commands/api/annotate/pagination.ts +0 -64
  15. package/src/cli/commands/api/annotate/prompts.ts +0 -220
  16. package/src/cli/commands/api/annotate/readback.ts +0 -58
  17. package/src/cli/commands/api/annotate/resources.ts +0 -91
  18. package/src/cli/commands/api/annotate/seed-bodies.ts +0 -61
  19. package/src/cli/commands/audit.ts +0 -786
  20. package/src/cli/commands/catalog.ts +0 -97
  21. package/src/cli/commands/check.ts +0 -361
  22. package/src/cli/commands/checks.ts +0 -1072
  23. package/src/cli/commands/ci-init.ts +0 -223
  24. package/src/cli/commands/clean.ts +0 -212
  25. package/src/cli/commands/cleanup.ts +0 -236
  26. package/src/cli/commands/completions.ts +0 -192
  27. package/src/cli/commands/coverage.ts +0 -872
  28. package/src/cli/commands/db.ts +0 -611
  29. package/src/cli/commands/describe.ts +0 -120
  30. package/src/cli/commands/discover.ts +0 -1356
  31. package/src/cli/commands/doctor.ts +0 -661
  32. package/src/cli/commands/fixtures.ts +0 -402
  33. package/src/cli/commands/generate.ts +0 -577
  34. package/src/cli/commands/init/agents-md.ts +0 -61
  35. package/src/cli/commands/init/bootstrap.ts +0 -111
  36. package/src/cli/commands/init/index.ts +0 -244
  37. package/src/cli/commands/init/skills.ts +0 -141
  38. package/src/cli/commands/init/templates/agents.md +0 -86
  39. package/src/cli/commands/init/templates/markdown.d.ts +0 -4
  40. package/src/cli/commands/init/templates/skills/warm-up-target.md +0 -122
  41. package/src/cli/commands/init/templates/skills/zond-checks.md +0 -621
  42. package/src/cli/commands/init/templates/skills/zond-seed.md +0 -114
  43. package/src/cli/commands/init/templates/skills/zond-triage.md +0 -272
  44. package/src/cli/commands/init/templates/skills/zond.md +0 -861
  45. package/src/cli/commands/init/templates/zond-config.yml +0 -14
  46. package/src/cli/commands/prepare-fixtures.ts +0 -97
  47. package/src/cli/commands/probe/_seed-bodies.ts +0 -52
  48. package/src/cli/commands/probe/mass-assignment.ts +0 -594
  49. package/src/cli/commands/probe/security.ts +0 -537
  50. package/src/cli/commands/probe/static.ts +0 -255
  51. package/src/cli/commands/probe/webhooks.ts +0 -163
  52. package/src/cli/commands/probe.ts +0 -535
  53. package/src/cli/commands/reference.ts +0 -87
  54. package/src/cli/commands/refresh-api.ts +0 -227
  55. package/src/cli/commands/remove-api.ts +0 -150
  56. package/src/cli/commands/report-bundle.ts +0 -310
  57. package/src/cli/commands/report.ts +0 -241
  58. package/src/cli/commands/request.ts +0 -548
  59. package/src/cli/commands/run.ts +0 -1162
  60. package/src/cli/commands/schema-from-runs.ts +0 -128
  61. package/src/cli/commands/secrets.ts +0 -133
  62. package/src/cli/commands/session.ts +0 -244
  63. package/src/cli/commands/use.ts +0 -74
  64. package/src/cli/index.ts +0 -55
  65. package/src/cli/json-envelope.ts +0 -108
  66. package/src/cli/json-schemas.ts +0 -314
  67. package/src/cli/output.ts +0 -40
  68. package/src/cli/program.ts +0 -219
  69. package/src/cli/resolve.ts +0 -105
  70. package/src/cli/runtime.ts +0 -7
  71. package/src/cli/safe-live.ts +0 -24
  72. package/src/cli/status-filter.ts +0 -114
  73. package/src/cli/util/api-context.ts +0 -85
  74. package/src/cli/version.ts +0 -8
  75. package/src/core/audit/persist.ts +0 -183
  76. package/src/core/checks/budget.ts +0 -59
  77. package/src/core/checks/checks/_crud-helpers.ts +0 -133
  78. package/src/core/checks/checks/_negative_mutator.ts +0 -133
  79. package/src/core/checks/checks/_readback-helpers.ts +0 -133
  80. package/src/core/checks/checks/content_type_conformance.ts +0 -39
  81. package/src/core/checks/checks/cross_call_references.ts +0 -147
  82. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +0 -219
  83. package/src/core/checks/checks/ensure_resource_availability.ts +0 -62
  84. package/src/core/checks/checks/idempotency_replay.ts +0 -242
  85. package/src/core/checks/checks/ignored_auth.ts +0 -254
  86. package/src/core/checks/checks/index.ts +0 -68
  87. package/src/core/checks/checks/lifecycle_transitions.ts +0 -416
  88. package/src/core/checks/checks/missing_required_header.ts +0 -40
  89. package/src/core/checks/checks/negative_data_rejection.ts +0 -148
  90. package/src/core/checks/checks/not_a_server_error.ts +0 -35
  91. package/src/core/checks/checks/open_cors_on_sensitive.ts +0 -160
  92. package/src/core/checks/checks/pagination_invariants.ts +0 -419
  93. package/src/core/checks/checks/positive_data_acceptance.ts +0 -33
  94. package/src/core/checks/checks/rate_limit_headers_absent.ts +0 -77
  95. package/src/core/checks/checks/response_headers_conformance.ts +0 -74
  96. package/src/core/checks/checks/response_schema_conformance.ts +0 -30
  97. package/src/core/checks/checks/status_code_conformance.ts +0 -132
  98. package/src/core/checks/checks/unsupported_method.ts +0 -63
  99. package/src/core/checks/checks/use_after_free.ts +0 -78
  100. package/src/core/checks/index.ts +0 -30
  101. package/src/core/checks/mode.ts +0 -82
  102. package/src/core/checks/recommended-action.ts +0 -68
  103. package/src/core/checks/registry.ts +0 -78
  104. package/src/core/checks/runner.ts +0 -1461
  105. package/src/core/checks/sarif.ts +0 -230
  106. package/src/core/checks/spec-findings.ts +0 -308
  107. package/src/core/checks/stateful.ts +0 -121
  108. package/src/core/checks/types.ts +0 -305
  109. package/src/core/checks/zond-extensions.ts +0 -73
  110. package/src/core/classifier/recommended-action.ts +0 -251
  111. package/src/core/context/current.ts +0 -51
  112. package/src/core/context/session.ts +0 -78
  113. package/src/core/coverage/loader.ts +0 -216
  114. package/src/core/coverage/reasons.ts +0 -300
  115. package/src/core/diagnostics/db-analysis.ts +0 -666
  116. package/src/core/diagnostics/failure-class.ts +0 -140
  117. package/src/core/diagnostics/failure-hints.ts +0 -112
  118. package/src/core/diagnostics/spec-pointer.ts +0 -99
  119. package/src/core/diagnostics/suggested-fixes.ts +0 -155
  120. package/src/core/exporter/case-study/index.ts +0 -270
  121. package/src/core/exporter/curl.ts +0 -40
  122. package/src/core/exporter/exporter.ts +0 -48
  123. package/src/core/exporter/html-report/escape.ts +0 -24
  124. package/src/core/exporter/html-report/index.ts +0 -479
  125. package/src/core/exporter/html-report/script.ts +0 -100
  126. package/src/core/exporter/html-report/styles.ts +0 -408
  127. package/src/core/generator/catalog-builder.ts +0 -179
  128. package/src/core/generator/chunker.ts +0 -78
  129. package/src/core/generator/coverage-phase.ts +0 -0
  130. package/src/core/generator/coverage-scanner.ts +0 -87
  131. package/src/core/generator/data-factory.ts +0 -759
  132. package/src/core/generator/describe.ts +0 -302
  133. package/src/core/generator/endpoint-warnings.ts +0 -52
  134. package/src/core/generator/fixtures-builder.ts +0 -332
  135. package/src/core/generator/index.ts +0 -14
  136. package/src/core/generator/openapi-reader.ts +0 -297
  137. package/src/core/generator/path-param-disambig.ts +0 -140
  138. package/src/core/generator/resources-builder.ts +0 -898
  139. package/src/core/generator/schema-utils.ts +0 -112
  140. package/src/core/generator/serializer.ts +0 -343
  141. package/src/core/generator/suite-generator.ts +0 -1301
  142. package/src/core/generator/types.ts +0 -63
  143. package/src/core/identity/identity-file.ts +0 -0
  144. package/src/core/lint/affects.ts +0 -28
  145. package/src/core/lint/config.ts +0 -96
  146. package/src/core/lint/format.ts +0 -42
  147. package/src/core/lint/index.ts +0 -94
  148. package/src/core/lint/reporter.ts +0 -128
  149. package/src/core/lint/rules/consistency.ts +0 -158
  150. package/src/core/lint/rules/heuristics.ts +0 -97
  151. package/src/core/lint/rules/strictness.ts +0 -109
  152. package/src/core/lint/types.ts +0 -96
  153. package/src/core/lint/walker.ts +0 -248
  154. package/src/core/meta/meta-store.ts +0 -11
  155. package/src/core/output/README.md +0 -73
  156. package/src/core/output/index.ts +0 -13
  157. package/src/core/output/run.ts +0 -91
  158. package/src/core/output/types.ts +0 -122
  159. package/src/core/parser/dynamic-values.ts +0 -160
  160. package/src/core/parser/env-interpolation.ts +0 -104
  161. package/src/core/parser/filter.ts +0 -97
  162. package/src/core/parser/schema.ts +0 -359
  163. package/src/core/parser/types.ts +0 -117
  164. package/src/core/parser/variables.ts +0 -0
  165. package/src/core/parser/yaml-parser.ts +0 -181
  166. package/src/core/probe/bootstrap.ts +0 -34
  167. package/src/core/probe/dry-run-envelope.ts +0 -61
  168. package/src/core/probe/mass-assignment/classify.ts +0 -175
  169. package/src/core/probe/mass-assignment/cleanup.ts +0 -52
  170. package/src/core/probe/mass-assignment/digest.ts +0 -114
  171. package/src/core/probe/mass-assignment/orchestrator.ts +0 -459
  172. package/src/core/probe/mass-assignment/regression.ts +0 -141
  173. package/src/core/probe/mass-assignment/suspects.ts +0 -92
  174. package/src/core/probe/mass-assignment/types.ts +0 -135
  175. package/src/core/probe/mass-assignment-probe-class.ts +0 -198
  176. package/src/core/probe/mass-assignment-probe.ts +0 -27
  177. package/src/core/probe/mass-assignment-template.ts +0 -240
  178. package/src/core/probe/method-probe.ts +0 -164
  179. package/src/core/probe/method-shared.ts +0 -69
  180. package/src/core/probe/negative-probe.ts +0 -691
  181. package/src/core/probe/orphan-tracker.ts +0 -188
  182. package/src/core/probe/path-discovery.ts +0 -439
  183. package/src/core/probe/probe-harness.ts +0 -119
  184. package/src/core/probe/registry.ts +0 -89
  185. package/src/core/probe/runner.ts +0 -136
  186. package/src/core/probe/security/baseline.ts +0 -174
  187. package/src/core/probe/security/classify.ts +0 -341
  188. package/src/core/probe/security/cleanup.ts +0 -125
  189. package/src/core/probe/security/detectors.ts +0 -71
  190. package/src/core/probe/security/digest.ts +0 -104
  191. package/src/core/probe/security/orchestrator.ts +0 -398
  192. package/src/core/probe/security/regression.ts +0 -103
  193. package/src/core/probe/security/types.ts +0 -151
  194. package/src/core/probe/security-probe-class.ts +0 -207
  195. package/src/core/probe/security-probe.ts +0 -32
  196. package/src/core/probe/shared.ts +0 -531
  197. package/src/core/probe/static-probe-class.ts +0 -125
  198. package/src/core/probe/types.ts +0 -165
  199. package/src/core/probe/verdict-aggregator.ts +0 -33
  200. package/src/core/probe/webhooks-probe.ts +0 -282
  201. package/src/core/reporter/console.ts +0 -240
  202. package/src/core/reporter/index.ts +0 -22
  203. package/src/core/reporter/json.ts +0 -22
  204. package/src/core/reporter/junit.ts +0 -93
  205. package/src/core/reporter/ndjson.ts +0 -37
  206. package/src/core/reporter/types.ts +0 -15
  207. package/src/core/runner/assertions.ts +0 -383
  208. package/src/core/runner/async-pool.ts +0 -108
  209. package/src/core/runner/auth-path.ts +0 -8
  210. package/src/core/runner/ci-context.ts +0 -72
  211. package/src/core/runner/executor.ts +0 -652
  212. package/src/core/runner/expr-eval.ts +0 -41
  213. package/src/core/runner/form-encode.ts +0 -41
  214. package/src/core/runner/http-client.ts +0 -224
  215. package/src/core/runner/learn-drift.ts +0 -293
  216. package/src/core/runner/preflight-vars.ts +0 -153
  217. package/src/core/runner/progress-tracker.ts +0 -73
  218. package/src/core/runner/rate-limiter.ts +0 -185
  219. package/src/core/runner/run-kind.ts +0 -45
  220. package/src/core/runner/schema-validator.ts +0 -308
  221. package/src/core/runner/send-request.ts +0 -232
  222. package/src/core/runner/transforms.ts +0 -65
  223. package/src/core/runner/types.ts +0 -94
  224. package/src/core/secrets/registry.ts +0 -164
  225. package/src/core/secrets/secrets-file.ts +0 -115
  226. package/src/core/selectors/operation-filter.ts +0 -144
  227. package/src/core/setup-api.ts +0 -590
  228. package/src/core/severity/category.ts +0 -94
  229. package/src/core/severity/index.ts +0 -58
  230. package/src/core/spec/infer-schema.ts +0 -102
  231. package/src/core/spec/layers.ts +0 -154
  232. package/src/core/spec/merge-specs.ts +0 -156
  233. package/src/core/spec/schema-from-runs.ts +0 -117
  234. package/src/core/spec/schema-overlay.ts +0 -130
  235. package/src/core/util/ajv.ts +0 -13
  236. package/src/core/util/format-eta.ts +0 -21
  237. package/src/core/util/headers.ts +0 -9
  238. package/src/core/util/url.ts +0 -24
  239. package/src/core/utils.ts +0 -13
  240. package/src/core/workspace/config.ts +0 -129
  241. package/src/core/workspace/fixture-gap-report.ts +0 -84
  242. package/src/core/workspace/fixture-gaps.ts +0 -71
  243. package/src/core/workspace/manifest.ts +0 -283
  244. package/src/core/workspace/output-rotation.ts +0 -62
  245. package/src/core/workspace/root.ts +0 -96
  246. package/src/core/workspace/triage-path.ts +0 -87
  247. package/src/db/lint-runs.ts +0 -47
  248. package/src/db/migrate.ts +0 -128
  249. package/src/db/migrations/0001_run_kind.sql +0 -25
  250. package/src/db/migrations/0002_run_kind_request.sql +0 -59
  251. package/src/db/migrations/sql.d.ts +0 -4
  252. package/src/db/queries/collections.ts +0 -133
  253. package/src/db/queries/coverage.ts +0 -9
  254. package/src/db/queries/dashboard.ts +0 -59
  255. package/src/db/queries/results.ts +0 -216
  256. package/src/db/queries/runs.ts +0 -289
  257. package/src/db/queries/sessions.ts +0 -42
  258. package/src/db/queries/settings.ts +0 -28
  259. package/src/db/queries/types.ts +0 -172
  260. package/src/db/queries.ts +0 -75
  261. package/src/db/schema.ts +0 -298
@@ -1,661 +0,0 @@
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
- }