@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,1236 @@
|
|
|
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
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Suffix-aware field extraction. For var `project_slug` we prefer the
|
|
35
|
+
* response's `slug` field over `id`; for `team_uuid` we prefer `uuid`.
|
|
36
|
+
* This matches the user's intent expressed in the env-var name and avoids
|
|
37
|
+
* the surprise where every nested resource gets the same generic `id` even
|
|
38
|
+
* when the path-param clearly wants a slug.
|
|
39
|
+
*/
|
|
40
|
+
const VAR_SUFFIX_HINTS: Array<{ suffix: string; field: string }> = [
|
|
41
|
+
{ suffix: "_slug", field: "slug" },
|
|
42
|
+
{ suffix: "_uuid", field: "uuid" },
|
|
43
|
+
{ suffix: "_key", field: "key" },
|
|
44
|
+
{ suffix: "_version", field: "version" },
|
|
45
|
+
{ suffix: "_name", field: "name" },
|
|
46
|
+
{ suffix: "_code", field: "code" },
|
|
47
|
+
{ suffix: "_id", field: "id" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function preferredFieldFromVar(varName: string): string {
|
|
51
|
+
for (const { suffix, field } of VAR_SUFFIX_HINTS) {
|
|
52
|
+
if (varName.endsWith(suffix)) return field;
|
|
53
|
+
}
|
|
54
|
+
return "id";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Strip a trailing FK-shape suffix (`_id`, `Id`, `_uuid`, `_slug`, `_name`,
|
|
58
|
+
* `_code`) from a var name and return the stem. Used by ARV-69 to find an
|
|
59
|
+
* owner resource when the resource map doesn't link the var to a list
|
|
60
|
+
* endpoint explicitly (common-style {id} placeholders).
|
|
61
|
+
*/
|
|
62
|
+
function stemFromVarName(varName: string): string | null {
|
|
63
|
+
const lower = varName.toLowerCase();
|
|
64
|
+
for (const suffix of ["_id", "_uuid", "_slug", "_name", "_code"]) {
|
|
65
|
+
if (lower.endsWith(suffix)) return lower.slice(0, -suffix.length);
|
|
66
|
+
}
|
|
67
|
+
// CamelCase: `domainId` → `domain`.
|
|
68
|
+
const m = varName.match(/^(.+?)(Id|Uuid)$/);
|
|
69
|
+
if (m) return m[1]!.toLowerCase();
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** ARV-69 (feedback round-02 / F10): try to find a resource whose
|
|
74
|
+
* list endpoint is a plausible source for `varName` based on the var's
|
|
75
|
+
* name stem. Matches singular ↔ plural and is case-insensitive. Returns
|
|
76
|
+
* the FkTarget on hit, undefined on miss.
|
|
77
|
+
*/
|
|
78
|
+
export function inferOwnerFromVarName(
|
|
79
|
+
varName: string,
|
|
80
|
+
map: ApiResourceMapYaml,
|
|
81
|
+
): FkTarget | undefined {
|
|
82
|
+
const stem = stemFromVarName(varName);
|
|
83
|
+
if (!stem) return undefined;
|
|
84
|
+
const candidates = new Set([stem, `${stem}s`, stem.endsWith("s") ? stem.slice(0, -1) : stem]);
|
|
85
|
+
for (const r of map.resources) {
|
|
86
|
+
if (!r.endpoints?.list) continue;
|
|
87
|
+
const lower = r.resource.toLowerCase();
|
|
88
|
+
if (candidates.has(lower)) {
|
|
89
|
+
return { varName, ownerResource: r.resource, listLabel: r.endpoints.list };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pickFieldFromObject(item: unknown, preferred: string): string | undefined {
|
|
96
|
+
if (!item || typeof item !== "object") return undefined;
|
|
97
|
+
const obj = item as Record<string, unknown>;
|
|
98
|
+
const tryKey = (k: string): string | undefined => {
|
|
99
|
+
if (k in obj) {
|
|
100
|
+
const v = obj[k];
|
|
101
|
+
if (typeof v === "string" || typeof v === "number") return String(v);
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
};
|
|
105
|
+
return (
|
|
106
|
+
tryKey(preferred) ??
|
|
107
|
+
tryKey("id") ??
|
|
108
|
+
tryKey("slug") ??
|
|
109
|
+
tryKey("uuid") ??
|
|
110
|
+
tryKey("key") ??
|
|
111
|
+
tryKey("name")
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Walk the response body for the first item matching common SaaS list shapes,
|
|
116
|
+
* then pick a field hint-aware. */
|
|
117
|
+
function extractFirstField(body: unknown, preferred: string): string | undefined {
|
|
118
|
+
if (Array.isArray(body)) return pickFieldFromObject(body[0], preferred);
|
|
119
|
+
if (body && typeof body === "object") {
|
|
120
|
+
const obj = body as Record<string, unknown>;
|
|
121
|
+
for (const key of ["data", "items", "results", "records"]) {
|
|
122
|
+
const arr = obj[key];
|
|
123
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
124
|
+
return pickFieldFromObject(arr[0], preferred);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** True when the list-response is well-shaped but contains zero items.
|
|
132
|
+
* Used to distinguish "no <entity> in target API yet — go create one"
|
|
133
|
+
* from "response shape unrecognized" (TASK-273). */
|
|
134
|
+
export function isEmptyListBody(body: unknown): boolean {
|
|
135
|
+
if (Array.isArray(body)) return body.length === 0;
|
|
136
|
+
if (body && typeof body === "object") {
|
|
137
|
+
const obj = body as Record<string, unknown>;
|
|
138
|
+
for (const key of ["data", "items", "results", "records"]) {
|
|
139
|
+
if (key in obj) {
|
|
140
|
+
const arr = obj[key];
|
|
141
|
+
return Array.isArray(arr) && arr.length === 0;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
import { printError, printSuccess, printWarning } from "../output.ts";
|
|
148
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
149
|
+
import { getSecretRegistry } from "../../core/secrets/registry.ts";
|
|
150
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../../core/generator/types.ts";
|
|
151
|
+
import type { RecommendedAction } from "../../core/diagnostics/failure-hints.ts";
|
|
152
|
+
|
|
153
|
+
export interface DiscoverOptions {
|
|
154
|
+
specPath: string;
|
|
155
|
+
/** Path to `apis/<name>/` — used to read .api-resources.yaml and write .env.yaml. */
|
|
156
|
+
apiDir: string;
|
|
157
|
+
/** Default `apis/<name>/.env.yaml`. */
|
|
158
|
+
envPath?: string;
|
|
159
|
+
apply?: boolean;
|
|
160
|
+
/** Per-request timeout (ms). */
|
|
161
|
+
timeoutMs?: number;
|
|
162
|
+
json?: boolean;
|
|
163
|
+
/** TASK-281: GET each fixture's read-by-id endpoint to classify live/stale/
|
|
164
|
+
* unknown. Without `--apply` this is a read-only report; with `--apply` (or
|
|
165
|
+
* the `--refresh` shortcut) stale fixtures are unset and re-resolved
|
|
166
|
+
* through the normal discover flow. */
|
|
167
|
+
verify?: boolean;
|
|
168
|
+
/** ARV-205/F19 (R10/R13/R14): command name surfaced in the JSON envelope.
|
|
169
|
+
* prepare-fixtures delegates here for the single-pass path; the envelope
|
|
170
|
+
* should reflect the user-facing command, not the internal "discover". */
|
|
171
|
+
commandName?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface FkTarget {
|
|
175
|
+
/** Env var name to fill (e.g. `audience_id`). */
|
|
176
|
+
varName: string;
|
|
177
|
+
/** Resource that owns the id (e.g. `audiences`). */
|
|
178
|
+
ownerResource: string;
|
|
179
|
+
/** List endpoint label, e.g. `GET /audiences`. */
|
|
180
|
+
listLabel: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface DiscoveryItem {
|
|
184
|
+
varName: string;
|
|
185
|
+
resource: string;
|
|
186
|
+
listPath: string;
|
|
187
|
+
/** What was found, if anything. */
|
|
188
|
+
discovered?: string;
|
|
189
|
+
/** What's currently in env (may be empty/placeholder). */
|
|
190
|
+
current?: string;
|
|
191
|
+
/** Action to take: write, skip-already-set, miss-no-list, miss-network, miss-status, miss-empty, miss-no-id. */
|
|
192
|
+
status:
|
|
193
|
+
| "write"
|
|
194
|
+
| "skip-already-set"
|
|
195
|
+
| "skip-already-equal"
|
|
196
|
+
| "skip-not-required"
|
|
197
|
+
| "miss-no-list"
|
|
198
|
+
| "miss-nested-list"
|
|
199
|
+
| "miss-no-owner"
|
|
200
|
+
| "miss-network"
|
|
201
|
+
| "miss-status"
|
|
202
|
+
| "miss-empty"
|
|
203
|
+
| "miss-no-id"
|
|
204
|
+
// TASK-281 verify-mode states
|
|
205
|
+
| "verify-live"
|
|
206
|
+
| "verify-stale"
|
|
207
|
+
| "verify-unknown"
|
|
208
|
+
| "verify-no-read"
|
|
209
|
+
| "verify-skip-empty"
|
|
210
|
+
// ARV-143: filled var classified as trusted user input — manifest source
|
|
211
|
+
// is user-config (auth/server/header) or there's no read-by-id endpoint
|
|
212
|
+
// for its resource. Refresh has no verification path, so we mark it as
|
|
213
|
+
// such instead of silently omitting (the doctor view that says set:true).
|
|
214
|
+
| "verify-user-config";
|
|
215
|
+
/** ARV-46: manifest-grade status enum projected onto agent-readable
|
|
216
|
+
* envelope. Filled when discover ran in manifest-driven mode.
|
|
217
|
+
* filled | failed:no-list-endpoint | failed:list-empty | failed:miss-network
|
|
218
|
+
* | skipped:already-set | skipped:not-required */
|
|
219
|
+
manifestStatus?: ManifestStatus;
|
|
220
|
+
/** ARV-46: source classification copied from `.api-fixtures.yaml`. */
|
|
221
|
+
manifestSource?: FixtureManifestEntry["source"];
|
|
222
|
+
reason?: string;
|
|
223
|
+
/** TASK-294: agent-routable action for items the user must fix.
|
|
224
|
+
* miss-* / verify-stale / verify-unknown → `fix_fixture`.
|
|
225
|
+
* miss-network → `fix_network_config`.
|
|
226
|
+
* write / skip-* / verify-live → undefined. */
|
|
227
|
+
recommended_action?: RecommendedAction;
|
|
228
|
+
/** ARV-142: when --refresh re-resolves a verify-stale item, the original
|
|
229
|
+
* verify-stale entry is replaced with the refresh outcome. This flag
|
|
230
|
+
* preserves the "was stale before refresh" signal so summary can
|
|
231
|
+
* report stale_fixed vs still_stale honestly. */
|
|
232
|
+
wasStale?: boolean;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** TASK-294: derive recommended_action from a DiscoveryItem's status. */
|
|
236
|
+
export function discoveryAction(status: DiscoveryItem["status"]): RecommendedAction | undefined {
|
|
237
|
+
if (status === "miss-network") return "fix_network_config";
|
|
238
|
+
if (status.startsWith("miss-") || status === "verify-stale" || status === "verify-unknown") {
|
|
239
|
+
return "fix_fixture";
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** ARV-46: stable manifest-grade status enum for agent consumers. The CLI
|
|
245
|
+
* prints this column when discover runs in manifest-driven mode and it's
|
|
246
|
+
* exposed verbatim in the JSON envelope. */
|
|
247
|
+
export type ManifestStatus =
|
|
248
|
+
| "filled"
|
|
249
|
+
| "failed:no-list-endpoint"
|
|
250
|
+
| "failed:list-empty"
|
|
251
|
+
| "failed:miss-network"
|
|
252
|
+
| "skipped:already-set"
|
|
253
|
+
| "skipped:not-required";
|
|
254
|
+
|
|
255
|
+
export function toManifestStatus(status: DiscoveryItem["status"]): ManifestStatus {
|
|
256
|
+
switch (status) {
|
|
257
|
+
case "write":
|
|
258
|
+
return "filled";
|
|
259
|
+
case "skip-already-set":
|
|
260
|
+
case "skip-already-equal":
|
|
261
|
+
return "skipped:already-set";
|
|
262
|
+
case "skip-not-required":
|
|
263
|
+
return "skipped:not-required";
|
|
264
|
+
case "miss-network":
|
|
265
|
+
return "failed:miss-network";
|
|
266
|
+
case "miss-empty":
|
|
267
|
+
return "failed:list-empty";
|
|
268
|
+
// miss-no-list / miss-nested-list / miss-no-owner / miss-status / miss-no-id —
|
|
269
|
+
// the underlying cause is "we have no usable list endpoint to read from", so
|
|
270
|
+
// they collapse onto the same manifest-level bucket.
|
|
271
|
+
default:
|
|
272
|
+
return "failed:no-list-endpoint";
|
|
273
|
+
}
|
|
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
|
+
};
|
|
324
|
+
/** ARV-172: per-resource state machine + action endpoints. */
|
|
325
|
+
lifecycle?: {
|
|
326
|
+
field: string;
|
|
327
|
+
states: string[];
|
|
328
|
+
transitions: Array<{ from: string; to: string[] }>;
|
|
329
|
+
actions: Record<string, {
|
|
330
|
+
endpoint: string;
|
|
331
|
+
expected_state: string;
|
|
332
|
+
body?: Record<string, unknown>;
|
|
333
|
+
}>;
|
|
334
|
+
};
|
|
335
|
+
/** ARV-187: LLM-authored example POST body for stateful checks that
|
|
336
|
+
* need a valid create payload. When present, stateful CRUD checks
|
|
337
|
+
* (cross_call_references, idempotency_replay, lifecycle_transitions,
|
|
338
|
+
* ensure_resource_availability, use_after_free) prefer this over
|
|
339
|
+
* `generateFromSchema(create.requestBodySchema)`. The fallback path
|
|
340
|
+
* stays — yaml is purely additive. `content_type` defaults to the
|
|
341
|
+
* create endpoint's `requestBodyContentType`. */
|
|
342
|
+
seed_body?: {
|
|
343
|
+
content_type?: string;
|
|
344
|
+
body: Record<string, unknown>;
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export interface ApiResourceMapYaml {
|
|
349
|
+
resources: ResourceYaml[];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** ARV-122 layer ids — exported so downstream code (doctor, future
|
|
353
|
+
* catalog --provenance) can compare against the provenance map
|
|
354
|
+
* without re-typing the strings. */
|
|
355
|
+
export const RESOURCE_LAYER_UPSTREAM = "upstream";
|
|
356
|
+
export const RESOURCE_LAYER_EXTENSION = "extension";
|
|
357
|
+
|
|
358
|
+
/** ARV-122: build the two-layer SpecLayer set for an API's resource
|
|
359
|
+
* map. Kept here (and not in `core/spec/layers.ts`) so the YAML
|
|
360
|
+
* loaders stay co-located with the schema types they parse. */
|
|
361
|
+
function buildResourceLayers(apiDir: string): SpecLayer<ResourceYaml>[] {
|
|
362
|
+
return [
|
|
363
|
+
{
|
|
364
|
+
id: RESOURCE_LAYER_UPSTREAM,
|
|
365
|
+
path: join(apiDir, ".api-resources.yaml"),
|
|
366
|
+
precedence: 10,
|
|
367
|
+
scope: "resources",
|
|
368
|
+
mergePolicy: "override",
|
|
369
|
+
load: async () => {
|
|
370
|
+
const file = Bun.file(join(apiDir, ".api-resources.yaml"));
|
|
371
|
+
if (!(await file.exists())) return [];
|
|
372
|
+
const parsed = Bun.YAML.parse(await file.text());
|
|
373
|
+
if (!parsed || typeof parsed !== "object") return [];
|
|
374
|
+
return (parsed as { resources?: ResourceYaml[] }).resources ?? [];
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
id: RESOURCE_LAYER_EXTENSION,
|
|
379
|
+
path: join(apiDir, ".api-resources.local.yaml"),
|
|
380
|
+
precedence: 20,
|
|
381
|
+
scope: "resources",
|
|
382
|
+
mergePolicy: "override",
|
|
383
|
+
load: () => readResourceExtensions(apiDir),
|
|
384
|
+
},
|
|
385
|
+
];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** ARV-122: compose the resource map through the SpecLayer pipeline,
|
|
389
|
+
* exposing the provenance map for callers that need to know which
|
|
390
|
+
* layer contributed a given resource (doctor diagnostics, m-18 CLI
|
|
391
|
+
* surface). `readResourceMap` keeps the legacy shape for callers
|
|
392
|
+
* that don't care. */
|
|
393
|
+
export async function composeResourceMap(
|
|
394
|
+
apiDir: string,
|
|
395
|
+
): Promise<ComposedSpec<ResourceYaml>> {
|
|
396
|
+
return composeSpec(buildResourceLayers(apiDir), (r) => r.resource);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function readResourceMap(apiDir: string): Promise<ApiResourceMapYaml | null> {
|
|
400
|
+
// Old contract: return null when the upstream `.api-resources.yaml`
|
|
401
|
+
// is missing (callers branch on this to surface a setup error). The
|
|
402
|
+
// SpecLayer pipeline returns an empty list in that case, so check
|
|
403
|
+
// existence explicitly to preserve behaviour.
|
|
404
|
+
const upstream = Bun.file(join(apiDir, ".api-resources.yaml"));
|
|
405
|
+
if (!(await upstream.exists())) return null;
|
|
406
|
+
|
|
407
|
+
// ARV-122: route the merge through composeSpec. Behaviour is
|
|
408
|
+
// identical to the previous ad-hoc Map merge — extension wins on
|
|
409
|
+
// name collision (precedence 20 > 10, mergePolicy: "override") —
|
|
410
|
+
// and the same path also feeds provenance into composeResourceMap.
|
|
411
|
+
const composed = await composeResourceMap(apiDir);
|
|
412
|
+
// ARV-169: field-level overlay for adding readback_diff / idempotency
|
|
413
|
+
// / pagination / lifecycle without re-declaring the whole entry.
|
|
414
|
+
const patches = await readResourcePatches(apiDir);
|
|
415
|
+
return { resources: applyResourcePatches(composed.entries, patches) };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** ARV-111: read `apis/<name>/.api-resources.local.yaml`. Same `resources:`
|
|
419
|
+
* shape as the main file (top-level `extensions:` key is the only
|
|
420
|
+
* difference, so the user can recognise it as a sibling). Returns [] when
|
|
421
|
+
* missing or empty so the merge path stays simple. */
|
|
422
|
+
export async function readResourceExtensions(apiDir: string): Promise<ResourceYaml[]> {
|
|
423
|
+
const path = join(apiDir, ".api-resources.local.yaml");
|
|
424
|
+
const file = Bun.file(path);
|
|
425
|
+
if (!(await file.exists())) return [];
|
|
426
|
+
const text = await file.text();
|
|
427
|
+
const parsed = Bun.YAML.parse(text);
|
|
428
|
+
if (!parsed || typeof parsed !== "object") return [];
|
|
429
|
+
const obj = parsed as { extensions?: ResourceYaml[] };
|
|
430
|
+
return obj.extensions ?? [];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** ARV-169 (m-20): partial overlay for adding fields (readback_diff,
|
|
434
|
+
* future idempotency / pagination / lifecycle) to an existing
|
|
435
|
+
* resource entry without re-declaring its CRUD wiring. Lives in the
|
|
436
|
+
* same `.api-resources.local.yaml` under top-level `patches:`. Each
|
|
437
|
+
* entry MUST carry `resource:` (the merge key); any other declared
|
|
438
|
+
* field overlays the upstream value, leaving omitted fields intact.
|
|
439
|
+
*
|
|
440
|
+
* Unlike `extensions:` (full replacement, ARV-111) this is field-
|
|
441
|
+
* level merge. Both can coexist in the same file. Returns [] when
|
|
442
|
+
* the file is missing or carries no `patches:` key. */
|
|
443
|
+
export async function readResourcePatches(apiDir: string): Promise<Array<Partial<ResourceYaml> & { resource: string }>> {
|
|
444
|
+
const path = join(apiDir, ".api-resources.local.yaml");
|
|
445
|
+
const file = Bun.file(path);
|
|
446
|
+
if (!(await file.exists())) return [];
|
|
447
|
+
const text = await file.text();
|
|
448
|
+
const parsed = Bun.YAML.parse(text);
|
|
449
|
+
if (!parsed || typeof parsed !== "object") return [];
|
|
450
|
+
const obj = parsed as { patches?: Array<Partial<ResourceYaml> & { resource?: string }> };
|
|
451
|
+
const raw = obj.patches ?? [];
|
|
452
|
+
return raw.filter((p): p is Partial<ResourceYaml> & { resource: string } =>
|
|
453
|
+
typeof p?.resource === "string" && p.resource.length > 0,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** ARV-169: apply partial patches over a composed resource list.
|
|
458
|
+
* Patch fields overwrite matching upstream fields; absent fields
|
|
459
|
+
* are preserved. Patches whose `resource` doesn't match anything
|
|
460
|
+
* upstream are dropped silently — callers wanting to ADD a whole
|
|
461
|
+
* resource use `extensions:` instead. */
|
|
462
|
+
function applyResourcePatches(
|
|
463
|
+
resources: ResourceYaml[],
|
|
464
|
+
patches: Array<Partial<ResourceYaml> & { resource: string }>,
|
|
465
|
+
): ResourceYaml[] {
|
|
466
|
+
if (patches.length === 0) return resources;
|
|
467
|
+
const byName = new Map(resources.map((r) => [r.resource, r] as const));
|
|
468
|
+
for (const p of patches) {
|
|
469
|
+
const upstream = byName.get(p.resource);
|
|
470
|
+
if (!upstream) continue;
|
|
471
|
+
byName.set(p.resource, { ...upstream, ...p });
|
|
472
|
+
}
|
|
473
|
+
return resources.map((r) => byName.get(r.resource) ?? r);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export interface FixtureManifestEntry {
|
|
477
|
+
name: string;
|
|
478
|
+
source: "auth" | "server" | "path" | "header" | "body-fk" | "capture-chain";
|
|
479
|
+
required: boolean;
|
|
480
|
+
description?: string;
|
|
481
|
+
defaultValue?: string;
|
|
482
|
+
affectedEndpoints?: string[];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export interface FixtureManifestYaml {
|
|
486
|
+
fixtures: FixtureManifestEntry[];
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Read `.api-fixtures.yaml`. Returns null when missing — caller falls back
|
|
490
|
+
* to the legacy resource-map-driven path. */
|
|
491
|
+
export async function readFixtureManifest(apiDir: string): Promise<FixtureManifestYaml | null> {
|
|
492
|
+
const path = join(apiDir, ".api-fixtures.yaml");
|
|
493
|
+
const file = Bun.file(path);
|
|
494
|
+
if (!(await file.exists())) return null;
|
|
495
|
+
const text = await file.text();
|
|
496
|
+
const parsed = Bun.YAML.parse(text);
|
|
497
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
498
|
+
const obj = parsed as { fixtures?: FixtureManifestEntry[] };
|
|
499
|
+
return { fixtures: obj.fixtures ?? [] };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Build the unique target list from FK deps. Each FK var = one discovery
|
|
503
|
+
* attempt (we hit the owner's list endpoint once and reuse the result).
|
|
504
|
+
*
|
|
505
|
+
* ARV-133: also include each resource's own idParam (when it has a list
|
|
506
|
+
* endpoint) — these are root-level required path-params with no fkDep edge
|
|
507
|
+
* to another resource, but they're trivially harvestable from the resource's
|
|
508
|
+
* own list endpoint. Without this, cascade silently skipped vars like
|
|
509
|
+
* `domain_id`, `webhook_id`, `template_id` even though `/domains`,
|
|
510
|
+
* `/webhooks`, `/templates` returned live data. Optional `manifest`
|
|
511
|
+
* parameter wires manifest-required path/body-fk vars onto a list endpoint
|
|
512
|
+
* via `inferOwnerFromVarName` (singular ↔ plural matching) so vars whose
|
|
513
|
+
* name doesn't appear in the resource map's idParam table still get
|
|
514
|
+
* attempted. */
|
|
515
|
+
export function collectTargets(
|
|
516
|
+
map: ApiResourceMapYaml,
|
|
517
|
+
manifest?: FixtureManifestYaml,
|
|
518
|
+
): FkTarget[] {
|
|
519
|
+
const seen = new Set<string>();
|
|
520
|
+
const out: FkTarget[] = [];
|
|
521
|
+
const push = (t: FkTarget): void => {
|
|
522
|
+
if (seen.has(t.varName)) return;
|
|
523
|
+
seen.add(t.varName);
|
|
524
|
+
out.push(t);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
// 1. fkDeps — parent-id edges declared by resource-builder.
|
|
528
|
+
for (const r of map.resources) {
|
|
529
|
+
for (const dep of r.fkDependencies ?? []) {
|
|
530
|
+
if (dep.in !== "path") continue;
|
|
531
|
+
if (!dep.ownerResource) continue;
|
|
532
|
+
const owner = map.resources.find(x => x.resource === dep.ownerResource);
|
|
533
|
+
const listLabel = owner?.endpoints.list ?? "";
|
|
534
|
+
push({ varName: dep.var, ownerResource: dep.ownerResource, listLabel });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 2. Each resource's own idParam → its own list endpoint. resource-builder's
|
|
539
|
+
// collectPathFkDeps skips this case (it emits only *parent* FKs), so
|
|
540
|
+
// without an explicit pass `domain_id`/`webhook_id`/etc. drop out of
|
|
541
|
+
// cascade entirely.
|
|
542
|
+
for (const r of map.resources) {
|
|
543
|
+
if (!r.idParam) continue;
|
|
544
|
+
if (!r.endpoints?.list) continue;
|
|
545
|
+
push({ varName: r.idParam, ownerResource: r.resource, listLabel: r.endpoints.list });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// 3. Manifest-required vars (path / body-fk) whose name doesn't match any
|
|
549
|
+
// fkDep edge or resource idParam. Use singular↔plural stemming to find
|
|
550
|
+
// an owner — same logic as the discover-via-manifest path uses (ARV-69).
|
|
551
|
+
if (manifest) {
|
|
552
|
+
for (const entry of manifest.fixtures) {
|
|
553
|
+
if (!entry.required) continue;
|
|
554
|
+
if (entry.source !== "path" && entry.source !== "body-fk") continue;
|
|
555
|
+
if (seen.has(entry.name)) continue;
|
|
556
|
+
const inferred = inferOwnerFromVarName(entry.name, map);
|
|
557
|
+
if (inferred) push(inferred);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export async function probeOne(
|
|
565
|
+
target: FkTarget,
|
|
566
|
+
current: string | undefined,
|
|
567
|
+
endpoints: EndpointInfo[],
|
|
568
|
+
schemes: SecuritySchemeInfo[],
|
|
569
|
+
vars: Record<string, string>,
|
|
570
|
+
baseUrl: string,
|
|
571
|
+
timeoutMs: number,
|
|
572
|
+
): Promise<DiscoveryItem> {
|
|
573
|
+
const item: DiscoveryItem = {
|
|
574
|
+
varName: target.varName,
|
|
575
|
+
resource: target.ownerResource,
|
|
576
|
+
listPath: "",
|
|
577
|
+
current,
|
|
578
|
+
status: "miss-no-list",
|
|
579
|
+
};
|
|
580
|
+
if (!target.listLabel) {
|
|
581
|
+
item.status = "miss-no-list";
|
|
582
|
+
item.reason = `resource "${target.ownerResource}" has no list endpoint in .api-resources.yaml`;
|
|
583
|
+
return item;
|
|
584
|
+
}
|
|
585
|
+
const parsed = parseEndpointLabel(target.listLabel);
|
|
586
|
+
if (!parsed) {
|
|
587
|
+
item.status = "miss-no-list";
|
|
588
|
+
item.reason = `cannot parse endpoint label "${target.listLabel}"`;
|
|
589
|
+
return item;
|
|
590
|
+
}
|
|
591
|
+
if (parsed.method !== "GET") {
|
|
592
|
+
item.status = "miss-no-list";
|
|
593
|
+
item.reason = `expected GET for list of ${target.ownerResource}, got ${parsed.method}`;
|
|
594
|
+
return item;
|
|
595
|
+
}
|
|
596
|
+
// For nested list paths (e.g. /orgs/{org}/projects/), substitute any
|
|
597
|
+
// parent path-params that are already known in vars. If all params resolve,
|
|
598
|
+
// proceed as a normal list call. Only bail if a param is still missing.
|
|
599
|
+
let effectivePath = parsed.path;
|
|
600
|
+
if (parsed.path.includes("{")) {
|
|
601
|
+
effectivePath = parsed.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
602
|
+
const val = vars[name];
|
|
603
|
+
return typeof val === "string" && val ? val : `{${name}}`;
|
|
604
|
+
});
|
|
605
|
+
if (effectivePath.includes("{")) {
|
|
606
|
+
item.status = "miss-nested-list";
|
|
607
|
+
item.reason = `nested collection (${parsed.path}) — missing parent fixture(s) in .env.yaml`;
|
|
608
|
+
return item;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
item.listPath = effectivePath;
|
|
612
|
+
|
|
613
|
+
// Already filled and not a placeholder → skip the call (live API, save it).
|
|
614
|
+
if (!isPlaceholder(current)) {
|
|
615
|
+
item.status = "skip-already-set";
|
|
616
|
+
return item;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const listEp = endpoints.find(
|
|
620
|
+
e => e.method.toUpperCase() === "GET" && e.path === parsed.path && !e.deprecated,
|
|
621
|
+
);
|
|
622
|
+
if (!listEp) {
|
|
623
|
+
item.status = "miss-no-list";
|
|
624
|
+
item.reason = `${parsed.path} not found in spec endpoints (resource map drift?)`;
|
|
625
|
+
return item;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${effectivePath}`;
|
|
629
|
+
const headers: Record<string, string> = {
|
|
630
|
+
accept: "application/json",
|
|
631
|
+
...liveAuthHeaders(listEp, schemes, vars),
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
let resp;
|
|
635
|
+
try {
|
|
636
|
+
// ARV-48: 1 network-class retry with exp+jitter backoff. Transient
|
|
637
|
+
// DNS/connection-reset blips on shared CI runners must not cost the
|
|
638
|
+
// user a whole prepare-fixtures rerun. Only network errors retry —
|
|
639
|
+
// 4xx/5xx HTTP statuses keep their existing branches (miss-status).
|
|
640
|
+
resp = await executeRequest(
|
|
641
|
+
{ method: "GET", url, headers },
|
|
642
|
+
{ timeout: timeoutMs, retries: 0, network_retries: 1 },
|
|
643
|
+
);
|
|
644
|
+
} catch (err) {
|
|
645
|
+
item.status = "miss-network";
|
|
646
|
+
item.reason = `network error: ${err instanceof Error ? err.message : String(err)}`;
|
|
647
|
+
return item;
|
|
648
|
+
}
|
|
649
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
650
|
+
item.status = "miss-status";
|
|
651
|
+
item.reason = `${parsed.method} ${parsed.path} → ${resp.status}`;
|
|
652
|
+
return item;
|
|
653
|
+
}
|
|
654
|
+
const respBody = resp.body_parsed ?? resp.body;
|
|
655
|
+
const id = extractFirstField(respBody, preferredFieldFromVar(target.varName));
|
|
656
|
+
if (id === undefined) {
|
|
657
|
+
// TASK-273: empty target-API is the most common cause of miss-no-id on
|
|
658
|
+
// fresh workspaces. Distinguish "list is well-shaped but empty" from
|
|
659
|
+
// "list shape unrecognized" so the user gets actionable guidance instead
|
|
660
|
+
// of guessing for 30 minutes whether zond is broken.
|
|
661
|
+
if (isEmptyListBody(respBody)) {
|
|
662
|
+
item.status = "miss-empty";
|
|
663
|
+
item.reason =
|
|
664
|
+
`no ${target.ownerResource} in target API — re-run with \`zond prepare-fixtures --api <name> --seed --apply\` ` +
|
|
665
|
+
`to POST-create one automatically, or create the resource yourself (in the product UI or via API) and re-run discover`;
|
|
666
|
+
} else {
|
|
667
|
+
item.status = "miss-no-id";
|
|
668
|
+
item.reason = `response shape has no extractable first id (no array/data/items/results/records field)`;
|
|
669
|
+
}
|
|
670
|
+
return item;
|
|
671
|
+
}
|
|
672
|
+
if (current && current === id) {
|
|
673
|
+
item.discovered = id;
|
|
674
|
+
item.status = "skip-already-equal";
|
|
675
|
+
return item;
|
|
676
|
+
}
|
|
677
|
+
item.discovered = id;
|
|
678
|
+
item.status = "write";
|
|
679
|
+
return item;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** TASK-281: GET <ownerResource>'s read-by-id endpoint with the current
|
|
683
|
+
* fixture value and classify the result. 5xx is treated as `unknown` (don't
|
|
684
|
+
* trash valid fixtures over a flaky API). */
|
|
685
|
+
export async function verifyOne(
|
|
686
|
+
target: FkTarget,
|
|
687
|
+
current: string | undefined,
|
|
688
|
+
ownerResource: ResourceYaml | undefined,
|
|
689
|
+
endpoints: EndpointInfo[],
|
|
690
|
+
schemes: SecuritySchemeInfo[],
|
|
691
|
+
vars: Record<string, string>,
|
|
692
|
+
baseUrl: string,
|
|
693
|
+
timeoutMs: number,
|
|
694
|
+
): Promise<DiscoveryItem> {
|
|
695
|
+
const item: DiscoveryItem = {
|
|
696
|
+
varName: target.varName,
|
|
697
|
+
resource: target.ownerResource,
|
|
698
|
+
listPath: "",
|
|
699
|
+
current,
|
|
700
|
+
status: "verify-unknown",
|
|
701
|
+
};
|
|
702
|
+
if (isPlaceholder(current)) {
|
|
703
|
+
item.status = "verify-skip-empty";
|
|
704
|
+
item.reason = "fixture is empty/placeholder — nothing to verify";
|
|
705
|
+
return item;
|
|
706
|
+
}
|
|
707
|
+
if (!ownerResource?.endpoints?.read) {
|
|
708
|
+
item.status = "verify-no-read";
|
|
709
|
+
item.reason = `resource "${target.ownerResource}" has no read-by-id endpoint in .api-resources.yaml`;
|
|
710
|
+
return item;
|
|
711
|
+
}
|
|
712
|
+
const parsed = parseEndpointLabel(ownerResource.endpoints.read);
|
|
713
|
+
if (!parsed) {
|
|
714
|
+
item.status = "verify-no-read";
|
|
715
|
+
item.reason = `cannot parse read endpoint label "${ownerResource.endpoints.read}"`;
|
|
716
|
+
return item;
|
|
717
|
+
}
|
|
718
|
+
// Substitute parent path-params from env vars; the resource's own idParam is
|
|
719
|
+
// taken from `current` (we are verifying that very value).
|
|
720
|
+
const idParam = ownerResource.idParam;
|
|
721
|
+
let effectivePath = parsed.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
722
|
+
if (name === idParam) return current!;
|
|
723
|
+
const val = vars[name];
|
|
724
|
+
return typeof val === "string" && val ? val : `{${name}}`;
|
|
725
|
+
});
|
|
726
|
+
if (effectivePath.includes("{")) {
|
|
727
|
+
item.status = "verify-unknown";
|
|
728
|
+
item.reason = `cannot resolve parent path-params for ${parsed.path}`;
|
|
729
|
+
return item;
|
|
730
|
+
}
|
|
731
|
+
item.listPath = effectivePath;
|
|
732
|
+
|
|
733
|
+
const ep = endpoints.find(
|
|
734
|
+
e => e.method.toUpperCase() === "GET" && e.path === parsed.path && !e.deprecated,
|
|
735
|
+
);
|
|
736
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${effectivePath}`;
|
|
737
|
+
const headers: Record<string, string> = {
|
|
738
|
+
accept: "application/json",
|
|
739
|
+
...(ep ? liveAuthHeaders(ep, schemes, vars) : {}),
|
|
740
|
+
};
|
|
741
|
+
let resp;
|
|
742
|
+
try {
|
|
743
|
+
// ARV-48: same single network-class retry as the discover probe.
|
|
744
|
+
resp = await executeRequest({ method: "GET", url, headers }, { timeout: timeoutMs, retries: 0, network_retries: 1 });
|
|
745
|
+
} catch (err) {
|
|
746
|
+
item.status = "verify-unknown";
|
|
747
|
+
item.reason = `network error: ${err instanceof Error ? err.message : String(err)}`;
|
|
748
|
+
return item;
|
|
749
|
+
}
|
|
750
|
+
if (resp.status >= 200 && resp.status < 300) {
|
|
751
|
+
item.status = "verify-live";
|
|
752
|
+
item.discovered = current;
|
|
753
|
+
return item;
|
|
754
|
+
}
|
|
755
|
+
if (resp.status === 404 || resp.status === 410) {
|
|
756
|
+
item.status = "verify-stale";
|
|
757
|
+
item.reason = `${parsed.method} ${effectivePath} → ${resp.status}`;
|
|
758
|
+
return item;
|
|
759
|
+
}
|
|
760
|
+
// 401/403 — token/scope issue, not a stale id; 5xx — flake; treat both as
|
|
761
|
+
// unknown so we never delete a fixture on shaky evidence.
|
|
762
|
+
item.status = "verify-unknown";
|
|
763
|
+
item.reason = `${parsed.method} ${effectivePath} → ${resp.status}`;
|
|
764
|
+
return item;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Append-or-update a key in YAML text. Conservative: matches `<key>:` at
|
|
768
|
+
* the start of a line and rewrites the value, preserving trailing comments
|
|
769
|
+
* that documented original placeholders. */
|
|
770
|
+
export function upsertEnvLine(yamlText: string, key: string, value: string): string {
|
|
771
|
+
const lines = yamlText.split("\n");
|
|
772
|
+
const re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`);
|
|
773
|
+
const idx = lines.findIndex(l => re.test(l));
|
|
774
|
+
const newLine = `${key}: ${JSON.stringify(value)}`;
|
|
775
|
+
if (idx === -1) {
|
|
776
|
+
// Insert before final newline if file ends with one, otherwise append.
|
|
777
|
+
if (lines[lines.length - 1] === "") {
|
|
778
|
+
lines.splice(lines.length - 1, 0, newLine);
|
|
779
|
+
} else {
|
|
780
|
+
lines.push(newLine);
|
|
781
|
+
}
|
|
782
|
+
} else {
|
|
783
|
+
lines[idx] = newLine;
|
|
784
|
+
}
|
|
785
|
+
return lines.join("\n");
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export async function discoverCommand(options: DiscoverOptions): Promise<number> {
|
|
789
|
+
const commandName = options.commandName ?? "discover";
|
|
790
|
+
try {
|
|
791
|
+
const doc = await readOpenApiSpec(options.specPath);
|
|
792
|
+
const endpoints = extractEndpoints(doc);
|
|
793
|
+
const securitySchemes = extractSecuritySchemes(doc);
|
|
794
|
+
|
|
795
|
+
const resourceMap = await readResourceMap(options.apiDir);
|
|
796
|
+
if (!resourceMap || resourceMap.resources.length === 0) {
|
|
797
|
+
const msg = `No .api-resources.yaml in ${options.apiDir}. Run 'zond refresh-api <name>' to (re)build it.`;
|
|
798
|
+
if (options.json) printJson(jsonError(commandName, [msg]));
|
|
799
|
+
else printError(msg);
|
|
800
|
+
return 2;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const envPath = options.envPath ?? join(options.apiDir, ".env.yaml");
|
|
804
|
+
const env = (await loadEnvFile(envPath)) ?? {};
|
|
805
|
+
// ARV-143 follow-up (security regression fix): register every loaded var
|
|
806
|
+
// with the SecretRegistry so the user-config bucket (and any other path
|
|
807
|
+
// that incidentally echoes a value) can't leak `.secrets.yaml`-resolved
|
|
808
|
+
// tokens to stdout / scrollback / tee. base_url is filtered out because
|
|
809
|
+
// we have to print it verbatim in the discovery header.
|
|
810
|
+
{
|
|
811
|
+
const reg = getSecretRegistry();
|
|
812
|
+
for (const [k, v] of Object.entries(env)) {
|
|
813
|
+
if (k === "base_url") continue;
|
|
814
|
+
reg.register(k, v);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const baseUrl = env["base_url"];
|
|
818
|
+
if (!baseUrl) {
|
|
819
|
+
const msg = `base_url is required in ${envPath} (live API calls need it).`;
|
|
820
|
+
if (options.json) printJson(jsonError(commandName, [msg]));
|
|
821
|
+
else printError(msg);
|
|
822
|
+
return 2;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ARV-46: manifest is the source-of-truth for the *list* of variables
|
|
826
|
+
// this API needs (per decision-7). When `.api-fixtures.yaml` exists,
|
|
827
|
+
// discover iterates it instead of `.env.yaml` keys / FK deps directly,
|
|
828
|
+
// so vars present in tests but absent from FK deps still show up in the
|
|
829
|
+
// status table — and env keys without a manifest entry surface as a
|
|
830
|
+
// warning instead of being silently ignored.
|
|
831
|
+
const manifest = await readFixtureManifest(options.apiDir);
|
|
832
|
+
|
|
833
|
+
const targets = collectTargets(resourceMap);
|
|
834
|
+
if (targets.length === 0 && !manifest) {
|
|
835
|
+
if (options.json) {
|
|
836
|
+
printJson(jsonOk(commandName, { items: [], message: "No path-FK dependencies with known owner resources." }));
|
|
837
|
+
} else {
|
|
838
|
+
console.log("No path-FK dependencies with known owner resources — nothing to discover.");
|
|
839
|
+
}
|
|
840
|
+
return 0;
|
|
841
|
+
}
|
|
842
|
+
// Index targets by var name so manifest entries can resolve their owner
|
|
843
|
+
// resource via the FK chain (manifest knows *what*, resource map knows
|
|
844
|
+
// *where to fetch*).
|
|
845
|
+
const targetsByVar = new Map<string, FkTarget>();
|
|
846
|
+
for (const t of targets) targetsByVar.set(t.varName, t);
|
|
847
|
+
// Resource map's `collectPathFkDeps` skips the resource's own idParam —
|
|
848
|
+
// it only emits *parent* FKs. The manifest legitimately wants discover
|
|
849
|
+
// to fill `api_key_id` (idParam of /api-keys/{api_key_id}) from the list
|
|
850
|
+
// endpoint /api-keys, so wire each resource's own idParam onto its list
|
|
851
|
+
// endpoint here. This is what makes "discover walks the manifest" not
|
|
852
|
+
// collapse 80% of entries into failed:no-list-endpoint.
|
|
853
|
+
for (const r of resourceMap.resources) {
|
|
854
|
+
if (!r.idParam || !r.endpoints?.list) continue;
|
|
855
|
+
if (targetsByVar.has(r.idParam)) continue;
|
|
856
|
+
targetsByVar.set(r.idParam, {
|
|
857
|
+
varName: r.idParam,
|
|
858
|
+
ownerResource: r.resource,
|
|
859
|
+
listLabel: r.endpoints.list,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// TASK-281: --verify mode — GET the read-by-id endpoint for every fixture
|
|
864
|
+
// and classify (live / stale / unknown). Without --apply this is purely
|
|
865
|
+
// diagnostic; with --apply we unset stale entries and re-resolve them via
|
|
866
|
+
// the regular discover flow below.
|
|
867
|
+
const items: DiscoveryItem[] = [];
|
|
868
|
+
if (options.verify) {
|
|
869
|
+
for (const target of targets) {
|
|
870
|
+
const owner = resourceMap.resources.find(r => r.resource === target.ownerResource);
|
|
871
|
+
const item = await verifyOne(
|
|
872
|
+
target,
|
|
873
|
+
env[target.varName],
|
|
874
|
+
owner,
|
|
875
|
+
endpoints,
|
|
876
|
+
securitySchemes,
|
|
877
|
+
env,
|
|
878
|
+
baseUrl,
|
|
879
|
+
options.timeoutMs ?? 30000,
|
|
880
|
+
);
|
|
881
|
+
items.push(item);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// For each stale fixture, drop it from env so the upcoming probeOne call
|
|
885
|
+
// treats it as a placeholder and re-resolves through the list endpoint.
|
|
886
|
+
// Without --apply we stop here — verify is read-only by default.
|
|
887
|
+
if (options.apply) {
|
|
888
|
+
for (const item of items) {
|
|
889
|
+
if (item.status === "verify-stale") delete env[item.varName];
|
|
890
|
+
}
|
|
891
|
+
// Re-resolve only the previously-stale targets — leaves unverified live
|
|
892
|
+
// ones in place (no point hitting the list endpoint for them).
|
|
893
|
+
const staleTargets = targets.filter(t => items.some(i => i.varName === t.varName && i.status === "verify-stale"));
|
|
894
|
+
for (const target of staleTargets) {
|
|
895
|
+
const refreshed = await probeOne(
|
|
896
|
+
target,
|
|
897
|
+
env[target.varName],
|
|
898
|
+
endpoints,
|
|
899
|
+
securitySchemes,
|
|
900
|
+
env,
|
|
901
|
+
baseUrl,
|
|
902
|
+
options.timeoutMs ?? 30000,
|
|
903
|
+
);
|
|
904
|
+
// Replace the verify-stale entry with the refresh outcome.
|
|
905
|
+
// ARV-142: preserve the wasStale marker so summary can count
|
|
906
|
+
// stale_fixed (refresh succeeded) vs still_stale (refresh failed).
|
|
907
|
+
const idx = items.findIndex(i => i.varName === target.varName);
|
|
908
|
+
if (idx >= 0) {
|
|
909
|
+
refreshed.wasStale = true;
|
|
910
|
+
items[idx] = refreshed;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ARV-143: surface filled vars that verify can't validate so the user
|
|
916
|
+
// doesn't think they're missing. Two buckets:
|
|
917
|
+
// 1. manifest user-config sources (auth / server / header) — never
|
|
918
|
+
// had a read endpoint, refresh just trusts the value.
|
|
919
|
+
// 2. targets whose verifyOne returned verify-no-read (resource exists
|
|
920
|
+
// but `.api-resources.yaml` has no read endpoint) — same story.
|
|
921
|
+
// Without this, refresh emitted "0 stale" + silence on these vars,
|
|
922
|
+
// contradicting doctor's set:true reporting (feedback-02 F12).
|
|
923
|
+
if (manifest) {
|
|
924
|
+
const seen = new Set(items.map(i => i.varName));
|
|
925
|
+
for (const entry of manifest.fixtures) {
|
|
926
|
+
if (seen.has(entry.name)) continue;
|
|
927
|
+
const current = env[entry.name];
|
|
928
|
+
if (!current || isPlaceholder(current)) continue;
|
|
929
|
+
const isUserConfig =
|
|
930
|
+
entry.source === "auth" ||
|
|
931
|
+
entry.source === "server" ||
|
|
932
|
+
entry.source === "header";
|
|
933
|
+
if (!isUserConfig) continue;
|
|
934
|
+
items.push({
|
|
935
|
+
varName: entry.name,
|
|
936
|
+
resource: "",
|
|
937
|
+
listPath: "",
|
|
938
|
+
current,
|
|
939
|
+
status: "verify-user-config",
|
|
940
|
+
manifestSource: entry.source,
|
|
941
|
+
reason: `${entry.source} var — no verification path, value trusted from .env.yaml`,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
// Promote verify-no-read items with a filled value to the same bucket
|
|
945
|
+
// so they show up under "trusted user input" in the summary instead of
|
|
946
|
+
// being lumped with empty/skip items.
|
|
947
|
+
for (const item of items) {
|
|
948
|
+
if (item.status === "verify-no-read" && item.current && !isPlaceholder(item.current)) {
|
|
949
|
+
item.status = "verify-user-config";
|
|
950
|
+
item.reason = `no read-by-id endpoint in .api-resources.yaml — value trusted from .env.yaml`;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
} else if (manifest) {
|
|
955
|
+
// ARV-46: drive the loop by manifest entries (one row per entry).
|
|
956
|
+
// Each entry's status maps onto the manifest-grade enum so agents
|
|
957
|
+
// get a stable contract independent of the underlying probe shape.
|
|
958
|
+
for (const entry of manifest.fixtures) {
|
|
959
|
+
const current = env[entry.name];
|
|
960
|
+
const placeholder: DiscoveryItem = {
|
|
961
|
+
varName: entry.name,
|
|
962
|
+
resource: "",
|
|
963
|
+
listPath: "",
|
|
964
|
+
current,
|
|
965
|
+
status: "skip-not-required",
|
|
966
|
+
manifestSource: entry.source,
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
// Sources that discover does not own: the user fills these (auth/
|
|
970
|
+
// server/header) or the runtime captures them (capture-chain).
|
|
971
|
+
// required:false manifest entries (currently capture-chain) are also
|
|
972
|
+
// not the discover loop's responsibility.
|
|
973
|
+
const isOwnedByDiscover =
|
|
974
|
+
entry.required && (entry.source === "path" || entry.source === "body-fk");
|
|
975
|
+
if (!isOwnedByDiscover) {
|
|
976
|
+
placeholder.status = "skip-not-required";
|
|
977
|
+
placeholder.manifestStatus = "skipped:not-required";
|
|
978
|
+
items.push(placeholder);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Already filled (and not a TODO placeholder) — leave it alone.
|
|
983
|
+
if (!isPlaceholder(current)) {
|
|
984
|
+
placeholder.status = "skip-already-set";
|
|
985
|
+
placeholder.manifestStatus = "skipped:already-set";
|
|
986
|
+
items.push(placeholder);
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Resolve owner resource via FK chain. body-fk vars often share the
|
|
991
|
+
// name with a path-param of another resource (audience_id ↔
|
|
992
|
+
// /audiences/{id}); resource map's collectBodyFkDeps already does
|
|
993
|
+
// name-stemming inference for us. A miss here means we have nothing
|
|
994
|
+
// to GET — the entry stays in the table as failed:no-list-endpoint.
|
|
995
|
+
let target = targetsByVar.get(entry.name);
|
|
996
|
+
if (!target) {
|
|
997
|
+
// ARV-69 (feedback round-02 / F10): the resource map only links a
|
|
998
|
+
// var to a list endpoint when the path explicitly carries it as a
|
|
999
|
+
// path-param (e.g. /audiences/{audience_id}). common-style APIs
|
|
1000
|
+
// commonly use the generic {id} placeholder, so vars like
|
|
1001
|
+
// `domain_id` / `segment_id` / `log_id` end up with no fkDep edge
|
|
1002
|
+
// even though /domains, /segments, /logs are perfectly usable as
|
|
1003
|
+
// list endpoints. Try a name-stemming fallback: strip the FK
|
|
1004
|
+
// suffix and match a resource whose name is the singular or plural
|
|
1005
|
+
// form.
|
|
1006
|
+
const inferred = inferOwnerFromVarName(entry.name, resourceMap);
|
|
1007
|
+
if (inferred) target = inferred;
|
|
1008
|
+
}
|
|
1009
|
+
if (!target) {
|
|
1010
|
+
placeholder.status = "miss-no-list";
|
|
1011
|
+
placeholder.manifestStatus = "failed:no-list-endpoint";
|
|
1012
|
+
placeholder.reason = `${entry.source}-source var has no owner resource in .api-resources.yaml — cannot derive a list endpoint`;
|
|
1013
|
+
items.push(placeholder);
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const item = await probeOne(
|
|
1018
|
+
target,
|
|
1019
|
+
current,
|
|
1020
|
+
endpoints,
|
|
1021
|
+
securitySchemes,
|
|
1022
|
+
env,
|
|
1023
|
+
baseUrl,
|
|
1024
|
+
options.timeoutMs ?? 30000,
|
|
1025
|
+
);
|
|
1026
|
+
item.manifestSource = entry.source;
|
|
1027
|
+
item.manifestStatus = toManifestStatus(item.status);
|
|
1028
|
+
items.push(item);
|
|
1029
|
+
}
|
|
1030
|
+
} else {
|
|
1031
|
+
// Legacy path: no manifest in the workspace — probe FK targets directly.
|
|
1032
|
+
for (const target of targets) {
|
|
1033
|
+
const current = env[target.varName];
|
|
1034
|
+
const item = await probeOne(
|
|
1035
|
+
target,
|
|
1036
|
+
current,
|
|
1037
|
+
endpoints,
|
|
1038
|
+
securitySchemes,
|
|
1039
|
+
env,
|
|
1040
|
+
baseUrl,
|
|
1041
|
+
options.timeoutMs ?? 30000,
|
|
1042
|
+
);
|
|
1043
|
+
items.push(item);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// TASK-294: stamp every item with recommended_action before consumers
|
|
1048
|
+
// (--json envelope, summary printer) read it.
|
|
1049
|
+
for (const it of items) {
|
|
1050
|
+
const action = discoveryAction(it.status);
|
|
1051
|
+
if (action) it.recommended_action = action;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const writes = items.filter(i => i.status === "write");
|
|
1055
|
+
let applied = false;
|
|
1056
|
+
let backupPath: string | null = null;
|
|
1057
|
+
if (options.apply && writes.length > 0) {
|
|
1058
|
+
backupPath = `${envPath}.bak`;
|
|
1059
|
+
try {
|
|
1060
|
+
await copyFile(envPath, backupPath);
|
|
1061
|
+
} catch {
|
|
1062
|
+
// missing source — write fresh; no backup needed.
|
|
1063
|
+
backupPath = null;
|
|
1064
|
+
}
|
|
1065
|
+
const file = Bun.file(envPath);
|
|
1066
|
+
let text = (await file.exists()) ? await file.text() : "";
|
|
1067
|
+
for (const w of writes) {
|
|
1068
|
+
text = upsertEnvLine(text, w.varName, w.discovered!);
|
|
1069
|
+
}
|
|
1070
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
1071
|
+
await Bun.write(envPath, text);
|
|
1072
|
+
applied = true;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// ARV-46: env keys without a manifest entry are noise — the user (or a
|
|
1076
|
+
// legacy hand-edit) put them there; the API doesn't actually need them.
|
|
1077
|
+
// Surface as warning so they can be removed; do not act on them.
|
|
1078
|
+
let unknownEnvKeys: string[] = [];
|
|
1079
|
+
if (manifest) {
|
|
1080
|
+
const manifestNames = new Set(manifest.fixtures.map(f => f.name));
|
|
1081
|
+
unknownEnvKeys = Object.keys(env).filter(k => !manifestNames.has(k));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const requiredManifestCount = manifest
|
|
1085
|
+
? manifest.fixtures.filter(f => f.required).length
|
|
1086
|
+
: 0;
|
|
1087
|
+
// ARV-143: in verify/refresh mode `manifestStatus` is not populated by
|
|
1088
|
+
// the verify loop (it uses verify-* statuses instead). Count verify-live
|
|
1089
|
+
// and verify-user-config as "filled" so the "Filled X/Y" line agrees with
|
|
1090
|
+
// doctor and the user_config bucket isn't double-counted as UNSET.
|
|
1091
|
+
const filledCount = items.filter(i =>
|
|
1092
|
+
i.manifestStatus === "filled" ||
|
|
1093
|
+
i.status === "verify-live" ||
|
|
1094
|
+
i.status === "verify-user-config" ||
|
|
1095
|
+
(i.wasStale === true && i.status === "write"),
|
|
1096
|
+
).length;
|
|
1097
|
+
|
|
1098
|
+
if (options.json) {
|
|
1099
|
+
// ARV-143 follow-up: strip raw secret values from items[].current so the
|
|
1100
|
+
// JSON envelope can't leak `.secrets.yaml`-resolved tokens. The
|
|
1101
|
+
// SecretRegistry registered every non-base_url env var above, so
|
|
1102
|
+
// redactObject swaps any registered value for `<redacted:<name>>`.
|
|
1103
|
+
const safeItems = getSecretRegistry().redactObject(items);
|
|
1104
|
+
printJson(jsonOk(commandName, {
|
|
1105
|
+
envPath,
|
|
1106
|
+
applied,
|
|
1107
|
+
backup: backupPath,
|
|
1108
|
+
items: safeItems,
|
|
1109
|
+
summary: {
|
|
1110
|
+
total: items.length,
|
|
1111
|
+
writes: writes.length,
|
|
1112
|
+
alreadySet: items.filter(i => i.status === "skip-already-set").length,
|
|
1113
|
+
misses: items.filter(i => i.status.startsWith("miss-")).length,
|
|
1114
|
+
...(manifest ? {
|
|
1115
|
+
manifest: {
|
|
1116
|
+
required: requiredManifestCount,
|
|
1117
|
+
filled: filledCount,
|
|
1118
|
+
unknownEnvKeys,
|
|
1119
|
+
},
|
|
1120
|
+
} : {}),
|
|
1121
|
+
...(options.verify ? {
|
|
1122
|
+
verify: {
|
|
1123
|
+
live: items.filter(i => i.status === "verify-live").length,
|
|
1124
|
+
// ARV-142: items currently classified as stale (refresh didn't
|
|
1125
|
+
// overwrite them — either --apply was off, or refresh failed).
|
|
1126
|
+
stale: items.filter(i => i.status === "verify-stale").length,
|
|
1127
|
+
// ARV-142: stale items that --refresh successfully re-resolved.
|
|
1128
|
+
stale_fixed: items.filter(i => i.wasStale === true && i.status === "write").length,
|
|
1129
|
+
// ARV-142: stale items where --refresh ran but couldn't write a
|
|
1130
|
+
// new value (e.g. list endpoint empty / unreachable).
|
|
1131
|
+
still_stale: items.filter(i => i.wasStale === true && i.status !== "write").length,
|
|
1132
|
+
unknown: items.filter(i => i.status === "verify-unknown").length,
|
|
1133
|
+
skipped: items.filter(i => i.status === "verify-skip-empty" || i.status === "verify-no-read").length,
|
|
1134
|
+
// ARV-143: filled vars with no verify path (user-config /
|
|
1135
|
+
// resource-without-read). Doctor reports these as set:true;
|
|
1136
|
+
// refresh now agrees by surfacing them in their own bucket.
|
|
1137
|
+
user_config: items.filter(i => i.status === "verify-user-config").length,
|
|
1138
|
+
},
|
|
1139
|
+
} : {}),
|
|
1140
|
+
},
|
|
1141
|
+
}));
|
|
1142
|
+
} else {
|
|
1143
|
+
console.log(`Discovery against ${baseUrl} (${envPath}):`);
|
|
1144
|
+
console.log("");
|
|
1145
|
+
const cols = ["var", "source", "resource", "list", "status", "value/reason"];
|
|
1146
|
+
const rows = items.map(i => [
|
|
1147
|
+
i.varName,
|
|
1148
|
+
i.manifestSource ?? "—",
|
|
1149
|
+
i.resource || "—",
|
|
1150
|
+
i.listPath || "—",
|
|
1151
|
+
i.manifestStatus ?? i.status,
|
|
1152
|
+
i.status === "write"
|
|
1153
|
+
? i.discovered!
|
|
1154
|
+
: i.status === "skip-already-set"
|
|
1155
|
+
? `(kept: ${i.current})`
|
|
1156
|
+
: i.status === "skip-already-equal"
|
|
1157
|
+
? `(unchanged: ${i.current})`
|
|
1158
|
+
: i.status === "skip-not-required"
|
|
1159
|
+
? `(not owned by discover)`
|
|
1160
|
+
: i.status === "verify-live"
|
|
1161
|
+
? `(live: ${i.current})`
|
|
1162
|
+
: i.status === "verify-stale"
|
|
1163
|
+
? `(stale: ${i.current})${i.reason ? ` — ${i.reason}` : ""}`
|
|
1164
|
+
: i.status === "verify-user-config"
|
|
1165
|
+
// ARV-143 follow-up: never echo the raw value here —
|
|
1166
|
+
// auth/header sources routinely carry tokens, and even
|
|
1167
|
+
// server URLs can be sensitive. Mirror doctor's
|
|
1168
|
+
// set/length-only contract from .secrets.yaml handling.
|
|
1169
|
+
? `(trusted, length=${(i.current ?? "").length})`
|
|
1170
|
+
: (i.reason ?? ""),
|
|
1171
|
+
]);
|
|
1172
|
+
// ARV-143 follow-up: redact every text cell through SecretRegistry so
|
|
1173
|
+
// an `auth_token` that happens to slip into a `(kept: ...)` /
|
|
1174
|
+
// `(live: ...)` cell can't reach stdout / scrollback / tee. The
|
|
1175
|
+
// verify-user-config branch already substitutes length-only — this
|
|
1176
|
+
// is defense in depth for the other status branches.
|
|
1177
|
+
const reg = getSecretRegistry();
|
|
1178
|
+
for (const r of rows) for (let i = 0; i < r.length; i++) r[i] = reg.redact(r[i]!);
|
|
1179
|
+
const widths = cols.map((h, i) => Math.max(h.length, ...rows.map(r => r[i]!.length)));
|
|
1180
|
+
const fmt = (cells: string[]) => cells.map((c, i) => c.padEnd(widths[i]!)).join(" ");
|
|
1181
|
+
console.log(fmt(cols));
|
|
1182
|
+
console.log(widths.map(w => "─".repeat(w)).join(" "));
|
|
1183
|
+
for (const r of rows) console.log(fmt(r));
|
|
1184
|
+
console.log("");
|
|
1185
|
+
if (options.verify) {
|
|
1186
|
+
const live = items.filter(i => i.status === "verify-live").length;
|
|
1187
|
+
const stale = items.filter(i => i.status === "verify-stale").length;
|
|
1188
|
+
const unknown = items.filter(i => i.status === "verify-unknown").length;
|
|
1189
|
+
// ARV-142: split stale-fixed vs still-stale so refresh telemetry no
|
|
1190
|
+
// longer hides "0 stale" while quietly overwriting on disk.
|
|
1191
|
+
const staleFixed = items.filter(i => i.wasStale === true && i.status === "write").length;
|
|
1192
|
+
const stillStale = items.filter(i => i.wasStale === true && i.status !== "write").length;
|
|
1193
|
+
// ARV-143: filled vars verify can't reach — call them out as trusted.
|
|
1194
|
+
const userConfig = items.filter(i => i.status === "verify-user-config").length;
|
|
1195
|
+
const parts = [`${live} live`, `${stale} stale`];
|
|
1196
|
+
if (staleFixed > 0) parts.push(`${staleFixed} stale-fixed`);
|
|
1197
|
+
if (stillStale > 0) parts.push(`${stillStale} still-stale`);
|
|
1198
|
+
parts.push(`${unknown} unknown`);
|
|
1199
|
+
if (userConfig > 0) parts.push(`${userConfig} trusted (no-verify-path)`);
|
|
1200
|
+
console.log(`Verify summary: ${parts.join(", ")}.`);
|
|
1201
|
+
if (stale > 0 && !options.apply) {
|
|
1202
|
+
printWarning(`${stale} stale fixture(s) detected. Re-run with --refresh to drop and re-resolve them.`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (manifest) {
|
|
1206
|
+
console.log(`Filled ${filledCount} / ${requiredManifestCount} manifest entries.`);
|
|
1207
|
+
}
|
|
1208
|
+
if (unknownEnvKeys.length > 0) {
|
|
1209
|
+
printWarning(
|
|
1210
|
+
`${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.`,
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
if (applied) {
|
|
1214
|
+
printSuccess(`Wrote ${writes.length} value(s) to ${envPath}` + (backupPath ? ` (backup: ${backupPath})` : ""));
|
|
1215
|
+
} else if (writes.length === 0) {
|
|
1216
|
+
if (!options.verify) console.log("Nothing to write (all targets already set or no discoveries succeeded).");
|
|
1217
|
+
} else {
|
|
1218
|
+
printWarning(`Dry-run: ${writes.length} value(s) ready. Re-run with --apply to write ${envPath}.`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
return 0;
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1224
|
+
if (options.json) printJson(jsonError(commandName, [message]));
|
|
1225
|
+
else printError(message);
|
|
1226
|
+
return 2;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// ARV-130 (m-19): file kept on purpose. CLI registration is owned by
|
|
1231
|
+
// ./prepare-fixtures.ts (TASK-299, m-13 D); the `discoverCommand` core
|
|
1232
|
+
// above is consumed both by that wrapper and by direct unit tests
|
|
1233
|
+
// (`tests/cli/discover*.test.ts`). It is NOT a deprecated alias for a
|
|
1234
|
+
// top-level `zond discover` command — that command does not exist and
|
|
1235
|
+
// has never been registered in `src/cli/program.ts`. See the m-19
|
|
1236
|
+
// audit note in backlog/tasks/arv-130.
|