@kirrosh/zond 0.22.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.
- package/CHANGELOG.md +648 -0
- package/README.md +58 -6
- package/package.json +9 -6
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +134 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +525 -0
- package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
- package/src/cli/commands/api/annotate/overlay.ts +206 -0
- package/src/cli/commands/api/annotate/pagination.ts +60 -0
- package/src/cli/commands/api/annotate/prompts.ts +183 -0
- package/src/cli/commands/api/annotate/readback.ts +58 -0
- package/src/cli/commands/api/annotate/resources.ts +91 -0
- package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
- package/src/cli/commands/audit.ts +480 -0
- package/src/cli/commands/bootstrap.ts +710 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +348 -0
- package/src/cli/commands/checks.ts +756 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +178 -7
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1236 -0
- package/src/cli/commands/doctor.ts +607 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +420 -46
- package/src/cli/commands/init/bootstrap.ts +30 -1
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +56 -3
- package/src/cli/commands/init/templates/agents.md +65 -61
- package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
- package/src/cli/commands/init/templates/skills/zond.md +592 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +135 -0
- package/src/cli/commands/probe/mass-assignment.ts +503 -0
- package/src/cli/commands/probe/security.ts +454 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +161 -0
- package/src/cli/commands/probe.ts +459 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +169 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +318 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +379 -4
- package/src/cli/commands/run.ts +842 -53
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +18 -1
- package/src/cli/index.ts +20 -3
- package/src/cli/json-envelope.ts +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +198 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/status-filter.ts +124 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/anti-fp/bootstrap.ts +34 -0
- package/src/core/anti-fp/index.ts +33 -0
- package/src/core/anti-fp/registry.ts +44 -0
- package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
- package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
- package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
- package/src/core/anti-fp/types.ts +68 -0
- package/src/core/checks/checks/_crud-helpers.ts +133 -0
- package/src/core/checks/checks/_negative_mutator.ts +133 -0
- package/src/core/checks/checks/_readback-helpers.ts +133 -0
- package/src/core/checks/checks/content_type_conformance.ts +39 -0
- package/src/core/checks/checks/cross_call_references.ts +134 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +246 -0
- package/src/core/checks/checks/ignored_auth.ts +211 -0
- package/src/core/checks/checks/index.ts +65 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +45 -0
- package/src/core/checks/checks/not_a_server_error.ts +27 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
- package/src/core/checks/checks/pagination_invariants.ts +238 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
- package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
- package/src/core/checks/checks/response_headers_conformance.ts +74 -0
- package/src/core/checks/checks/response_schema_conformance.ts +30 -0
- package/src/core/checks/checks/status_code_conformance.ts +61 -0
- package/src/core/checks/checks/unsupported_method.ts +63 -0
- package/src/core/checks/checks/use_after_free.ts +78 -0
- package/src/core/checks/index.ts +30 -0
- package/src/core/checks/mode.ts +79 -0
- package/src/core/checks/recommended-action.ts +64 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +874 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +189 -0
- package/src/core/classifier/recommended-action.ts +222 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +185 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +151 -11
- package/src/core/diagnostics/failure-class.ts +120 -0
- package/src/core/diagnostics/failure-hints.ts +212 -9
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +156 -0
- package/src/core/exporter/case-study/index.ts +270 -0
- package/src/core/exporter/curl.ts +40 -0
- package/src/core/exporter/exporter.ts +48 -0
- package/src/core/exporter/html-report/escape.ts +24 -0
- package/src/core/exporter/html-report/index.ts +479 -0
- package/src/core/exporter/html-report/script.ts +100 -0
- package/src/core/exporter/html-report/styles.ts +408 -0
- package/src/core/generator/chunker.ts +42 -16
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/create-body.ts +89 -0
- package/src/core/generator/data-factory.ts +445 -19
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +325 -0
- package/src/core/generator/index.ts +7 -5
- package/src/core/generator/openapi-reader.ts +37 -3
- package/src/core/generator/path-param-disambig.ts +114 -0
- package/src/core/generator/resources-builder.ts +648 -0
- package/src/core/generator/schema-utils.ts +11 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +419 -111
- package/src/core/generator/types.ts +8 -0
- package/src/core/identity/identity-file.ts +129 -0
- package/src/core/lint/affects.ts +28 -0
- package/src/core/lint/config.ts +96 -0
- package/src/core/lint/format.ts +42 -0
- package/src/core/lint/index.ts +94 -0
- package/src/core/lint/reporter.ts +128 -0
- package/src/core/lint/rules/consistency.ts +158 -0
- package/src/core/lint/rules/heuristics.ts +97 -0
- package/src/core/lint/rules/strictness.ts +109 -0
- package/src/core/lint/types.ts +96 -0
- package/src/core/lint/walker.ts +248 -0
- package/src/core/meta/meta-store.ts +6 -73
- package/src/core/output/README.md +91 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +126 -0
- package/src/core/output/types.ts +129 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +129 -4
- package/src/core/parser/types.ts +19 -1
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +58 -12
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +57 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +1122 -0
- package/src/core/probe/mass-assignment-template.ts +212 -0
- package/src/core/probe/method-probe.ts +43 -76
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +183 -149
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +440 -0
- package/src/core/probe/probe-harness.ts +120 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security-probe-class.ts +201 -0
- package/src/core/probe/security-probe.ts +1453 -0
- package/src/core/probe/shared.ts +505 -0
- package/src/core/probe/static-probe-class.ts +125 -0
- package/src/core/probe/types.ts +165 -0
- package/src/core/probe/verdict-aggregator.ts +33 -0
- package/src/core/probe/webhooks-probe.ts +284 -0
- package/src/core/reporter/console.ts +41 -2
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +11 -1
- package/src/core/reporter/junit.ts +27 -12
- package/src/core/reporter/ndjson.ts +37 -0
- package/src/core/reporter/types.ts +3 -0
- package/src/core/runner/assertions.ts +58 -1
- package/src/core/runner/async-pool.ts +108 -0
- package/src/core/runner/auth-path.ts +8 -0
- package/src/core/runner/ci-context.ts +72 -0
- package/src/core/runner/executor.ts +264 -20
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +75 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +149 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +89 -17
- package/src/core/runner/run-kind.ts +39 -0
- package/src/core/runner/schema-validator.ts +312 -0
- package/src/core/runner/send-request.ts +153 -20
- package/src/core/runner/types.ts +38 -0
- package/src/core/secrets/registry.ts +164 -0
- package/src/core/secrets/secrets-file.ts +115 -0
- package/src/core/selectors/operation-filter.ts +144 -0
- package/src/core/setup-api.ts +415 -16
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +121 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +126 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/sql.d.ts +4 -0
- package/src/db/queries/collections.ts +133 -0
- package/src/db/queries/coverage.ts +9 -0
- package/src/db/queries/dashboard.ts +59 -0
- package/src/db/queries/results.ts +128 -0
- package/src/db/queries/runs.ts +235 -0
- package/src/db/queries/sessions.ts +42 -0
- package/src/db/queries/settings.ts +28 -0
- package/src/db/queries/types.ts +172 -0
- package/src/db/queries.ts +72 -802
- package/src/db/schema.ts +178 -50
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
- package/src/cli/commands/probe-methods.ts +0 -108
- package/src/cli/commands/probe-validation.ts +0 -124
- package/src/cli/commands/serve.ts +0 -114
- package/src/cli/commands/sync.ts +0 -268
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/diagnostics/render-md.ts +0 -112
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -19
- package/src/core/parser/index.ts +0 -21
- package/src/core/runner/execute-run.ts +0 -132
- package/src/core/runner/index.ts +0 -12
- package/src/core/sync/spec-differ.ts +0 -38
- package/src/web/data/collection-state.ts +0 -362
- package/src/web/routes/api.ts +0 -314
- package/src/web/routes/dashboard.ts +0 -350
- package/src/web/routes/runs.ts +0 -64
- package/src/web/schemas.ts +0 -121
- package/src/web/server.ts +0 -134
- package/src/web/static/htmx.min.cjs +0 -1
- package/src/web/static/style.css +0 -1148
- package/src/web/views/endpoints-tab.ts +0 -174
- package/src/web/views/explorer-tab.ts +0 -402
- package/src/web/views/health-strip.ts +0 -92
- package/src/web/views/layout.ts +0 -48
- package/src/web/views/results.ts +0 -210
- package/src/web/views/runs-tab.ts +0 -126
- 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
|
+
}
|