@kirrosh/zond 0.22.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +811 -0
- package/README.md +59 -6
- package/package.json +9 -7
- package/src/CLAUDE.md +112 -0
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +146 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +880 -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 +64 -0
- package/src/cli/commands/api/annotate/prompts.ts +220 -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 +786 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +361 -0
- package/src/cli/commands/checks.ts +1072 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +236 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +823 -132
- package/src/cli/commands/db.ts +486 -12
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1356 -0
- package/src/cli/commands/doctor.ts +661 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +438 -47
- package/src/cli/commands/init/bootstrap.ts +34 -2
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +99 -3
- package/src/cli/commands/init/templates/agents.md +77 -64
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
- package/src/cli/commands/init/templates/skills/zond.md +802 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +97 -0
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +594 -0
- package/src/cli/commands/probe/security.ts +537 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +163 -0
- package/src/cli/commands/probe.ts +535 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +227 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +310 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +495 -4
- package/src/cli/commands/run.ts +870 -53
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- 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 +92 -3
- package/src/cli/json-schemas.ts +314 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +199 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +114 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -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 +147 -0
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +242 -0
- package/src/core/checks/checks/ignored_auth.ts +254 -0
- package/src/core/checks/checks/index.ts +68 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +148 -0
- package/src/core/checks/checks/not_a_server_error.ts +35 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
- package/src/core/checks/checks/pagination_invariants.ts +419 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +33 -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 +132 -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 +82 -0
- package/src/core/checks/recommended-action.ts +68 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +1461 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +305 -0
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +251 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +216 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +293 -59
- package/src/core/diagnostics/failure-class.ts +140 -0
- package/src/core/diagnostics/failure-hints.ts +88 -89
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +155 -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 +38 -19
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/data-factory.ts +586 -22
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +332 -0
- package/src/core/generator/index.ts +5 -5
- package/src/core/generator/openapi-reader.ts +135 -7
- package/src/core/generator/path-param-disambig.ts +140 -0
- package/src/core/generator/resources-builder.ts +898 -0
- package/src/core/generator/schema-utils.ts +33 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +583 -122
- package/src/core/generator/types.ts +14 -0
- package/src/core/identity/identity-file.ts +0 -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 +73 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +91 -0
- package/src/core/output/types.ts +122 -0
- package/src/core/parser/dynamic-values.ts +160 -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 +61 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +27 -0
- package/src/core/probe/mass-assignment-template.ts +240 -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 +439 -0
- package/src/core/probe/probe-harness.ts +119 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +207 -0
- package/src/core/probe/security-probe.ts +32 -0
- package/src/core/probe/shared.ts +531 -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 +282 -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 +59 -2
- 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 +265 -36
- package/src/core/runner/form-encode.ts +41 -0
- package/src/core/runner/http-client.ts +112 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +153 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +87 -33
- package/src/core/runner/run-kind.ts +45 -0
- package/src/core/runner/schema-validator.ts +308 -0
- package/src/core/runner/send-request.ts +158 -20
- package/src/core/runner/types.ts +44 -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 +457 -20
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +58 -0
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +128 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -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 +216 -0
- package/src/db/queries/runs.ts +289 -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 +75 -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,1356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zond discover` — auto-fill `.env.yaml` from list-endpoints (TASK-136).
|
|
3
|
+
*
|
|
4
|
+
* Phase 2.5 of the audit flow used to be manual: `zond request GET /audiences`,
|
|
5
|
+
* pluck the slug, paste into `.env.yaml`, repeat for every FK. ~15 min per
|
|
6
|
+
* API. This command walks the resource map (`.api-resources.yaml`), hits
|
|
7
|
+
* every owner-resource list-endpoint with the user's auth token, extracts
|
|
8
|
+
* the first id, and proposes a diff. By default dry-run; `--apply` writes
|
|
9
|
+
* with a `.env.yaml.bak` backup.
|
|
10
|
+
*
|
|
11
|
+
* Scope (v1):
|
|
12
|
+
* - Only list-endpoints with no path-params (collection-level GETs).
|
|
13
|
+
* - Only FK vars whose owner is identified in `.api-resources.yaml`.
|
|
14
|
+
* - Skips vars already present in `.env.yaml` unless their value is empty
|
|
15
|
+
* or a `# TODO` placeholder.
|
|
16
|
+
*/
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { copyFile } from "fs/promises";
|
|
19
|
+
import {
|
|
20
|
+
readOpenApiSpec,
|
|
21
|
+
extractEndpoints,
|
|
22
|
+
extractSecuritySchemes,
|
|
23
|
+
} from "../../core/generator/index.ts";
|
|
24
|
+
import { loadEnvFile } from "../../core/parser/variables.ts";
|
|
25
|
+
import {
|
|
26
|
+
composeSpec,
|
|
27
|
+
type ComposedSpec,
|
|
28
|
+
type SpecLayer,
|
|
29
|
+
} from "../../core/spec/layers.ts";
|
|
30
|
+
import { liveAuthHeaders } from "../../core/probe/shared.ts";
|
|
31
|
+
import { executeRequest } from "../../core/runner/http-client.ts";
|
|
32
|
+
import { writeFixtureGaps, type FixtureGap } from "../../core/workspace/fixture-gaps.ts";
|
|
33
|
+
import { reportFixtureGaps, type FixtureGapReport } from "../../core/workspace/fixture-gap-report.ts";
|
|
34
|
+
import { parseSafe } from "../../core/parser/yaml-parser.ts";
|
|
35
|
+
|
|
36
|
+
/** Strip a trailing FK-shape suffix (`_id`, `Id`, `_uuid`, `_slug`, `_name`,
|
|
37
|
+
* `_code`) from a var name and return the stem. Used by ARV-69 to find an
|
|
38
|
+
* owner resource when the resource map doesn't link the var to a list
|
|
39
|
+
* endpoint explicitly (common-style {id} placeholders).
|
|
40
|
+
*/
|
|
41
|
+
function stemFromVarName(varName: string): string | null {
|
|
42
|
+
const lower = varName.toLowerCase();
|
|
43
|
+
for (const suffix of ["_id", "_uuid", "_slug", "_name", "_code"]) {
|
|
44
|
+
if (lower.endsWith(suffix)) return lower.slice(0, -suffix.length);
|
|
45
|
+
}
|
|
46
|
+
// CamelCase: `domainId` → `domain`.
|
|
47
|
+
const m = varName.match(/^(.+?)(Id|Uuid)$/);
|
|
48
|
+
if (m) return m[1]!.toLowerCase();
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** ARV-69 (feedback round-02 / F10): try to find a resource whose
|
|
53
|
+
* list endpoint is a plausible source for `varName` based on the var's
|
|
54
|
+
* name stem. Matches singular ↔ plural and is case-insensitive. Returns
|
|
55
|
+
* the FkTarget on hit, undefined on miss.
|
|
56
|
+
*/
|
|
57
|
+
export function inferOwnerFromVarName(
|
|
58
|
+
varName: string,
|
|
59
|
+
map: ApiResourceMapYaml,
|
|
60
|
+
): FkTarget | undefined {
|
|
61
|
+
const stem = stemFromVarName(varName);
|
|
62
|
+
if (!stem) return undefined;
|
|
63
|
+
const candidates = new Set([stem, `${stem}s`, stem.endsWith("s") ? stem.slice(0, -1) : stem]);
|
|
64
|
+
for (const r of map.resources) {
|
|
65
|
+
if (!r.endpoints?.list) continue;
|
|
66
|
+
const lower = r.resource.toLowerCase();
|
|
67
|
+
if (candidates.has(lower)) {
|
|
68
|
+
return { varName, ownerResource: r.resource, listLabel: r.endpoints.list };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Return the first object in a list-response (bare array or a `data`/`items`/
|
|
75
|
+
* `results`/`records` envelope), or undefined. Distinguishes a non-empty
|
|
76
|
+
* recognized list ("resource exists, agent must pick a value") from an
|
|
77
|
+
* unrecognized shape. */
|
|
78
|
+
function firstListItem(body: unknown): Record<string, unknown> | undefined {
|
|
79
|
+
const pick = (arr: unknown): Record<string, unknown> | undefined =>
|
|
80
|
+
Array.isArray(arr) && arr[0] && typeof arr[0] === "object"
|
|
81
|
+
? (arr[0] as Record<string, unknown>)
|
|
82
|
+
: undefined;
|
|
83
|
+
if (Array.isArray(body)) return pick(body);
|
|
84
|
+
if (body && typeof body === "object") {
|
|
85
|
+
const obj = body as Record<string, unknown>;
|
|
86
|
+
for (const key of ["data", "items", "results", "records"]) {
|
|
87
|
+
const hit = pick(obj[key]);
|
|
88
|
+
if (hit) return hit;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True when the list-response is well-shaped but contains zero items.
|
|
95
|
+
* Used to distinguish "no <entity> in target API yet — go create one"
|
|
96
|
+
* from "response shape unrecognized" (TASK-273). */
|
|
97
|
+
export function isEmptyListBody(body: unknown): boolean {
|
|
98
|
+
if (Array.isArray(body)) return body.length === 0;
|
|
99
|
+
if (body && typeof body === "object") {
|
|
100
|
+
const obj = body as Record<string, unknown>;
|
|
101
|
+
for (const key of ["data", "items", "results", "records"]) {
|
|
102
|
+
if (key in obj) {
|
|
103
|
+
const arr = obj[key];
|
|
104
|
+
return Array.isArray(arr) && arr.length === 0;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
import { printError, printSuccess, printWarning } from "../output.ts";
|
|
111
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
112
|
+
import { getSecretRegistry } from "../../core/secrets/registry.ts";
|
|
113
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../../core/generator/types.ts";
|
|
114
|
+
import type { RecommendedAction } from "../../core/diagnostics/failure-hints.ts";
|
|
115
|
+
|
|
116
|
+
export interface DiscoverOptions {
|
|
117
|
+
specPath: string;
|
|
118
|
+
/** Path to `apis/<name>/` — used to read .api-resources.yaml and write .env.yaml. */
|
|
119
|
+
apiDir: string;
|
|
120
|
+
/** Default `apis/<name>/.env.yaml`. */
|
|
121
|
+
envPath?: string;
|
|
122
|
+
apply?: boolean;
|
|
123
|
+
/** Per-request timeout (ms). */
|
|
124
|
+
timeoutMs?: number;
|
|
125
|
+
json?: boolean;
|
|
126
|
+
/** TASK-281: GET each fixture's read-by-id endpoint to classify live/stale/
|
|
127
|
+
* unknown. Without `--apply` this is a read-only report; with `--apply` (or
|
|
128
|
+
* the `--refresh` shortcut) stale fixtures are unset and re-resolved
|
|
129
|
+
* through the normal discover flow. */
|
|
130
|
+
verify?: boolean;
|
|
131
|
+
/** ARV-205/F19 (R10/R13/R14): command name surfaced in the JSON envelope.
|
|
132
|
+
* prepare-fixtures delegates here for the single-pass path; the envelope
|
|
133
|
+
* should reflect the user-facing command, not the internal "discover". */
|
|
134
|
+
commandName?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface FkTarget {
|
|
138
|
+
/** Env var name to fill (e.g. `audience_id`). */
|
|
139
|
+
varName: string;
|
|
140
|
+
/** Resource that owns the id (e.g. `audiences`). */
|
|
141
|
+
ownerResource: string;
|
|
142
|
+
/** List endpoint label, e.g. `GET /audiences`. */
|
|
143
|
+
listLabel: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface DiscoveryItem {
|
|
147
|
+
varName: string;
|
|
148
|
+
resource: string;
|
|
149
|
+
listPath: string;
|
|
150
|
+
/** What was found, if anything. */
|
|
151
|
+
discovered?: string;
|
|
152
|
+
/** What's currently in env (may be empty/placeholder). */
|
|
153
|
+
current?: string;
|
|
154
|
+
/** Gap/verify classification. ARV-362 (m-25): discover never harvests a
|
|
155
|
+
* value — the write path is gone. Non-empty lists surface as
|
|
156
|
+
* `miss-needs-value` (resource exists, agent picks); everything else is a
|
|
157
|
+
* verify state or a miss-* gap. */
|
|
158
|
+
status:
|
|
159
|
+
| "skip-already-set"
|
|
160
|
+
| "skip-not-required"
|
|
161
|
+
| "miss-no-list"
|
|
162
|
+
| "miss-nested-list"
|
|
163
|
+
| "miss-no-owner"
|
|
164
|
+
| "miss-network"
|
|
165
|
+
| "miss-status"
|
|
166
|
+
| "miss-empty"
|
|
167
|
+
| "miss-no-id"
|
|
168
|
+
// ARV-362: got a non-empty list, but which record/field fills the slot is
|
|
169
|
+
// the agent's call (ARV-334 lived here). discover reports the gap.
|
|
170
|
+
| "miss-needs-value"
|
|
171
|
+
// TASK-281 verify-mode states
|
|
172
|
+
| "verify-live"
|
|
173
|
+
| "verify-stale"
|
|
174
|
+
| "verify-unknown"
|
|
175
|
+
| "verify-no-read"
|
|
176
|
+
| "verify-skip-empty"
|
|
177
|
+
// ARV-143: filled var classified as trusted user input — manifest source
|
|
178
|
+
// is user-config (auth/server/header) or there's no read-by-id endpoint
|
|
179
|
+
// for its resource. Refresh has no verification path, so we mark it as
|
|
180
|
+
// such instead of silently omitting (the doctor view that says set:true).
|
|
181
|
+
| "verify-user-config";
|
|
182
|
+
/** ARV-46: manifest-grade status enum projected onto agent-readable
|
|
183
|
+
* envelope. Filled when discover ran in manifest-driven mode.
|
|
184
|
+
* filled | failed:no-list-endpoint | failed:list-empty | failed:miss-network
|
|
185
|
+
* | skipped:already-set | skipped:not-required */
|
|
186
|
+
manifestStatus?: ManifestStatus;
|
|
187
|
+
/** ARV-46: source classification copied from `.api-fixtures.yaml`. */
|
|
188
|
+
manifestSource?: FixtureManifestEntry["source"];
|
|
189
|
+
reason?: string;
|
|
190
|
+
/** ARV-382: when zond can't CONFIDENTLY derive the owner list endpoint
|
|
191
|
+
* (miss-no-list), it surfaces the plausible GET/list endpoints — ranked by
|
|
192
|
+
* structural proximity to where the id is consumed — instead of dead-ending.
|
|
193
|
+
* zond does NOT pick a value; the agent reads these, picks one, and sets the
|
|
194
|
+
* fixture (or fires one `zond request` against the top candidate). Empty
|
|
195
|
+
* when nothing structurally plausible exists. */
|
|
196
|
+
candidates?: string[];
|
|
197
|
+
/** TASK-294: agent-routable action for items the user must fix.
|
|
198
|
+
* miss-* / verify-stale / verify-unknown → `fix_fixture`.
|
|
199
|
+
* miss-network → `fix_network_config`.
|
|
200
|
+
* skip-* / verify-live → undefined. */
|
|
201
|
+
recommended_action?: RecommendedAction;
|
|
202
|
+
/** ARV-362: set when --refresh drops (unsets) a stale fixture. Marks which
|
|
203
|
+
* vars to unset on disk and feeds the summary `dropped` count. */
|
|
204
|
+
wasStale?: boolean;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** TASK-294: derive recommended_action from a DiscoveryItem's status. */
|
|
208
|
+
export function discoveryAction(status: DiscoveryItem["status"]): RecommendedAction | undefined {
|
|
209
|
+
if (status === "miss-network") return "fix_network_config";
|
|
210
|
+
if (status.startsWith("miss-") || status === "verify-stale" || status === "verify-unknown") {
|
|
211
|
+
return "fix_fixture";
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** ARV-46: stable manifest-grade status enum for agent consumers. The CLI
|
|
217
|
+
* prints this column when discover runs in manifest-driven mode and it's
|
|
218
|
+
* exposed verbatim in the JSON envelope. */
|
|
219
|
+
export type ManifestStatus =
|
|
220
|
+
| "failed:no-list-endpoint"
|
|
221
|
+
| "failed:list-empty"
|
|
222
|
+
| "failed:needs-value"
|
|
223
|
+
| "failed:miss-network"
|
|
224
|
+
| "skipped:already-set"
|
|
225
|
+
| "skipped:not-required";
|
|
226
|
+
|
|
227
|
+
export function toManifestStatus(status: DiscoveryItem["status"]): ManifestStatus {
|
|
228
|
+
switch (status) {
|
|
229
|
+
case "skip-already-set":
|
|
230
|
+
return "skipped:already-set";
|
|
231
|
+
case "skip-not-required":
|
|
232
|
+
return "skipped:not-required";
|
|
233
|
+
case "miss-network":
|
|
234
|
+
return "failed:miss-network";
|
|
235
|
+
case "miss-empty":
|
|
236
|
+
return "failed:list-empty";
|
|
237
|
+
// ARV-362: list has records but discover won't pick — the agent fills it.
|
|
238
|
+
case "miss-needs-value":
|
|
239
|
+
return "failed:needs-value";
|
|
240
|
+
// miss-no-list / miss-nested-list / miss-no-owner / miss-status / miss-no-id —
|
|
241
|
+
// the underlying cause is "we have no usable list endpoint to read from", so
|
|
242
|
+
// they collapse onto the same manifest-level bucket.
|
|
243
|
+
default:
|
|
244
|
+
return "failed:no-list-endpoint";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** ARV-324: project confirmed-empty/inaccessible/needs-value list-probes into
|
|
249
|
+
* the `.fixture-gaps.yaml` shape. `miss-status` (list endpoint rejected the
|
|
250
|
+
* request), `miss-empty` (200 with an empty collection) and `miss-needs-value`
|
|
251
|
+
* (ARV-362: 200 with records the agent must pick from) all carry an actual
|
|
252
|
+
* observed response worth cross-referencing against a later `checks run` —
|
|
253
|
+
* the other miss-* statuses mean "we never even sent a request" (no list
|
|
254
|
+
* endpoint / no owner resource). De-dupes by (method, path); a later pass's
|
|
255
|
+
* entry for the same var wins. */
|
|
256
|
+
export function gapsFromItems(items: DiscoveryItem[]): FixtureGap[] {
|
|
257
|
+
const byKey = new Map<string, FixtureGap>();
|
|
258
|
+
for (const item of items) {
|
|
259
|
+
if (
|
|
260
|
+
item.status !== "miss-status" &&
|
|
261
|
+
item.status !== "miss-empty" &&
|
|
262
|
+
item.status !== "miss-needs-value"
|
|
263
|
+
) continue;
|
|
264
|
+
if (!item.listPath) continue;
|
|
265
|
+
byKey.set(`GET ${item.listPath}`, {
|
|
266
|
+
method: "GET",
|
|
267
|
+
path: item.listPath,
|
|
268
|
+
resource: item.resource,
|
|
269
|
+
var: item.varName,
|
|
270
|
+
reason: item.reason ?? item.status,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return [...byKey.values()];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function isPlaceholder(value: string | undefined): boolean {
|
|
277
|
+
if (!value) return true;
|
|
278
|
+
const trimmed = value.trim();
|
|
279
|
+
if (trimmed === "") return true;
|
|
280
|
+
// `var: "" # TODO: fill in` lands as "" after YAML parse.
|
|
281
|
+
if (/^TODO/i.test(trimmed)) return true;
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseEndpointLabel(label: string): { method: string; path: string } | null {
|
|
286
|
+
const parts = label.trim().split(/\s+/);
|
|
287
|
+
if (parts.length < 2) return null;
|
|
288
|
+
return { method: parts[0]!.toUpperCase(), path: parts[1]! };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface ResourceYaml {
|
|
292
|
+
resource: string;
|
|
293
|
+
basePath: string;
|
|
294
|
+
itemPath: string;
|
|
295
|
+
idParam: string;
|
|
296
|
+
captureField?: string;
|
|
297
|
+
hasFullCrud?: boolean;
|
|
298
|
+
endpoints: { list?: string; create?: string; read?: string; update?: string; delete?: string };
|
|
299
|
+
fkDependencies: Array<{ var: string; param: string; in: "path" | "body"; ownerResource: string | null }>;
|
|
300
|
+
/** ARV-169: optional POST→GET drift overrides. snake_case to match
|
|
301
|
+
* yaml on disk; loaders preserve as-is so the check can read it. */
|
|
302
|
+
readback_diff?: {
|
|
303
|
+
ignore_fields?: string[];
|
|
304
|
+
write_to_read_map?: Record<string, string>;
|
|
305
|
+
};
|
|
306
|
+
/** ARV-170: opt-in idempotency-replay probe for this resource's
|
|
307
|
+
* create endpoint. */
|
|
308
|
+
idempotency?: {
|
|
309
|
+
header?: string;
|
|
310
|
+
scope?: "endpoint" | "global";
|
|
311
|
+
ignore_response_fields?: string[];
|
|
312
|
+
};
|
|
313
|
+
/** ARV-171: pagination-invariants probe for this resource's list
|
|
314
|
+
* endpoint. */
|
|
315
|
+
pagination?: {
|
|
316
|
+
type?: "cursor" | "page" | "offset" | "token";
|
|
317
|
+
cursor_param?: string;
|
|
318
|
+
cursor_field?: string;
|
|
319
|
+
has_more_field?: string;
|
|
320
|
+
limit_param?: string;
|
|
321
|
+
default_limit?: number;
|
|
322
|
+
items_field?: string;
|
|
323
|
+
page_param?: string;
|
|
324
|
+
start_page?: number;
|
|
325
|
+
};
|
|
326
|
+
/** ARV-172: per-resource state machine + action endpoints. */
|
|
327
|
+
lifecycle?: {
|
|
328
|
+
field: string;
|
|
329
|
+
states: string[];
|
|
330
|
+
transitions: Array<{ from: string; to: string[] }>;
|
|
331
|
+
actions: Record<string, {
|
|
332
|
+
endpoint: string;
|
|
333
|
+
expected_state: string;
|
|
334
|
+
body?: Record<string, unknown>;
|
|
335
|
+
}>;
|
|
336
|
+
};
|
|
337
|
+
/** ARV-187: LLM-authored example POST body for stateful checks that
|
|
338
|
+
* need a valid create payload. When present, stateful CRUD checks
|
|
339
|
+
* (cross_call_references, idempotency_replay, lifecycle_transitions,
|
|
340
|
+
* ensure_resource_availability, use_after_free) prefer this over
|
|
341
|
+
* `generateFromSchema(create.requestBodySchema)`. The fallback path
|
|
342
|
+
* stays — yaml is purely additive. `content_type` defaults to the
|
|
343
|
+
* create endpoint's `requestBodyContentType`. */
|
|
344
|
+
seed_body?: {
|
|
345
|
+
content_type?: string;
|
|
346
|
+
body: Record<string, unknown>;
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export interface ApiResourceMapYaml {
|
|
351
|
+
resources: ResourceYaml[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** ARV-122 layer ids — exported so downstream code (doctor, future
|
|
355
|
+
* catalog --provenance) can compare against the provenance map
|
|
356
|
+
* without re-typing the strings. */
|
|
357
|
+
export const RESOURCE_LAYER_UPSTREAM = "upstream";
|
|
358
|
+
export const RESOURCE_LAYER_EXTENSION = "extension";
|
|
359
|
+
|
|
360
|
+
/** ARV-122: build the two-layer SpecLayer set for an API's resource
|
|
361
|
+
* map. Kept here (and not in `core/spec/layers.ts`) so the YAML
|
|
362
|
+
* loaders stay co-located with the schema types they parse. */
|
|
363
|
+
function buildResourceLayers(apiDir: string): SpecLayer<ResourceYaml>[] {
|
|
364
|
+
return [
|
|
365
|
+
{
|
|
366
|
+
id: RESOURCE_LAYER_UPSTREAM,
|
|
367
|
+
path: join(apiDir, ".api-resources.yaml"),
|
|
368
|
+
precedence: 10,
|
|
369
|
+
scope: "resources",
|
|
370
|
+
mergePolicy: "override",
|
|
371
|
+
load: async () => {
|
|
372
|
+
const file = Bun.file(join(apiDir, ".api-resources.yaml"));
|
|
373
|
+
if (!(await file.exists())) return [];
|
|
374
|
+
const parsed = Bun.YAML.parse(await file.text());
|
|
375
|
+
if (!parsed || typeof parsed !== "object") return [];
|
|
376
|
+
return (parsed as { resources?: ResourceYaml[] }).resources ?? [];
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
id: RESOURCE_LAYER_EXTENSION,
|
|
381
|
+
path: join(apiDir, ".api-resources.local.yaml"),
|
|
382
|
+
precedence: 20,
|
|
383
|
+
scope: "resources",
|
|
384
|
+
mergePolicy: "override",
|
|
385
|
+
load: () => readResourceExtensions(apiDir),
|
|
386
|
+
},
|
|
387
|
+
];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** ARV-122: compose the resource map through the SpecLayer pipeline,
|
|
391
|
+
* exposing the provenance map for callers that need to know which
|
|
392
|
+
* layer contributed a given resource (doctor diagnostics, m-18 CLI
|
|
393
|
+
* surface). `readResourceMap` keeps the legacy shape for callers
|
|
394
|
+
* that don't care. */
|
|
395
|
+
export async function composeResourceMap(
|
|
396
|
+
apiDir: string,
|
|
397
|
+
): Promise<ComposedSpec<ResourceYaml>> {
|
|
398
|
+
return composeSpec(buildResourceLayers(apiDir), (r) => r.resource);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function readResourceMap(apiDir: string): Promise<ApiResourceMapYaml | null> {
|
|
402
|
+
// Old contract: return null when the upstream `.api-resources.yaml`
|
|
403
|
+
// is missing (callers branch on this to surface a setup error). The
|
|
404
|
+
// SpecLayer pipeline returns an empty list in that case, so check
|
|
405
|
+
// existence explicitly to preserve behaviour.
|
|
406
|
+
const upstream = Bun.file(join(apiDir, ".api-resources.yaml"));
|
|
407
|
+
if (!(await upstream.exists())) return null;
|
|
408
|
+
|
|
409
|
+
// ARV-122: route the merge through composeSpec. Behaviour is
|
|
410
|
+
// identical to the previous ad-hoc Map merge — extension wins on
|
|
411
|
+
// name collision (precedence 20 > 10, mergePolicy: "override") —
|
|
412
|
+
// and the same path also feeds provenance into composeResourceMap.
|
|
413
|
+
const composed = await composeResourceMap(apiDir);
|
|
414
|
+
// ARV-169: field-level overlay for adding readback_diff / idempotency
|
|
415
|
+
// / pagination / lifecycle without re-declaring the whole entry.
|
|
416
|
+
const patches = await readResourcePatches(apiDir);
|
|
417
|
+
return { resources: applyResourcePatches(composed.entries, patches) };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** ARV-111: read `apis/<name>/.api-resources.local.yaml`. Same `resources:`
|
|
421
|
+
* shape as the main file (top-level `extensions:` key is the only
|
|
422
|
+
* difference, so the user can recognise it as a sibling). Returns [] when
|
|
423
|
+
* missing or empty so the merge path stays simple. */
|
|
424
|
+
export async function readResourceExtensions(apiDir: string): Promise<ResourceYaml[]> {
|
|
425
|
+
const path = join(apiDir, ".api-resources.local.yaml");
|
|
426
|
+
const file = Bun.file(path);
|
|
427
|
+
if (!(await file.exists())) return [];
|
|
428
|
+
const text = await file.text();
|
|
429
|
+
const parsed = Bun.YAML.parse(text);
|
|
430
|
+
if (!parsed || typeof parsed !== "object") return [];
|
|
431
|
+
const obj = parsed as { extensions?: ResourceYaml[] };
|
|
432
|
+
return obj.extensions ?? [];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** ARV-169 (m-20): partial overlay for adding fields (readback_diff,
|
|
436
|
+
* future idempotency / pagination / lifecycle) to an existing
|
|
437
|
+
* resource entry without re-declaring its CRUD wiring. Lives in the
|
|
438
|
+
* same `.api-resources.local.yaml` under top-level `patches:`. Each
|
|
439
|
+
* entry MUST carry `resource:` (the merge key); any other declared
|
|
440
|
+
* field overlays the upstream value, leaving omitted fields intact.
|
|
441
|
+
*
|
|
442
|
+
* Unlike `extensions:` (full replacement, ARV-111) this is field-
|
|
443
|
+
* level merge. Both can coexist in the same file. Returns [] when
|
|
444
|
+
* the file is missing or carries no `patches:` key. */
|
|
445
|
+
export async function readResourcePatches(apiDir: string): Promise<Array<Partial<ResourceYaml> & { resource: string }>> {
|
|
446
|
+
const path = join(apiDir, ".api-resources.local.yaml");
|
|
447
|
+
const file = Bun.file(path);
|
|
448
|
+
if (!(await file.exists())) return [];
|
|
449
|
+
const text = await file.text();
|
|
450
|
+
const parsed = Bun.YAML.parse(text);
|
|
451
|
+
if (!parsed || typeof parsed !== "object") return [];
|
|
452
|
+
const obj = parsed as { patches?: Array<Partial<ResourceYaml> & { resource?: string }> };
|
|
453
|
+
const raw = obj.patches ?? [];
|
|
454
|
+
return raw.filter((p): p is Partial<ResourceYaml> & { resource: string } =>
|
|
455
|
+
typeof p?.resource === "string" && p.resource.length > 0,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** ARV-169: apply partial patches over a composed resource list.
|
|
460
|
+
* Patch fields overwrite matching upstream fields; absent fields
|
|
461
|
+
* are preserved. Patches whose `resource` doesn't match anything
|
|
462
|
+
* upstream are dropped silently — callers wanting to ADD a whole
|
|
463
|
+
* resource use `extensions:` instead. */
|
|
464
|
+
function applyResourcePatches(
|
|
465
|
+
resources: ResourceYaml[],
|
|
466
|
+
patches: Array<Partial<ResourceYaml> & { resource: string }>,
|
|
467
|
+
): ResourceYaml[] {
|
|
468
|
+
if (patches.length === 0) return resources;
|
|
469
|
+
const byName = new Map(resources.map((r) => [r.resource, r] as const));
|
|
470
|
+
for (const p of patches) {
|
|
471
|
+
const upstream = byName.get(p.resource);
|
|
472
|
+
if (!upstream) continue;
|
|
473
|
+
byName.set(p.resource, { ...upstream, ...p });
|
|
474
|
+
}
|
|
475
|
+
return resources.map((r) => byName.get(r.resource) ?? r);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export interface FixtureManifestEntry {
|
|
479
|
+
name: string;
|
|
480
|
+
source: "auth" | "server" | "path" | "header" | "body-fk" | "capture-chain";
|
|
481
|
+
required: boolean;
|
|
482
|
+
description?: string;
|
|
483
|
+
defaultValue?: string;
|
|
484
|
+
affectedEndpoints?: string[];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export interface FixtureManifestYaml {
|
|
488
|
+
fixtures: FixtureManifestEntry[];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Read `.api-fixtures.yaml`. Returns null when missing — caller falls back
|
|
492
|
+
* to the legacy resource-map-driven path. */
|
|
493
|
+
export async function readFixtureManifest(apiDir: string): Promise<FixtureManifestYaml | null> {
|
|
494
|
+
const path = join(apiDir, ".api-fixtures.yaml");
|
|
495
|
+
const file = Bun.file(path);
|
|
496
|
+
if (!(await file.exists())) return null;
|
|
497
|
+
const text = await file.text();
|
|
498
|
+
const parsed = Bun.YAML.parse(text);
|
|
499
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
500
|
+
const obj = parsed as { fixtures?: FixtureManifestEntry[] };
|
|
501
|
+
return { fixtures: obj.fixtures ?? [] };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Build the unique target list from FK deps. Each FK var = one discovery
|
|
505
|
+
* attempt (we hit the owner's list endpoint once and reuse the result).
|
|
506
|
+
*
|
|
507
|
+
* ARV-133: also include each resource's own idParam (when it has a list
|
|
508
|
+
* endpoint) — these are root-level required path-params with no fkDep edge
|
|
509
|
+
* to another resource, but they're trivially harvestable from the resource's
|
|
510
|
+
* own list endpoint. Without this, cascade silently skipped vars like
|
|
511
|
+
* `domain_id`, `webhook_id`, `template_id` even though `/domains`,
|
|
512
|
+
* `/webhooks`, `/templates` returned live data. Optional `manifest`
|
|
513
|
+
* parameter wires manifest-required path/body-fk vars onto a list endpoint
|
|
514
|
+
* via `inferOwnerFromVarName` (singular ↔ plural matching) so vars whose
|
|
515
|
+
* name doesn't appear in the resource map's idParam table still get
|
|
516
|
+
* attempted. */
|
|
517
|
+
/** ARV-382: for a fixture var that resolved to no confident owner list
|
|
518
|
+
* endpoint, surface the structurally-plausible GET/list endpoints instead of
|
|
519
|
+
* dead-ending. We walk back from each `{param}` segment in the endpoints the
|
|
520
|
+
* var affects and collect any GET whose path is that collection prefix (or
|
|
521
|
+
* prefix + a list verb), ranked by proximity (deeper prefix = closer). This
|
|
522
|
+
* is deterministic candidate EVIDENCE — zond does not pick a value or a
|
|
523
|
+
* winner; the agent judges. Reuses the existing list-verb set (no new
|
|
524
|
+
* markers). Returns [] when nothing plausible exists. */
|
|
525
|
+
export function findCandidateListEndpoints(
|
|
526
|
+
affectedLabels: string[],
|
|
527
|
+
endpoints: Array<{ method: string; path: string; deprecated?: boolean }>,
|
|
528
|
+
): string[] {
|
|
529
|
+
const strip = (p: string) => (p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p);
|
|
530
|
+
const isParam = (s: string | undefined) => !!s && /^\{[^}]+\}$/.test(s);
|
|
531
|
+
// Prefer live endpoints; still surface a deprecated-only list (marked) so the
|
|
532
|
+
// agent — not zond — decides whether an EOL read is acceptable to seed an id.
|
|
533
|
+
const getByPath = new Map<string, { label: string; deprecated: boolean }>();
|
|
534
|
+
for (const e of endpoints) {
|
|
535
|
+
if (e.method.toUpperCase() !== "GET") continue;
|
|
536
|
+
const key = strip(e.path);
|
|
537
|
+
const entry = { label: `${e.method.toUpperCase()} ${e.path}`, deprecated: !!e.deprecated };
|
|
538
|
+
const prev = getByPath.get(key);
|
|
539
|
+
if (!prev || (prev.deprecated && !entry.deprecated)) getByPath.set(key, entry); // live wins
|
|
540
|
+
}
|
|
541
|
+
const VERBS = ["", "/list", "/search", "/find"];
|
|
542
|
+
const scored = new Map<string, number>();
|
|
543
|
+
const consider = (label: string, score: number) => {
|
|
544
|
+
if ((scored.get(label) ?? -1) < score) scored.set(label, score);
|
|
545
|
+
};
|
|
546
|
+
for (const label of affectedLabels) {
|
|
547
|
+
const path = label.slice(label.indexOf(" ") + 1);
|
|
548
|
+
const segs = strip(path).split("/");
|
|
549
|
+
for (let i = 0; i < segs.length; i++) {
|
|
550
|
+
if (!isParam(segs[i])) continue;
|
|
551
|
+
// Walk back over the run of non-param segments before this param.
|
|
552
|
+
for (let k = i; k >= 1; k--) {
|
|
553
|
+
if (isParam(segs[k - 1])) break;
|
|
554
|
+
const prefix = segs.slice(0, k).join("/");
|
|
555
|
+
if (prefix.includes("{")) continue; // an inner param — not a concrete GET path
|
|
556
|
+
for (const v of VERBS) {
|
|
557
|
+
const hit = getByPath.get(prefix + v);
|
|
558
|
+
if (!hit) continue;
|
|
559
|
+
// deeper prefix = closer; deprecated candidates rank below all live.
|
|
560
|
+
consider(hit.deprecated ? `${hit.label} (deprecated)` : hit.label, (hit.deprecated ? 0 : 1000) + k);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return [...scored.entries()].sort((a, b) => b[1] - a[1]).map((e) => e[0]).slice(0, 5);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function collectTargets(
|
|
569
|
+
map: ApiResourceMapYaml,
|
|
570
|
+
manifest?: FixtureManifestYaml,
|
|
571
|
+
): FkTarget[] {
|
|
572
|
+
const seen = new Set<string>();
|
|
573
|
+
const out: FkTarget[] = [];
|
|
574
|
+
const push = (t: FkTarget): void => {
|
|
575
|
+
if (seen.has(t.varName)) return;
|
|
576
|
+
seen.add(t.varName);
|
|
577
|
+
out.push(t);
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// 1. fkDeps — parent-id edges declared by resource-builder.
|
|
581
|
+
for (const r of map.resources) {
|
|
582
|
+
for (const dep of r.fkDependencies ?? []) {
|
|
583
|
+
if (dep.in !== "path") continue;
|
|
584
|
+
if (!dep.ownerResource) continue;
|
|
585
|
+
const owner = map.resources.find(x => x.resource === dep.ownerResource);
|
|
586
|
+
const listLabel = owner?.endpoints.list ?? "";
|
|
587
|
+
push({ varName: dep.var, ownerResource: dep.ownerResource, listLabel });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// 2. Each resource's own idParam → its own list endpoint. resource-builder's
|
|
592
|
+
// collectPathFkDeps skips this case (it emits only *parent* FKs), so
|
|
593
|
+
// without an explicit pass `domain_id`/`webhook_id`/etc. drop out of
|
|
594
|
+
// cascade entirely.
|
|
595
|
+
for (const r of map.resources) {
|
|
596
|
+
if (!r.idParam) continue;
|
|
597
|
+
if (!r.endpoints?.list) continue;
|
|
598
|
+
push({ varName: r.idParam, ownerResource: r.resource, listLabel: r.endpoints.list });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// 3. Manifest-required vars (path / body-fk) whose name doesn't match any
|
|
602
|
+
// fkDep edge or resource idParam. Use singular↔plural stemming to find
|
|
603
|
+
// an owner — same logic as the discover-via-manifest path uses (ARV-69).
|
|
604
|
+
if (manifest) {
|
|
605
|
+
for (const entry of manifest.fixtures) {
|
|
606
|
+
if (!entry.required) continue;
|
|
607
|
+
if (entry.source !== "path" && entry.source !== "body-fk") continue;
|
|
608
|
+
if (seen.has(entry.name)) continue;
|
|
609
|
+
const inferred = inferOwnerFromVarName(entry.name, map);
|
|
610
|
+
if (inferred) push(inferred);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return out;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export async function probeOne(
|
|
618
|
+
target: FkTarget,
|
|
619
|
+
current: string | undefined,
|
|
620
|
+
endpoints: EndpointInfo[],
|
|
621
|
+
schemes: SecuritySchemeInfo[],
|
|
622
|
+
vars: Record<string, string>,
|
|
623
|
+
baseUrl: string,
|
|
624
|
+
timeoutMs: number,
|
|
625
|
+
): Promise<DiscoveryItem> {
|
|
626
|
+
const item: DiscoveryItem = {
|
|
627
|
+
varName: target.varName,
|
|
628
|
+
resource: target.ownerResource,
|
|
629
|
+
listPath: "",
|
|
630
|
+
current,
|
|
631
|
+
status: "miss-no-list",
|
|
632
|
+
};
|
|
633
|
+
if (!target.listLabel) {
|
|
634
|
+
item.status = "miss-no-list";
|
|
635
|
+
item.reason = `resource "${target.ownerResource}" has no list endpoint in .api-resources.yaml`;
|
|
636
|
+
return item;
|
|
637
|
+
}
|
|
638
|
+
const parsed = parseEndpointLabel(target.listLabel);
|
|
639
|
+
if (!parsed) {
|
|
640
|
+
item.status = "miss-no-list";
|
|
641
|
+
item.reason = `cannot parse endpoint label "${target.listLabel}"`;
|
|
642
|
+
return item;
|
|
643
|
+
}
|
|
644
|
+
if (parsed.method !== "GET") {
|
|
645
|
+
item.status = "miss-no-list";
|
|
646
|
+
item.reason = `expected GET for list of ${target.ownerResource}, got ${parsed.method}`;
|
|
647
|
+
return item;
|
|
648
|
+
}
|
|
649
|
+
// For nested list paths (e.g. /orgs/{org}/projects/), substitute any
|
|
650
|
+
// parent path-params that are already known in vars. If all params resolve,
|
|
651
|
+
// proceed as a normal list call. Only bail if a param is still missing.
|
|
652
|
+
let effectivePath = parsed.path;
|
|
653
|
+
if (parsed.path.includes("{")) {
|
|
654
|
+
effectivePath = parsed.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
655
|
+
const val = vars[name];
|
|
656
|
+
return typeof val === "string" && val ? val : `{${name}}`;
|
|
657
|
+
});
|
|
658
|
+
if (effectivePath.includes("{")) {
|
|
659
|
+
item.status = "miss-nested-list";
|
|
660
|
+
item.reason = `nested collection (${parsed.path}) — missing parent fixture(s) in .env.yaml`;
|
|
661
|
+
return item;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
item.listPath = effectivePath;
|
|
665
|
+
|
|
666
|
+
// Already filled and not a placeholder → skip the call (live API, save it).
|
|
667
|
+
if (!isPlaceholder(current)) {
|
|
668
|
+
item.status = "skip-already-set";
|
|
669
|
+
return item;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const listEp = endpoints.find(
|
|
673
|
+
e => e.method.toUpperCase() === "GET" && e.path === parsed.path && !e.deprecated,
|
|
674
|
+
);
|
|
675
|
+
if (!listEp) {
|
|
676
|
+
item.status = "miss-no-list";
|
|
677
|
+
item.reason = `${parsed.path} not found in spec endpoints (resource map drift?)`;
|
|
678
|
+
return item;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${effectivePath}`;
|
|
682
|
+
const headers: Record<string, string> = {
|
|
683
|
+
accept: "application/json",
|
|
684
|
+
...liveAuthHeaders(listEp, schemes, vars),
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
let resp;
|
|
688
|
+
try {
|
|
689
|
+
// ARV-48: 1 network-class retry with exp+jitter backoff. Transient
|
|
690
|
+
// DNS/connection-reset blips on shared CI runners must not cost the
|
|
691
|
+
// user a whole prepare-fixtures rerun. Only network errors retry —
|
|
692
|
+
// 4xx/5xx HTTP statuses keep their existing branches (miss-status).
|
|
693
|
+
resp = await executeRequest(
|
|
694
|
+
{ method: "GET", url, headers },
|
|
695
|
+
{ timeout: timeoutMs, retries: 0, network_retries: 1 },
|
|
696
|
+
);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
item.status = "miss-network";
|
|
699
|
+
item.reason = `network error: ${err instanceof Error ? err.message : String(err)}`;
|
|
700
|
+
return item;
|
|
701
|
+
}
|
|
702
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
703
|
+
item.status = "miss-status";
|
|
704
|
+
// ARV-99: bare `METHOD path → status` leaves the agent guessing what
|
|
705
|
+
// to do. Append a status-specific next-step hint so 404 / 401 / 403 /
|
|
706
|
+
// 5xx each get a routed action. Spec drift (404) and token scope
|
|
707
|
+
// (401/403) are the two common root causes — call them out.
|
|
708
|
+
let hint = "";
|
|
709
|
+
if (resp.status === 404) {
|
|
710
|
+
hint =
|
|
711
|
+
` — list endpoint 404'd. Spec may have a stale path. Try \`zond refresh-api <name>\` to re-sync; ` +
|
|
712
|
+
`if the path is correct, the API likely doesn't expose this resource for your token — add the var to ` +
|
|
713
|
+
`.api-resources.local.yaml as a custom create endpoint (extension overlay) or fill .env.yaml by hand`;
|
|
714
|
+
} else if (resp.status === 401 || resp.status === 403) {
|
|
715
|
+
hint =
|
|
716
|
+
` — auth/scope rejection on the list endpoint. Check token scope; if the token genuinely cannot list ${target.ownerResource}, ` +
|
|
717
|
+
`fill .env.yaml by hand or rerun with \`--no-seed\` to skip futile attempts`;
|
|
718
|
+
} else if (resp.status >= 500) {
|
|
719
|
+
hint = ` — server-side error; retry later or check provider status before treating this as a fixture gap`;
|
|
720
|
+
}
|
|
721
|
+
item.reason = `${parsed.method} ${parsed.path} → ${resp.status}${hint}`;
|
|
722
|
+
return item;
|
|
723
|
+
}
|
|
724
|
+
// ARV-362 (m-25): discover no longer harvests a value from the list —
|
|
725
|
+
// which record/field fills the slot is the agent's call (ARV-334 lived in
|
|
726
|
+
// that guess). We only classify the gap so the agent knows the next step.
|
|
727
|
+
const respBody = resp.body_parsed ?? resp.body;
|
|
728
|
+
if (isEmptyListBody(respBody)) {
|
|
729
|
+
// TASK-273: empty target API — nothing to pick. Point the agent at
|
|
730
|
+
// creating the resource first.
|
|
731
|
+
item.status = "miss-empty";
|
|
732
|
+
item.reason =
|
|
733
|
+
`no ${target.ownerResource} in target API — create the resource yourself ` +
|
|
734
|
+
`(in the product UI or via API), then set its id in .env.yaml (or run ` +
|
|
735
|
+
`\`zond fixtures add\`) and re-run \`zond prepare-fixtures --api <name>\``;
|
|
736
|
+
return item;
|
|
737
|
+
}
|
|
738
|
+
if (!firstListItem(respBody)) {
|
|
739
|
+
item.status = "miss-no-id";
|
|
740
|
+
item.reason = `response shape unrecognized (no array/data/items/results/records field)`;
|
|
741
|
+
return item;
|
|
742
|
+
}
|
|
743
|
+
// Non-empty recognized list: the resource exists, but discover won't choose
|
|
744
|
+
// a record/field — the agent picks and fills .env.yaml by hand.
|
|
745
|
+
item.status = "miss-needs-value";
|
|
746
|
+
item.reason =
|
|
747
|
+
`${target.ownerResource} list has records but discover won't choose one — ` +
|
|
748
|
+
`pick a value and set {${target.varName}} in .env.yaml (or run \`zond fixtures add\`), ` +
|
|
749
|
+
`then re-run \`zond prepare-fixtures --api <name>\``;
|
|
750
|
+
return item;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** TASK-281: GET <ownerResource>'s read-by-id endpoint with the current
|
|
754
|
+
* fixture value and classify the result. 5xx is treated as `unknown` (don't
|
|
755
|
+
* trash valid fixtures over a flaky API). */
|
|
756
|
+
export async function verifyOne(
|
|
757
|
+
target: FkTarget,
|
|
758
|
+
current: string | undefined,
|
|
759
|
+
ownerResource: ResourceYaml | undefined,
|
|
760
|
+
endpoints: EndpointInfo[],
|
|
761
|
+
schemes: SecuritySchemeInfo[],
|
|
762
|
+
vars: Record<string, string>,
|
|
763
|
+
baseUrl: string,
|
|
764
|
+
timeoutMs: number,
|
|
765
|
+
): Promise<DiscoveryItem> {
|
|
766
|
+
const item: DiscoveryItem = {
|
|
767
|
+
varName: target.varName,
|
|
768
|
+
resource: target.ownerResource,
|
|
769
|
+
listPath: "",
|
|
770
|
+
current,
|
|
771
|
+
status: "verify-unknown",
|
|
772
|
+
};
|
|
773
|
+
if (isPlaceholder(current)) {
|
|
774
|
+
item.status = "verify-skip-empty";
|
|
775
|
+
item.reason = "fixture is empty/placeholder — nothing to verify";
|
|
776
|
+
return item;
|
|
777
|
+
}
|
|
778
|
+
if (!ownerResource?.endpoints?.read) {
|
|
779
|
+
item.status = "verify-no-read";
|
|
780
|
+
item.reason = `resource "${target.ownerResource}" has no read-by-id endpoint in .api-resources.yaml`;
|
|
781
|
+
return item;
|
|
782
|
+
}
|
|
783
|
+
const parsed = parseEndpointLabel(ownerResource.endpoints.read);
|
|
784
|
+
if (!parsed) {
|
|
785
|
+
item.status = "verify-no-read";
|
|
786
|
+
item.reason = `cannot parse read endpoint label "${ownerResource.endpoints.read}"`;
|
|
787
|
+
return item;
|
|
788
|
+
}
|
|
789
|
+
// Substitute parent path-params from env vars; the resource's own idParam is
|
|
790
|
+
// taken from `current` (we are verifying that very value).
|
|
791
|
+
const idParam = ownerResource.idParam;
|
|
792
|
+
let effectivePath = parsed.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
793
|
+
if (name === idParam) return current!;
|
|
794
|
+
const val = vars[name];
|
|
795
|
+
return typeof val === "string" && val ? val : `{${name}}`;
|
|
796
|
+
});
|
|
797
|
+
if (effectivePath.includes("{")) {
|
|
798
|
+
item.status = "verify-unknown";
|
|
799
|
+
item.reason = `cannot resolve parent path-params for ${parsed.path}`;
|
|
800
|
+
return item;
|
|
801
|
+
}
|
|
802
|
+
item.listPath = effectivePath;
|
|
803
|
+
|
|
804
|
+
const ep = endpoints.find(
|
|
805
|
+
e => e.method.toUpperCase() === "GET" && e.path === parsed.path && !e.deprecated,
|
|
806
|
+
);
|
|
807
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${effectivePath}`;
|
|
808
|
+
const headers: Record<string, string> = {
|
|
809
|
+
accept: "application/json",
|
|
810
|
+
...(ep ? liveAuthHeaders(ep, schemes, vars) : {}),
|
|
811
|
+
};
|
|
812
|
+
let resp;
|
|
813
|
+
try {
|
|
814
|
+
// ARV-48: same single network-class retry as the discover probe.
|
|
815
|
+
resp = await executeRequest({ method: "GET", url, headers }, { timeout: timeoutMs, retries: 0, network_retries: 1 });
|
|
816
|
+
} catch (err) {
|
|
817
|
+
item.status = "verify-unknown";
|
|
818
|
+
item.reason = `network error: ${err instanceof Error ? err.message : String(err)}`;
|
|
819
|
+
return item;
|
|
820
|
+
}
|
|
821
|
+
if (resp.status >= 200 && resp.status < 300) {
|
|
822
|
+
item.status = "verify-live";
|
|
823
|
+
item.discovered = current;
|
|
824
|
+
return item;
|
|
825
|
+
}
|
|
826
|
+
if (resp.status === 404 || resp.status === 410) {
|
|
827
|
+
item.status = "verify-stale";
|
|
828
|
+
item.reason = `${parsed.method} ${effectivePath} → ${resp.status}`;
|
|
829
|
+
return item;
|
|
830
|
+
}
|
|
831
|
+
// 401/403 — token/scope issue, not a stale id; 5xx — flake; treat both as
|
|
832
|
+
// unknown so we never delete a fixture on shaky evidence.
|
|
833
|
+
item.status = "verify-unknown";
|
|
834
|
+
item.reason = `${parsed.method} ${effectivePath} → ${resp.status}`;
|
|
835
|
+
return item;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** Append-or-update a key in YAML text. Conservative: matches `<key>:` at
|
|
839
|
+
* the start of a line and rewrites the value, preserving trailing comments
|
|
840
|
+
* that documented original placeholders. */
|
|
841
|
+
export function upsertEnvLine(yamlText: string, key: string, value: string): string {
|
|
842
|
+
const lines = yamlText.split("\n");
|
|
843
|
+
const re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`);
|
|
844
|
+
const idx = lines.findIndex(l => re.test(l));
|
|
845
|
+
const newLine = `${key}: ${JSON.stringify(value)}`;
|
|
846
|
+
if (idx === -1) {
|
|
847
|
+
// Insert before final newline if file ends with one, otherwise append.
|
|
848
|
+
if (lines[lines.length - 1] === "") {
|
|
849
|
+
lines.splice(lines.length - 1, 0, newLine);
|
|
850
|
+
} else {
|
|
851
|
+
lines.push(newLine);
|
|
852
|
+
}
|
|
853
|
+
} else {
|
|
854
|
+
lines[idx] = newLine;
|
|
855
|
+
}
|
|
856
|
+
return lines.join("\n");
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** First 8 `{{var}}` names + "… and N more" — keeps the gap warning readable
|
|
860
|
+
* when a barely-provisioned account leaves dozens of required fixtures empty. */
|
|
861
|
+
function capNames(names: string[], max = 8): string {
|
|
862
|
+
const head = names.slice(0, max).map(n => `{{${n}}}`).join(", ");
|
|
863
|
+
return names.length > max ? `${head}, … and ${names.length - max} more` : head;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export async function discoverCommand(options: DiscoverOptions): Promise<number> {
|
|
867
|
+
const commandName = options.commandName ?? "discover";
|
|
868
|
+
try {
|
|
869
|
+
const doc = await readOpenApiSpec(options.specPath);
|
|
870
|
+
const endpoints = extractEndpoints(doc);
|
|
871
|
+
const securitySchemes = extractSecuritySchemes(doc);
|
|
872
|
+
|
|
873
|
+
const resourceMap = await readResourceMap(options.apiDir);
|
|
874
|
+
if (!resourceMap || resourceMap.resources.length === 0) {
|
|
875
|
+
const msg = `No .api-resources.yaml in ${options.apiDir}. Run 'zond refresh-api <name>' to (re)build it.`;
|
|
876
|
+
if (options.json) printJson(jsonError(commandName, [msg]));
|
|
877
|
+
else printError(msg);
|
|
878
|
+
return 2;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const envPath = options.envPath ?? join(options.apiDir, ".env.yaml");
|
|
882
|
+
const env = (await loadEnvFile(envPath)) ?? {};
|
|
883
|
+
// ARV-143 follow-up (security regression fix): register every loaded var
|
|
884
|
+
// with the SecretRegistry so the user-config bucket (and any other path
|
|
885
|
+
// that incidentally echoes a value) can't leak `.secrets.yaml`-resolved
|
|
886
|
+
// tokens to stdout / scrollback / tee. base_url is filtered out because
|
|
887
|
+
// we have to print it verbatim in the discovery header.
|
|
888
|
+
{
|
|
889
|
+
const reg = getSecretRegistry();
|
|
890
|
+
for (const [k, v] of Object.entries(env)) {
|
|
891
|
+
if (k === "base_url") continue;
|
|
892
|
+
reg.register(k, v);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const baseUrl = env["base_url"];
|
|
896
|
+
if (!baseUrl) {
|
|
897
|
+
const msg = `base_url is required in ${envPath} (live API calls need it).`;
|
|
898
|
+
if (options.json) printJson(jsonError(commandName, [msg]));
|
|
899
|
+
else printError(msg);
|
|
900
|
+
return 2;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// ARV-46: manifest is the source-of-truth for the *list* of variables
|
|
904
|
+
// this API needs (per decision-7). When `.api-fixtures.yaml` exists,
|
|
905
|
+
// discover iterates it instead of `.env.yaml` keys / FK deps directly,
|
|
906
|
+
// so vars present in tests but absent from FK deps still show up in the
|
|
907
|
+
// status table — and env keys without a manifest entry surface as a
|
|
908
|
+
// warning instead of being silently ignored.
|
|
909
|
+
const manifest = await readFixtureManifest(options.apiDir);
|
|
910
|
+
|
|
911
|
+
const targets = collectTargets(resourceMap);
|
|
912
|
+
if (targets.length === 0 && !manifest) {
|
|
913
|
+
if (options.json) {
|
|
914
|
+
printJson(jsonOk(commandName, { items: [], message: "No path-FK dependencies with known owner resources." }));
|
|
915
|
+
} else {
|
|
916
|
+
console.log("No path-FK dependencies with known owner resources — nothing to discover.");
|
|
917
|
+
}
|
|
918
|
+
return 0;
|
|
919
|
+
}
|
|
920
|
+
// Index targets by var name so manifest entries can resolve their owner
|
|
921
|
+
// resource via the FK chain (manifest knows *what*, resource map knows
|
|
922
|
+
// *where to fetch*).
|
|
923
|
+
const targetsByVar = new Map<string, FkTarget>();
|
|
924
|
+
for (const t of targets) targetsByVar.set(t.varName, t);
|
|
925
|
+
// Resource map's `collectPathFkDeps` skips the resource's own idParam —
|
|
926
|
+
// it only emits *parent* FKs. The manifest legitimately wants discover
|
|
927
|
+
// to fill `api_key_id` (idParam of /api-keys/{api_key_id}) from the list
|
|
928
|
+
// endpoint /api-keys, so wire each resource's own idParam onto its list
|
|
929
|
+
// endpoint here. This is what makes "discover walks the manifest" not
|
|
930
|
+
// collapse 80% of entries into failed:no-list-endpoint.
|
|
931
|
+
for (const r of resourceMap.resources) {
|
|
932
|
+
if (!r.idParam || !r.endpoints?.list) continue;
|
|
933
|
+
if (targetsByVar.has(r.idParam)) continue;
|
|
934
|
+
targetsByVar.set(r.idParam, {
|
|
935
|
+
varName: r.idParam,
|
|
936
|
+
ownerResource: r.resource,
|
|
937
|
+
listLabel: r.endpoints.list,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// TASK-281: --verify mode — GET the read-by-id endpoint for every fixture
|
|
942
|
+
// and classify (live / stale / unknown). Without --apply this is purely
|
|
943
|
+
// diagnostic; with --apply we unset stale entries and re-resolve them via
|
|
944
|
+
// the regular discover flow below.
|
|
945
|
+
const items: DiscoveryItem[] = [];
|
|
946
|
+
if (options.verify) {
|
|
947
|
+
for (const target of targets) {
|
|
948
|
+
const owner = resourceMap.resources.find(r => r.resource === target.ownerResource);
|
|
949
|
+
const item = await verifyOne(
|
|
950
|
+
target,
|
|
951
|
+
env[target.varName],
|
|
952
|
+
owner,
|
|
953
|
+
endpoints,
|
|
954
|
+
securitySchemes,
|
|
955
|
+
env,
|
|
956
|
+
baseUrl,
|
|
957
|
+
options.timeoutMs ?? 30000,
|
|
958
|
+
);
|
|
959
|
+
items.push(item);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ARV-362 (m-25): --refresh drops the known-bad stale id (unsets it in
|
|
963
|
+
// .env.yaml, disk-write below) so the var resurfaces as a gap. discover
|
|
964
|
+
// no longer re-resolves a replacement value — the agent picks the new
|
|
965
|
+
// one. `wasStale` marks which vars to unset on disk.
|
|
966
|
+
if (options.apply) {
|
|
967
|
+
for (const item of items) {
|
|
968
|
+
if (item.status === "verify-stale") {
|
|
969
|
+
delete env[item.varName];
|
|
970
|
+
item.wasStale = true;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ARV-143: surface filled vars that verify can't validate so the user
|
|
976
|
+
// doesn't think they're missing. Two buckets:
|
|
977
|
+
// 1. manifest user-config sources (auth / server / header) — never
|
|
978
|
+
// had a read endpoint, refresh just trusts the value.
|
|
979
|
+
// 2. targets whose verifyOne returned verify-no-read (resource exists
|
|
980
|
+
// but `.api-resources.yaml` has no read endpoint) — same story.
|
|
981
|
+
// Without this, refresh emitted "0 stale" + silence on these vars,
|
|
982
|
+
// contradicting doctor's set:true reporting (feedback-02 F12).
|
|
983
|
+
if (manifest) {
|
|
984
|
+
const seen = new Set(items.map(i => i.varName));
|
|
985
|
+
for (const entry of manifest.fixtures) {
|
|
986
|
+
if (seen.has(entry.name)) continue;
|
|
987
|
+
const current = env[entry.name];
|
|
988
|
+
if (!current || isPlaceholder(current)) continue;
|
|
989
|
+
const isUserConfig =
|
|
990
|
+
entry.source === "auth" ||
|
|
991
|
+
entry.source === "server" ||
|
|
992
|
+
entry.source === "header";
|
|
993
|
+
if (!isUserConfig) continue;
|
|
994
|
+
items.push({
|
|
995
|
+
varName: entry.name,
|
|
996
|
+
resource: "",
|
|
997
|
+
listPath: "",
|
|
998
|
+
current,
|
|
999
|
+
status: "verify-user-config",
|
|
1000
|
+
manifestSource: entry.source,
|
|
1001
|
+
reason: `${entry.source} var — no verification path, value trusted from .env.yaml`,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
// Promote verify-no-read items with a filled value to the same bucket
|
|
1005
|
+
// so they show up under "trusted user input" in the summary instead of
|
|
1006
|
+
// being lumped with empty/skip items.
|
|
1007
|
+
for (const item of items) {
|
|
1008
|
+
if (item.status === "verify-no-read" && item.current && !isPlaceholder(item.current)) {
|
|
1009
|
+
item.status = "verify-user-config";
|
|
1010
|
+
item.reason = `no read-by-id endpoint in .api-resources.yaml — value trusted from .env.yaml`;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
} else if (manifest) {
|
|
1015
|
+
// ARV-46: drive the loop by manifest entries (one row per entry).
|
|
1016
|
+
// Each entry's status maps onto the manifest-grade enum so agents
|
|
1017
|
+
// get a stable contract independent of the underlying probe shape.
|
|
1018
|
+
for (const entry of manifest.fixtures) {
|
|
1019
|
+
const current = env[entry.name];
|
|
1020
|
+
const placeholder: DiscoveryItem = {
|
|
1021
|
+
varName: entry.name,
|
|
1022
|
+
resource: "",
|
|
1023
|
+
listPath: "",
|
|
1024
|
+
current,
|
|
1025
|
+
status: "skip-not-required",
|
|
1026
|
+
manifestSource: entry.source,
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
// Sources that discover does not own: the user fills these (auth/
|
|
1030
|
+
// server/header) or the runtime captures them (capture-chain).
|
|
1031
|
+
// required:false manifest entries (currently capture-chain) are also
|
|
1032
|
+
// not the discover loop's responsibility.
|
|
1033
|
+
const isOwnedByDiscover =
|
|
1034
|
+
entry.required && (entry.source === "path" || entry.source === "body-fk");
|
|
1035
|
+
if (!isOwnedByDiscover) {
|
|
1036
|
+
placeholder.status = "skip-not-required";
|
|
1037
|
+
placeholder.manifestStatus = "skipped:not-required";
|
|
1038
|
+
items.push(placeholder);
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Already filled (and not a TODO placeholder) — leave it alone.
|
|
1043
|
+
if (!isPlaceholder(current)) {
|
|
1044
|
+
placeholder.status = "skip-already-set";
|
|
1045
|
+
placeholder.manifestStatus = "skipped:already-set";
|
|
1046
|
+
items.push(placeholder);
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Resolve owner resource via FK chain. body-fk vars often share the
|
|
1051
|
+
// name with a path-param of another resource (audience_id ↔
|
|
1052
|
+
// /audiences/{id}); resource map's collectBodyFkDeps already does
|
|
1053
|
+
// name-stemming inference for us. A miss here means we have nothing
|
|
1054
|
+
// to GET — the entry stays in the table as failed:no-list-endpoint.
|
|
1055
|
+
let target = targetsByVar.get(entry.name);
|
|
1056
|
+
if (!target) {
|
|
1057
|
+
// ARV-69 (feedback round-02 / F10): the resource map only links a
|
|
1058
|
+
// var to a list endpoint when the path explicitly carries it as a
|
|
1059
|
+
// path-param (e.g. /audiences/{audience_id}). common-style APIs
|
|
1060
|
+
// commonly use the generic {id} placeholder, so vars like
|
|
1061
|
+
// `domain_id` / `segment_id` / `log_id` end up with no fkDep edge
|
|
1062
|
+
// even though /domains, /segments, /logs are perfectly usable as
|
|
1063
|
+
// list endpoints. Try a name-stemming fallback: strip the FK
|
|
1064
|
+
// suffix and match a resource whose name is the singular or plural
|
|
1065
|
+
// form.
|
|
1066
|
+
const inferred = inferOwnerFromVarName(entry.name, resourceMap);
|
|
1067
|
+
if (inferred) target = inferred;
|
|
1068
|
+
}
|
|
1069
|
+
if (!target) {
|
|
1070
|
+
// ARV-382: no confident owner — surface plausible list endpoints as
|
|
1071
|
+
// evidence instead of dead-ending. The agent picks (zond doesn't).
|
|
1072
|
+
const candidates = findCandidateListEndpoints(entry.affectedEndpoints ?? [], endpoints);
|
|
1073
|
+
placeholder.status = "miss-no-list";
|
|
1074
|
+
placeholder.manifestStatus = "failed:no-list-endpoint";
|
|
1075
|
+
if (candidates.length > 0) {
|
|
1076
|
+
placeholder.candidates = candidates;
|
|
1077
|
+
placeholder.reason = `${entry.source}-source var has no confident owner in .api-resources.yaml — ${candidates.length} candidate list endpoint(s) surfaced; GET one, pick a record, set the value`;
|
|
1078
|
+
} else {
|
|
1079
|
+
placeholder.reason = `${entry.source}-source var has no owner resource in .api-resources.yaml — cannot derive a list endpoint`;
|
|
1080
|
+
}
|
|
1081
|
+
items.push(placeholder);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const item = await probeOne(
|
|
1086
|
+
target,
|
|
1087
|
+
current,
|
|
1088
|
+
endpoints,
|
|
1089
|
+
securitySchemes,
|
|
1090
|
+
env,
|
|
1091
|
+
baseUrl,
|
|
1092
|
+
options.timeoutMs ?? 30000,
|
|
1093
|
+
);
|
|
1094
|
+
item.manifestSource = entry.source;
|
|
1095
|
+
item.manifestStatus = toManifestStatus(item.status);
|
|
1096
|
+
items.push(item);
|
|
1097
|
+
}
|
|
1098
|
+
} else {
|
|
1099
|
+
// Legacy path: no manifest in the workspace — probe FK targets directly.
|
|
1100
|
+
for (const target of targets) {
|
|
1101
|
+
const current = env[target.varName];
|
|
1102
|
+
const item = await probeOne(
|
|
1103
|
+
target,
|
|
1104
|
+
current,
|
|
1105
|
+
endpoints,
|
|
1106
|
+
securitySchemes,
|
|
1107
|
+
env,
|
|
1108
|
+
baseUrl,
|
|
1109
|
+
options.timeoutMs ?? 30000,
|
|
1110
|
+
);
|
|
1111
|
+
items.push(item);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// TASK-294: stamp every item with recommended_action before consumers
|
|
1116
|
+
// (--json envelope, summary printer) read it.
|
|
1117
|
+
for (const it of items) {
|
|
1118
|
+
const action = discoveryAction(it.status);
|
|
1119
|
+
if (action) it.recommended_action = action;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// ARV-362 (m-25): discover never writes discovered values — that's the
|
|
1123
|
+
// agent's call. The only disk mutation is --refresh unsetting stale ids so
|
|
1124
|
+
// the known-bad value is removed and the var resurfaces as a gap.
|
|
1125
|
+
const unsets = options.verify && options.apply
|
|
1126
|
+
? items.filter(i => i.wasStale === true).map(i => i.varName)
|
|
1127
|
+
: [];
|
|
1128
|
+
let applied = false;
|
|
1129
|
+
let backupPath: string | null = null;
|
|
1130
|
+
if (unsets.length > 0) {
|
|
1131
|
+
backupPath = `${envPath}.bak`;
|
|
1132
|
+
try {
|
|
1133
|
+
await copyFile(envPath, backupPath);
|
|
1134
|
+
} catch {
|
|
1135
|
+
// missing source — write fresh; no backup needed.
|
|
1136
|
+
backupPath = null;
|
|
1137
|
+
}
|
|
1138
|
+
const file = Bun.file(envPath);
|
|
1139
|
+
let text = (await file.exists()) ? await file.text() : "";
|
|
1140
|
+
for (const v of unsets) {
|
|
1141
|
+
text = upsertEnvLine(text, v, "");
|
|
1142
|
+
}
|
|
1143
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
1144
|
+
await Bun.write(envPath, text);
|
|
1145
|
+
applied = true;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// ARV-46: env keys without a manifest entry are noise — the user (or a
|
|
1149
|
+
// legacy hand-edit) put them there; the API doesn't actually need them.
|
|
1150
|
+
// Surface as warning so they can be removed; do not act on them.
|
|
1151
|
+
// ARV-260: exclude auto-managed auth keys (auth_token, api_key) — zond
|
|
1152
|
+
// itself writes them at `add api` time and uses them to inject the
|
|
1153
|
+
// Authorization/X-API-Key header even when the spec lacks
|
|
1154
|
+
// securitySchemes. They are not in the fixtures manifest by design.
|
|
1155
|
+
// Without this filter, prepare-fixtures tells users to "drop them from
|
|
1156
|
+
// .env.yaml or run refresh-api" — both wrong actions that would break
|
|
1157
|
+
// auth.
|
|
1158
|
+
const AUTO_MANAGED_KEYS = new Set(["auth_token", "api_key"]);
|
|
1159
|
+
let unknownEnvKeys: string[] = [];
|
|
1160
|
+
if (manifest) {
|
|
1161
|
+
const manifestNames = new Set(manifest.fixtures.map(f => f.name));
|
|
1162
|
+
unknownEnvKeys = Object.keys(env).filter(
|
|
1163
|
+
k => !manifestNames.has(k) && !AUTO_MANAGED_KEYS.has(k),
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ARV-324: persist confirmed empty/inaccessible operations so a later,
|
|
1168
|
+
// separate `checks run` invocation can tell "known fixture gap" apart
|
|
1169
|
+
// from "new backend bug" instead of mislabeling both report_backend_bug.
|
|
1170
|
+
// Rewritten wholesale every run so a since-fixed gap doesn't linger.
|
|
1171
|
+
await writeFixtureGaps(options.apiDir, gapsFromItems(items));
|
|
1172
|
+
|
|
1173
|
+
// ARV-349/350: report suite vars this single-pass run cannot resolve.
|
|
1174
|
+
// Load the generated suites (best-effort — absent tests/ dir is fine) and
|
|
1175
|
+
// scan them against the env we'd end up with (current + this run's writes).
|
|
1176
|
+
// Report only; never invent values / auto-seed.
|
|
1177
|
+
let gapReport: FixtureGapReport = { undefinedVars: [], unseededRoots: [] };
|
|
1178
|
+
try {
|
|
1179
|
+
const { suites } = await parseSafe(join(options.apiDir, "tests"));
|
|
1180
|
+
if (suites.length > 0) {
|
|
1181
|
+
// ARV-362: discover writes nothing, so the effective env is just what's
|
|
1182
|
+
// already on disk (minus any stale ids --refresh unset above).
|
|
1183
|
+
const effectiveEnv: Record<string, string> = { ...env };
|
|
1184
|
+
// Required manifest vars still empty after this pass = candidate
|
|
1185
|
+
// chain-roots. Source-agnostic: real specs model these as `path`
|
|
1186
|
+
// required:true with an empty default, not only `capture-chain`.
|
|
1187
|
+
const requiredEmptyVars = new Set(
|
|
1188
|
+
(manifest?.fixtures ?? [])
|
|
1189
|
+
.filter(f => f.required && !(effectiveEnv[f.name] && effectiveEnv[f.name]!.length > 0))
|
|
1190
|
+
.map(f => f.name),
|
|
1191
|
+
);
|
|
1192
|
+
gapReport = reportFixtureGaps(suites, effectiveEnv, requiredEmptyVars);
|
|
1193
|
+
}
|
|
1194
|
+
} catch { /* suite scan is best-effort — never fail prepare-fixtures on it */ }
|
|
1195
|
+
|
|
1196
|
+
const requiredManifestCount = manifest
|
|
1197
|
+
? manifest.fixtures.filter(f => f.required).length
|
|
1198
|
+
: 0;
|
|
1199
|
+
// ARV-362: discover never fills a value, so "filled" means "already set on
|
|
1200
|
+
// disk". Manifest-driven mode reports skip-already-set; verify mode reports
|
|
1201
|
+
// verify-live / verify-user-config. The "Filled X/Y" line agrees with
|
|
1202
|
+
// doctor and the user_config bucket isn't double-counted as UNSET.
|
|
1203
|
+
const filledCount = items.filter(i =>
|
|
1204
|
+
i.manifestStatus === "skipped:already-set" ||
|
|
1205
|
+
i.status === "verify-live" ||
|
|
1206
|
+
i.status === "verify-user-config",
|
|
1207
|
+
).length;
|
|
1208
|
+
|
|
1209
|
+
if (options.json) {
|
|
1210
|
+
// ARV-143 follow-up: strip raw secret values from items[].current so the
|
|
1211
|
+
// JSON envelope can't leak `.secrets.yaml`-resolved tokens. The
|
|
1212
|
+
// SecretRegistry registered every non-base_url env var above, so
|
|
1213
|
+
// redactObject swaps any registered value for `<redacted:<name>>`.
|
|
1214
|
+
const safeItems = getSecretRegistry().redactObject(items);
|
|
1215
|
+
printJson(jsonOk(commandName, {
|
|
1216
|
+
envPath,
|
|
1217
|
+
applied,
|
|
1218
|
+
backup: backupPath,
|
|
1219
|
+
items: safeItems,
|
|
1220
|
+
summary: {
|
|
1221
|
+
total: items.length,
|
|
1222
|
+
// ARV-362: discover writes no values; --refresh may unset stale ids.
|
|
1223
|
+
unset: unsets.length,
|
|
1224
|
+
alreadySet: items.filter(i => i.status === "skip-already-set").length,
|
|
1225
|
+
misses: items.filter(i => i.status.startsWith("miss-")).length,
|
|
1226
|
+
// ARV-349/350: gaps prepare-fixtures cannot resolve deterministically.
|
|
1227
|
+
// Present so an agent/user can fill them; values are never invented.
|
|
1228
|
+
fixtureGaps: gapReport,
|
|
1229
|
+
...(manifest ? {
|
|
1230
|
+
manifest: {
|
|
1231
|
+
required: requiredManifestCount,
|
|
1232
|
+
filled: filledCount,
|
|
1233
|
+
unknownEnvKeys,
|
|
1234
|
+
},
|
|
1235
|
+
} : {}),
|
|
1236
|
+
...(options.verify ? {
|
|
1237
|
+
verify: {
|
|
1238
|
+
live: items.filter(i => i.status === "verify-live").length,
|
|
1239
|
+
// Items classified stale. With --refresh they are also unset on
|
|
1240
|
+
// disk (see `dropped`); without it they stay for the agent to fix.
|
|
1241
|
+
stale: items.filter(i => i.status === "verify-stale").length,
|
|
1242
|
+
// ARV-362: stale ids --refresh removed from .env.yaml (now gaps).
|
|
1243
|
+
dropped: items.filter(i => i.wasStale === true).length,
|
|
1244
|
+
unknown: items.filter(i => i.status === "verify-unknown").length,
|
|
1245
|
+
skipped: items.filter(i => i.status === "verify-skip-empty" || i.status === "verify-no-read").length,
|
|
1246
|
+
// ARV-143: filled vars with no verify path (user-config /
|
|
1247
|
+
// resource-without-read). Doctor reports these as set:true;
|
|
1248
|
+
// refresh now agrees by surfacing them in their own bucket.
|
|
1249
|
+
user_config: items.filter(i => i.status === "verify-user-config").length,
|
|
1250
|
+
},
|
|
1251
|
+
} : {}),
|
|
1252
|
+
},
|
|
1253
|
+
}));
|
|
1254
|
+
} else {
|
|
1255
|
+
console.log(`Discovery against ${baseUrl} (${envPath}):`);
|
|
1256
|
+
console.log("");
|
|
1257
|
+
const cols = ["var", "source", "resource", "list", "status", "value/reason"];
|
|
1258
|
+
const rows = items.map(i => [
|
|
1259
|
+
i.varName,
|
|
1260
|
+
i.manifestSource ?? "—",
|
|
1261
|
+
i.resource || "—",
|
|
1262
|
+
i.listPath || "—",
|
|
1263
|
+
i.manifestStatus ?? i.status,
|
|
1264
|
+
i.status === "skip-already-set"
|
|
1265
|
+
? `(kept: ${i.current})`
|
|
1266
|
+
: i.status === "skip-not-required"
|
|
1267
|
+
? `(not owned by discover)`
|
|
1268
|
+
: i.status === "verify-live"
|
|
1269
|
+
? `(live: ${i.current})`
|
|
1270
|
+
: i.status === "verify-stale"
|
|
1271
|
+
? `(stale: ${i.current})${i.reason ? ` — ${i.reason}` : ""}`
|
|
1272
|
+
: i.status === "verify-user-config"
|
|
1273
|
+
// ARV-143 follow-up: never echo the raw value here —
|
|
1274
|
+
// auth/header sources routinely carry tokens, and even
|
|
1275
|
+
// server URLs can be sensitive. Mirror doctor's
|
|
1276
|
+
// set/length-only contract from .secrets.yaml handling.
|
|
1277
|
+
? `(trusted, length=${(i.current ?? "").length})`
|
|
1278
|
+
: (i.reason ?? ""),
|
|
1279
|
+
]);
|
|
1280
|
+
// ARV-143 follow-up: redact every text cell through SecretRegistry so
|
|
1281
|
+
// an `auth_token` that happens to slip into a `(kept: ...)` /
|
|
1282
|
+
// `(live: ...)` cell can't reach stdout / scrollback / tee. The
|
|
1283
|
+
// verify-user-config branch already substitutes length-only — this
|
|
1284
|
+
// is defense in depth for the other status branches.
|
|
1285
|
+
const reg = getSecretRegistry();
|
|
1286
|
+
for (const r of rows) for (let i = 0; i < r.length; i++) r[i] = reg.redact(r[i]!);
|
|
1287
|
+
const widths = cols.map((h, i) => Math.max(h.length, ...rows.map(r => r[i]!.length)));
|
|
1288
|
+
const fmt = (cells: string[]) => cells.map((c, i) => c.padEnd(widths[i]!)).join(" ");
|
|
1289
|
+
console.log(fmt(cols));
|
|
1290
|
+
console.log(widths.map(w => "─".repeat(w)).join(" "));
|
|
1291
|
+
for (const r of rows) console.log(fmt(r));
|
|
1292
|
+
console.log("");
|
|
1293
|
+
if (options.verify) {
|
|
1294
|
+
const live = items.filter(i => i.status === "verify-live").length;
|
|
1295
|
+
const stale = items.filter(i => i.status === "verify-stale").length;
|
|
1296
|
+
const unknown = items.filter(i => i.status === "verify-unknown").length;
|
|
1297
|
+
// ARV-362: stale ids --refresh removed from .env.yaml (now gaps).
|
|
1298
|
+
const dropped = items.filter(i => i.wasStale === true).length;
|
|
1299
|
+
// ARV-143: filled vars verify can't reach — call them out as trusted.
|
|
1300
|
+
const userConfig = items.filter(i => i.status === "verify-user-config").length;
|
|
1301
|
+
const parts = [`${live} live`, `${stale} stale`];
|
|
1302
|
+
if (dropped > 0) parts.push(`${dropped} dropped`);
|
|
1303
|
+
parts.push(`${unknown} unknown`);
|
|
1304
|
+
if (userConfig > 0) parts.push(`${userConfig} trusted (no-verify-path)`);
|
|
1305
|
+
console.log(`Verify summary: ${parts.join(", ")}.`);
|
|
1306
|
+
if (stale > 0 && !options.apply) {
|
|
1307
|
+
printWarning(`${stale} stale fixture(s) detected. Re-run with --refresh to drop them (agent refills .env.yaml).`);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (manifest) {
|
|
1311
|
+
console.log(`Filled ${filledCount} / ${requiredManifestCount} manifest entries.`);
|
|
1312
|
+
}
|
|
1313
|
+
// ARV-350: chain-roots that gate whole CRUD suites — flag first, they're
|
|
1314
|
+
// the highest-leverage gap (one id un-skips a dependent chain). Cap the
|
|
1315
|
+
// inline list; the full set is in the JSON envelope for agent consumption.
|
|
1316
|
+
if (gapReport.unseededRoots.length > 0) {
|
|
1317
|
+
printWarning(
|
|
1318
|
+
`${gapReport.unseededRoots.length} unseeded chain-root(s): ${capNames(gapReport.unseededRoots.map(r => r.variable))}. ` +
|
|
1319
|
+
`Dependent CRUD suites skip until these are set — supply an id via \`fixtures add\` / .env.yaml ` +
|
|
1320
|
+
`(prepare-fixtures does not auto-seed; use --json for the full list).`,
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
// ARV-349: suite vars nothing produces — no env value, capture, or param.
|
|
1324
|
+
if (gapReport.undefinedVars.length > 0) {
|
|
1325
|
+
printWarning(
|
|
1326
|
+
`${gapReport.undefinedVars.length} unresolved suite var(s): ${capNames(gapReport.undefinedVars.map(v => v.variable))}. ` +
|
|
1327
|
+
`Fill them via \`fixtures add\` / .env.yaml (prepare-fixtures does not invent values; use --json for the full list).`,
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
if (unknownEnvKeys.length > 0) {
|
|
1331
|
+
printWarning(
|
|
1332
|
+
`${unknownEnvKeys.length} env key(s) not in manifest, ignored: ${unknownEnvKeys.join(", ")}. Drop them from .env.yaml or run \`zond refresh-api\` if the manifest is stale.`,
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
if (applied) {
|
|
1336
|
+
printSuccess(`Unset ${unsets.length} stale fixture(s) in ${envPath}` + (backupPath ? ` (backup: ${backupPath})` : "") + ` — refill by hand / \`fixtures add\`.`);
|
|
1337
|
+
} else if (!options.verify) {
|
|
1338
|
+
console.log("discover reports gaps only — it never writes values (fill .env.yaml by hand or via `fixtures add`).");
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return 0;
|
|
1342
|
+
} catch (err) {
|
|
1343
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1344
|
+
if (options.json) printJson(jsonError(commandName, [message]));
|
|
1345
|
+
else printError(message);
|
|
1346
|
+
return 2;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// ARV-130 (m-19): file kept on purpose. CLI registration is owned by
|
|
1351
|
+
// ./prepare-fixtures.ts (TASK-299, m-13 D); the `discoverCommand` core
|
|
1352
|
+
// above is consumed both by that wrapper and by direct unit tests
|
|
1353
|
+
// (`tests/cli/discover*.test.ts`). It is NOT a deprecated alias for a
|
|
1354
|
+
// top-level `zond discover` command — that command does not exist and
|
|
1355
|
+
// has never been registered in `src/cli/program.ts`. See the m-19
|
|
1356
|
+
// audit note in backlog/tasks/arv-130.
|