@kirrosh/zond 0.26.1 → 0.27.1
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 +65 -0
- package/README.md +39 -22
- package/bin/zond.mjs +23 -0
- package/package.json +36 -23
- package/scripts/npm/postinstall.mjs +76 -0
- package/src/CLAUDE.md +0 -112
- package/src/bun-types.d.ts +0 -5
- package/src/cli/argv.ts +0 -122
- package/src/cli/commands/add-api.ts +0 -146
- package/src/cli/commands/api/annotate/idempotency.ts +0 -59
- package/src/cli/commands/api/annotate/index.ts +0 -880
- package/src/cli/commands/api/annotate/lifecycle.ts +0 -74
- package/src/cli/commands/api/annotate/overlay.ts +0 -206
- package/src/cli/commands/api/annotate/pagination.ts +0 -64
- package/src/cli/commands/api/annotate/prompts.ts +0 -220
- package/src/cli/commands/api/annotate/readback.ts +0 -58
- package/src/cli/commands/api/annotate/resources.ts +0 -91
- package/src/cli/commands/api/annotate/seed-bodies.ts +0 -61
- package/src/cli/commands/audit.ts +0 -786
- package/src/cli/commands/catalog.ts +0 -97
- package/src/cli/commands/check.ts +0 -361
- package/src/cli/commands/checks.ts +0 -1072
- package/src/cli/commands/ci-init.ts +0 -223
- package/src/cli/commands/clean.ts +0 -212
- package/src/cli/commands/cleanup.ts +0 -236
- package/src/cli/commands/completions.ts +0 -192
- package/src/cli/commands/coverage.ts +0 -872
- package/src/cli/commands/db.ts +0 -611
- package/src/cli/commands/describe.ts +0 -120
- package/src/cli/commands/discover.ts +0 -1356
- package/src/cli/commands/doctor.ts +0 -661
- package/src/cli/commands/fixtures.ts +0 -402
- package/src/cli/commands/generate.ts +0 -577
- package/src/cli/commands/init/agents-md.ts +0 -61
- package/src/cli/commands/init/bootstrap.ts +0 -111
- package/src/cli/commands/init/index.ts +0 -244
- package/src/cli/commands/init/skills.ts +0 -141
- package/src/cli/commands/init/templates/agents.md +0 -86
- package/src/cli/commands/init/templates/markdown.d.ts +0 -4
- package/src/cli/commands/init/templates/skills/warm-up-target.md +0 -122
- package/src/cli/commands/init/templates/skills/zond-checks.md +0 -621
- package/src/cli/commands/init/templates/skills/zond-seed.md +0 -114
- package/src/cli/commands/init/templates/skills/zond-triage.md +0 -272
- package/src/cli/commands/init/templates/skills/zond.md +0 -861
- package/src/cli/commands/init/templates/zond-config.yml +0 -14
- package/src/cli/commands/prepare-fixtures.ts +0 -97
- package/src/cli/commands/probe/_seed-bodies.ts +0 -52
- package/src/cli/commands/probe/mass-assignment.ts +0 -594
- package/src/cli/commands/probe/security.ts +0 -537
- package/src/cli/commands/probe/static.ts +0 -255
- package/src/cli/commands/probe/webhooks.ts +0 -163
- package/src/cli/commands/probe.ts +0 -535
- package/src/cli/commands/reference.ts +0 -87
- package/src/cli/commands/refresh-api.ts +0 -227
- package/src/cli/commands/remove-api.ts +0 -150
- package/src/cli/commands/report-bundle.ts +0 -310
- package/src/cli/commands/report.ts +0 -241
- package/src/cli/commands/request.ts +0 -548
- package/src/cli/commands/run.ts +0 -1162
- package/src/cli/commands/schema-from-runs.ts +0 -128
- package/src/cli/commands/secrets.ts +0 -133
- package/src/cli/commands/session.ts +0 -244
- package/src/cli/commands/use.ts +0 -74
- package/src/cli/index.ts +0 -55
- package/src/cli/json-envelope.ts +0 -108
- package/src/cli/json-schemas.ts +0 -314
- package/src/cli/output.ts +0 -40
- package/src/cli/program.ts +0 -219
- package/src/cli/resolve.ts +0 -105
- package/src/cli/runtime.ts +0 -7
- package/src/cli/safe-live.ts +0 -24
- package/src/cli/status-filter.ts +0 -114
- package/src/cli/util/api-context.ts +0 -85
- package/src/cli/version.ts +0 -8
- package/src/core/audit/persist.ts +0 -183
- package/src/core/checks/budget.ts +0 -59
- package/src/core/checks/checks/_crud-helpers.ts +0 -133
- package/src/core/checks/checks/_negative_mutator.ts +0 -133
- package/src/core/checks/checks/_readback-helpers.ts +0 -133
- package/src/core/checks/checks/content_type_conformance.ts +0 -39
- package/src/core/checks/checks/cross_call_references.ts +0 -147
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +0 -219
- package/src/core/checks/checks/ensure_resource_availability.ts +0 -62
- package/src/core/checks/checks/idempotency_replay.ts +0 -242
- package/src/core/checks/checks/ignored_auth.ts +0 -254
- package/src/core/checks/checks/index.ts +0 -68
- package/src/core/checks/checks/lifecycle_transitions.ts +0 -416
- package/src/core/checks/checks/missing_required_header.ts +0 -40
- package/src/core/checks/checks/negative_data_rejection.ts +0 -148
- package/src/core/checks/checks/not_a_server_error.ts +0 -35
- package/src/core/checks/checks/open_cors_on_sensitive.ts +0 -160
- package/src/core/checks/checks/pagination_invariants.ts +0 -419
- package/src/core/checks/checks/positive_data_acceptance.ts +0 -33
- package/src/core/checks/checks/rate_limit_headers_absent.ts +0 -77
- package/src/core/checks/checks/response_headers_conformance.ts +0 -74
- package/src/core/checks/checks/response_schema_conformance.ts +0 -30
- package/src/core/checks/checks/status_code_conformance.ts +0 -132
- package/src/core/checks/checks/unsupported_method.ts +0 -63
- package/src/core/checks/checks/use_after_free.ts +0 -78
- package/src/core/checks/index.ts +0 -30
- package/src/core/checks/mode.ts +0 -82
- package/src/core/checks/recommended-action.ts +0 -68
- package/src/core/checks/registry.ts +0 -78
- package/src/core/checks/runner.ts +0 -1461
- package/src/core/checks/sarif.ts +0 -230
- package/src/core/checks/spec-findings.ts +0 -308
- package/src/core/checks/stateful.ts +0 -121
- package/src/core/checks/types.ts +0 -305
- package/src/core/checks/zond-extensions.ts +0 -73
- package/src/core/classifier/recommended-action.ts +0 -251
- package/src/core/context/current.ts +0 -51
- package/src/core/context/session.ts +0 -78
- package/src/core/coverage/loader.ts +0 -216
- package/src/core/coverage/reasons.ts +0 -300
- package/src/core/diagnostics/db-analysis.ts +0 -666
- package/src/core/diagnostics/failure-class.ts +0 -140
- package/src/core/diagnostics/failure-hints.ts +0 -112
- package/src/core/diagnostics/spec-pointer.ts +0 -99
- package/src/core/diagnostics/suggested-fixes.ts +0 -155
- package/src/core/exporter/case-study/index.ts +0 -270
- package/src/core/exporter/curl.ts +0 -40
- package/src/core/exporter/exporter.ts +0 -48
- package/src/core/exporter/html-report/escape.ts +0 -24
- package/src/core/exporter/html-report/index.ts +0 -479
- package/src/core/exporter/html-report/script.ts +0 -100
- package/src/core/exporter/html-report/styles.ts +0 -408
- package/src/core/generator/catalog-builder.ts +0 -179
- package/src/core/generator/chunker.ts +0 -78
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/coverage-scanner.ts +0 -87
- package/src/core/generator/data-factory.ts +0 -759
- package/src/core/generator/describe.ts +0 -302
- package/src/core/generator/endpoint-warnings.ts +0 -52
- package/src/core/generator/fixtures-builder.ts +0 -332
- package/src/core/generator/index.ts +0 -14
- package/src/core/generator/openapi-reader.ts +0 -297
- package/src/core/generator/path-param-disambig.ts +0 -140
- package/src/core/generator/resources-builder.ts +0 -898
- package/src/core/generator/schema-utils.ts +0 -112
- package/src/core/generator/serializer.ts +0 -343
- package/src/core/generator/suite-generator.ts +0 -1301
- package/src/core/generator/types.ts +0 -63
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/lint/affects.ts +0 -28
- package/src/core/lint/config.ts +0 -96
- package/src/core/lint/format.ts +0 -42
- package/src/core/lint/index.ts +0 -94
- package/src/core/lint/reporter.ts +0 -128
- package/src/core/lint/rules/consistency.ts +0 -158
- package/src/core/lint/rules/heuristics.ts +0 -97
- package/src/core/lint/rules/strictness.ts +0 -109
- package/src/core/lint/types.ts +0 -96
- package/src/core/lint/walker.ts +0 -248
- package/src/core/meta/meta-store.ts +0 -11
- package/src/core/output/README.md +0 -73
- package/src/core/output/index.ts +0 -13
- package/src/core/output/run.ts +0 -91
- package/src/core/output/types.ts +0 -122
- package/src/core/parser/dynamic-values.ts +0 -160
- package/src/core/parser/env-interpolation.ts +0 -104
- package/src/core/parser/filter.ts +0 -97
- package/src/core/parser/schema.ts +0 -359
- package/src/core/parser/types.ts +0 -117
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +0 -181
- package/src/core/probe/bootstrap.ts +0 -34
- package/src/core/probe/dry-run-envelope.ts +0 -61
- package/src/core/probe/mass-assignment/classify.ts +0 -175
- package/src/core/probe/mass-assignment/cleanup.ts +0 -52
- package/src/core/probe/mass-assignment/digest.ts +0 -114
- package/src/core/probe/mass-assignment/orchestrator.ts +0 -459
- package/src/core/probe/mass-assignment/regression.ts +0 -141
- package/src/core/probe/mass-assignment/suspects.ts +0 -92
- package/src/core/probe/mass-assignment/types.ts +0 -135
- package/src/core/probe/mass-assignment-probe-class.ts +0 -198
- package/src/core/probe/mass-assignment-probe.ts +0 -27
- package/src/core/probe/mass-assignment-template.ts +0 -240
- package/src/core/probe/method-probe.ts +0 -164
- package/src/core/probe/method-shared.ts +0 -69
- package/src/core/probe/negative-probe.ts +0 -691
- package/src/core/probe/orphan-tracker.ts +0 -188
- package/src/core/probe/path-discovery.ts +0 -439
- package/src/core/probe/probe-harness.ts +0 -119
- package/src/core/probe/registry.ts +0 -89
- package/src/core/probe/runner.ts +0 -136
- package/src/core/probe/security/baseline.ts +0 -174
- package/src/core/probe/security/classify.ts +0 -341
- package/src/core/probe/security/cleanup.ts +0 -125
- package/src/core/probe/security/detectors.ts +0 -71
- package/src/core/probe/security/digest.ts +0 -104
- package/src/core/probe/security/orchestrator.ts +0 -398
- package/src/core/probe/security/regression.ts +0 -103
- package/src/core/probe/security/types.ts +0 -151
- package/src/core/probe/security-probe-class.ts +0 -207
- package/src/core/probe/security-probe.ts +0 -32
- package/src/core/probe/shared.ts +0 -531
- package/src/core/probe/static-probe-class.ts +0 -125
- package/src/core/probe/types.ts +0 -165
- package/src/core/probe/verdict-aggregator.ts +0 -33
- package/src/core/probe/webhooks-probe.ts +0 -282
- package/src/core/reporter/console.ts +0 -240
- package/src/core/reporter/index.ts +0 -22
- package/src/core/reporter/json.ts +0 -22
- package/src/core/reporter/junit.ts +0 -93
- package/src/core/reporter/ndjson.ts +0 -37
- package/src/core/reporter/types.ts +0 -15
- package/src/core/runner/assertions.ts +0 -383
- package/src/core/runner/async-pool.ts +0 -108
- package/src/core/runner/auth-path.ts +0 -8
- package/src/core/runner/ci-context.ts +0 -72
- package/src/core/runner/executor.ts +0 -652
- package/src/core/runner/expr-eval.ts +0 -41
- package/src/core/runner/form-encode.ts +0 -41
- package/src/core/runner/http-client.ts +0 -224
- package/src/core/runner/learn-drift.ts +0 -293
- package/src/core/runner/preflight-vars.ts +0 -153
- package/src/core/runner/progress-tracker.ts +0 -73
- package/src/core/runner/rate-limiter.ts +0 -185
- package/src/core/runner/run-kind.ts +0 -45
- package/src/core/runner/schema-validator.ts +0 -308
- package/src/core/runner/send-request.ts +0 -232
- package/src/core/runner/transforms.ts +0 -65
- package/src/core/runner/types.ts +0 -94
- package/src/core/secrets/registry.ts +0 -164
- package/src/core/secrets/secrets-file.ts +0 -115
- package/src/core/selectors/operation-filter.ts +0 -144
- package/src/core/setup-api.ts +0 -590
- package/src/core/severity/category.ts +0 -94
- package/src/core/severity/index.ts +0 -58
- package/src/core/spec/infer-schema.ts +0 -102
- package/src/core/spec/layers.ts +0 -154
- package/src/core/spec/merge-specs.ts +0 -156
- package/src/core/spec/schema-from-runs.ts +0 -117
- package/src/core/spec/schema-overlay.ts +0 -130
- package/src/core/util/ajv.ts +0 -13
- package/src/core/util/format-eta.ts +0 -21
- package/src/core/util/headers.ts +0 -9
- package/src/core/util/url.ts +0 -24
- package/src/core/utils.ts +0 -13
- package/src/core/workspace/config.ts +0 -129
- package/src/core/workspace/fixture-gap-report.ts +0 -84
- package/src/core/workspace/fixture-gaps.ts +0 -71
- package/src/core/workspace/manifest.ts +0 -283
- package/src/core/workspace/output-rotation.ts +0 -62
- package/src/core/workspace/root.ts +0 -96
- package/src/core/workspace/triage-path.ts +0 -87
- package/src/db/lint-runs.ts +0 -47
- package/src/db/migrate.ts +0 -128
- package/src/db/migrations/0001_run_kind.sql +0 -25
- package/src/db/migrations/0002_run_kind_request.sql +0 -59
- package/src/db/migrations/sql.d.ts +0 -4
- package/src/db/queries/collections.ts +0 -133
- package/src/db/queries/coverage.ts +0 -9
- package/src/db/queries/dashboard.ts +0 -59
- package/src/db/queries/results.ts +0 -216
- package/src/db/queries/runs.ts +0 -289
- package/src/db/queries/sessions.ts +0 -42
- package/src/db/queries/settings.ts +0 -28
- package/src/db/queries/types.ts +0 -172
- package/src/db/queries.ts +0 -75
- package/src/db/schema.ts +0 -298
|
@@ -1,880 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ARV-187: `zond api annotate` — agent-augmented overlay authoring.
|
|
3
|
-
*
|
|
4
|
-
* zond does NOT call an LLM and does NOT carry LLM prompt text.
|
|
5
|
-
* It exposes two phases that bracket the agent's own inference:
|
|
6
|
-
*
|
|
7
|
-
* 1. `zond api annotate dump --<kind>` — emit raw, per-resource
|
|
8
|
-
* spec slices + the expected response shape (zod-derived contract).
|
|
9
|
-
* The agent reads them, decides how to ask its model, generates
|
|
10
|
-
* one YAML response per resource.
|
|
11
|
-
*
|
|
12
|
-
* 2. `zond api annotate apply --<kind> --input <file|->` — read the
|
|
13
|
-
* agent's YAML response, validate via zod, render a diff against
|
|
14
|
-
* the existing `.api-resources.local.yaml`, and (with --yes) write.
|
|
15
|
-
*
|
|
16
|
-
* The agent owns prompt formulation, model choice, and inference. zond
|
|
17
|
-
* owns spec-parsing, response validation, and overlay I/O. No network
|
|
18
|
-
* calls from zond, no API keys, deterministic binary behaviour.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { join } from "node:path";
|
|
22
|
-
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
23
|
-
import { existsSync } from "node:fs";
|
|
24
|
-
import type { Command } from "commander";
|
|
25
|
-
import type { OpenAPIV3 } from "openapi-types";
|
|
26
|
-
import { resolveApiCollection } from "../../../resolve.ts";
|
|
27
|
-
import { readOpenApiSpec } from "../../../../core/generator/openapi-reader.ts";
|
|
28
|
-
import { readResourceMap, type ResourceYaml } from "../../discover.ts";
|
|
29
|
-
import { buildResourceSlices, type ResourceSlice, type EndpointDump } from "./prompts.ts";
|
|
30
|
-
import { readLocalOverlay, writeLocalOverlay, mergePatches, renderChangesDiff, type ResourcePatch } from "./overlay.ts";
|
|
31
|
-
import * as seedBodies from "./seed-bodies.ts";
|
|
32
|
-
import * as lifecycle from "./lifecycle.ts";
|
|
33
|
-
import * as idempotency from "./idempotency.ts";
|
|
34
|
-
import * as pagination from "./pagination.ts";
|
|
35
|
-
import * as readback from "./readback.ts";
|
|
36
|
-
import * as resourcesModule from "./resources.ts";
|
|
37
|
-
import { printError, printSuccess, printWarning } from "../../../output.ts";
|
|
38
|
-
import { jsonOk, jsonError, printJson } from "../../../json-envelope.ts";
|
|
39
|
-
import { globalJson } from "../../../resolve.ts";
|
|
40
|
-
|
|
41
|
-
type SubcommandKind = "seed-bodies" | "lifecycle" | "idempotency" | "pagination" | "readback" | "resources";
|
|
42
|
-
|
|
43
|
-
// ─── Dump phase ──────────────────────────────────────────────────────
|
|
44
|
-
|
|
45
|
-
export interface DumpBundle {
|
|
46
|
-
kind: SubcommandKind;
|
|
47
|
-
/** "*orphans*" for kind=resources; resource name otherwise. */
|
|
48
|
-
resource: string;
|
|
49
|
-
/** Output of buildResourceSlices for a single resource — endpoints,
|
|
50
|
-
* schemas, descriptions, x-codeSamples. The raw material. */
|
|
51
|
-
data: unknown;
|
|
52
|
-
/** zod-derived shape of the YAML response zond will accept in `apply`.
|
|
53
|
-
* Not an LLM prompt — a typed contract the agent can reference. */
|
|
54
|
-
expected_response_shape: unknown;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const EXPECTED_SHAPES: Record<SubcommandKind, unknown> = {
|
|
58
|
-
"seed-bodies": seedBodies.EXPECTED_OUTPUT_SHAPE,
|
|
59
|
-
"lifecycle": lifecycle.EXPECTED_OUTPUT_SHAPE,
|
|
60
|
-
"idempotency": idempotency.EXPECTED_OUTPUT_SHAPE,
|
|
61
|
-
"pagination": pagination.EXPECTED_OUTPUT_SHAPE,
|
|
62
|
-
"readback": readback.EXPECTED_OUTPUT_SHAPE,
|
|
63
|
-
"resources": resourcesModule.EXPECTED_OUTPUT_SHAPE,
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
export interface DumpOptions {
|
|
67
|
-
api: string;
|
|
68
|
-
kind: SubcommandKind;
|
|
69
|
-
only?: string[];
|
|
70
|
-
json?: boolean;
|
|
71
|
-
dbPath?: string;
|
|
72
|
-
/** ARV-277: enrich seed-bodies bundles with the most recent
|
|
73
|
-
* fixture-kind POST attempt to the create endpoint, so the calling
|
|
74
|
-
* agent sees exactly what zond tried last + how the server replied
|
|
75
|
-
* (avoids the "agent re-discovers each 400 by hand" loop). */
|
|
76
|
-
withLastAttempt?: boolean;
|
|
77
|
-
/** ARV-278: when `withLastAttempt` is true, return the most recent N
|
|
78
|
-
* attempts (most recent first) instead of just the last one. Surfaces
|
|
79
|
-
* the *progression* of errors as the overlay was iterated — first 400
|
|
80
|
-
* was "missing X", after fixing the body the next 400 is "stale FK"
|
|
81
|
-
* pulls cascade-staleness (ARV-282) onto the agent's screen one
|
|
82
|
-
* iteration earlier. `1` keeps the old single-snapshot behaviour. */
|
|
83
|
-
historyLimit?: number;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export async function dumpCommand(opts: DumpOptions): Promise<number> {
|
|
87
|
-
const col = resolveApiCollection(opts.api, opts.dbPath);
|
|
88
|
-
if ("error" in col) { printError(col.error); return 2; }
|
|
89
|
-
if (!col.baseDir || !col.spec) {
|
|
90
|
-
printError(`API '${opts.api}' has no spec/base_dir registered.`);
|
|
91
|
-
return 2;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const doc = await readOpenApiSpec(col.spec);
|
|
95
|
-
const map = await readResourceMap(col.baseDir);
|
|
96
|
-
if (!map) {
|
|
97
|
-
printError(`API '${opts.api}' has no .api-resources.yaml. Run \`zond refresh-api ${opts.api}\` first.`);
|
|
98
|
-
return 2;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
let resources = map.resources;
|
|
102
|
-
let unknownOnly: string[] = [];
|
|
103
|
-
if (opts.only && opts.only.length > 0) {
|
|
104
|
-
const wanted = new Set(opts.only);
|
|
105
|
-
const knownNames = new Set(map.resources.map((r) => r.resource));
|
|
106
|
-
unknownOnly = opts.only.filter((n) => !knownNames.has(n));
|
|
107
|
-
resources = resources.filter((r) => wanted.has(r.resource));
|
|
108
|
-
}
|
|
109
|
-
const slices = buildResourceSlices(doc, resources);
|
|
110
|
-
const bundles = buildDumpBundles(opts.kind, slices, doc, map.resources);
|
|
111
|
-
|
|
112
|
-
// ARV-277: enrich seed-bodies bundles with the last fixture-kind POST
|
|
113
|
-
// attempt (from `runs/results` DB). The agent reads `last_attempt`
|
|
114
|
-
// alongside the spec slice instead of running `zond request POST …`
|
|
115
|
-
// by hand to reproduce the failure mode.
|
|
116
|
-
if (opts.withLastAttempt && opts.kind === "seed-bodies") {
|
|
117
|
-
try {
|
|
118
|
-
// ARV-330: use the wider all-run-kinds query (a create-path POST made
|
|
119
|
-
// during a check/probe run counts too, not just fixture runs) and pass
|
|
120
|
-
// the child-exclude pattern so probe sub-resource POSTs
|
|
121
|
-
// (`/v1/accounts/{id}/reject`) don't monopolise the history window.
|
|
122
|
-
const { getRecentCreatePosts } = await import("../../../../db/queries/results.ts");
|
|
123
|
-
const limit = Math.max(1, Math.floor(opts.historyLimit ?? 1));
|
|
124
|
-
for (const b of bundles) {
|
|
125
|
-
const data = b.data as Record<string, unknown> | null;
|
|
126
|
-
const endpoints = data?.endpoints as Record<string, EndpointDump | undefined> | undefined;
|
|
127
|
-
const create = endpoints?.create;
|
|
128
|
-
if (!create) continue;
|
|
129
|
-
const pattern = createUrlLikePattern(create.path);
|
|
130
|
-
const recent = getRecentCreatePosts(pattern, limit, childExcludePattern(create.path));
|
|
131
|
-
if (recent.length === 0) continue;
|
|
132
|
-
// ARV-278: keep `last_attempt` as the most-recent entry (back-compat
|
|
133
|
-
// with single-snapshot consumers) and add `attempt_history` when
|
|
134
|
-
// the caller asked for >1.
|
|
135
|
-
(b.data as Record<string, unknown>).last_attempt = recent[0];
|
|
136
|
-
if (limit > 1) (b.data as Record<string, unknown>).attempt_history = recent;
|
|
137
|
-
}
|
|
138
|
-
} catch (err) {
|
|
139
|
-
// DB lookup is best-effort — degraded dump (no `last_attempt`) is
|
|
140
|
-
// still useful, so we warn but don't fail the command.
|
|
141
|
-
process.stderr.write(`zond: --with-last-attempt: DB lookup failed (${(err as Error).message}); dump emitted without last_attempt.\n`);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ARV-226: when `--only` filtered to resources that have no applicable
|
|
146
|
-
// surface for this kind (no list endpoint for pagination/readback, no
|
|
147
|
-
// write surface for idempotency/seed-bodies, etc.), the dump silently
|
|
148
|
-
// returns []. Distinguish "no surface" from "unknown resource" so the
|
|
149
|
-
// user can tell whether they mistyped or hit a category-not-applicable.
|
|
150
|
-
if (!opts.json && opts.only && opts.only.length > 0 && opts.kind !== "resources") {
|
|
151
|
-
const emitted = new Set(bundles.map((b) => b.resource));
|
|
152
|
-
const filteredButNoSurface = opts.only.filter(
|
|
153
|
-
(n) => !emitted.has(n) && !unknownOnly.includes(n),
|
|
154
|
-
);
|
|
155
|
-
for (const n of unknownOnly) {
|
|
156
|
-
process.stderr.write(`zond: --only: resource '${n}' not in .api-resources.yaml (refresh-api or check spelling).\n`);
|
|
157
|
-
}
|
|
158
|
-
for (const n of filteredButNoSurface) {
|
|
159
|
-
process.stderr.write(`zond: --only: resource '${n}' has no applicable surface for --${opts.kind} (skipped).\n`);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (opts.json) {
|
|
164
|
-
printJson(jsonOk("api annotate dump", { kind: opts.kind, bundles }));
|
|
165
|
-
} else {
|
|
166
|
-
process.stdout.write(JSON.stringify(bundles, null, 2) + "\n");
|
|
167
|
-
}
|
|
168
|
-
return 0;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* ARV-277: convert a create-endpoint path (`/v1/customers/{customer}/sources`)
|
|
173
|
-
* to a SQL LIKE pattern matched against full request_url
|
|
174
|
-
* (`https://api.stripe.com/v1/customers/cus_…/sources?...`). Path-params
|
|
175
|
-
* collapse to `%`; the prefix and suffix wildcards cover the scheme/host
|
|
176
|
-
* and optional query string. Anchoring is intentionally loose because
|
|
177
|
-
* recorders write absolute URLs and we want to find the seed POST
|
|
178
|
-
* regardless of base_url shape.
|
|
179
|
-
*/
|
|
180
|
-
export function createUrlLikePattern(createPath: string): string {
|
|
181
|
-
const collapsed = createPath.replace(/\{[^}]+\}/g, "%");
|
|
182
|
-
// SQL LIKE `%` matches across slashes (no special escape needed for
|
|
183
|
-
// the path segments since we don't expect %/_ in spec paths). Leading
|
|
184
|
-
// wildcard absorbs `https://api.stripe.com`, trailing absorbs `?expand=…`.
|
|
185
|
-
return `%${collapsed}%`;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* ARV-330: companion NOT-LIKE pattern that excludes child sub-resource
|
|
190
|
-
* POSTs (`/v1/accounts/{id}/reject`, `.../persons`) from a create-path
|
|
191
|
-
* match. Pairs with `createUrlLikePattern` in `getRecentCreatePosts`.
|
|
192
|
-
*/
|
|
193
|
-
export function childExcludePattern(createPath: string): string {
|
|
194
|
-
const collapsed = createPath.replace(/\{[^}]+\}/g, "%");
|
|
195
|
-
return `%${collapsed}/%`;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function buildDumpBundles(
|
|
199
|
-
kind: SubcommandKind,
|
|
200
|
-
slices: ResourceSlice[],
|
|
201
|
-
doc: OpenAPIV3.Document,
|
|
202
|
-
allResources: ResourceYaml[],
|
|
203
|
-
): DumpBundle[] {
|
|
204
|
-
if (kind === "resources") {
|
|
205
|
-
const claimedPaths = new Set<string>();
|
|
206
|
-
for (const r of allResources) {
|
|
207
|
-
for (const role of ["list", "create", "read", "update", "delete"] as const) {
|
|
208
|
-
const ep = r.endpoints[role];
|
|
209
|
-
if (ep) claimedPaths.add(ep.split(/\s+/)[1] ?? "");
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
const orphans: string[] = [];
|
|
213
|
-
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
214
|
-
if (!item || typeof item !== "object") continue;
|
|
215
|
-
for (const method of ["get", "post", "put", "patch", "delete"]) {
|
|
216
|
-
if (!(item as Record<string, unknown>)[method]) continue;
|
|
217
|
-
if (claimedPaths.has(path)) continue;
|
|
218
|
-
orphans.push(`${method.toUpperCase()} ${path}`);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
if (orphans.length === 0) return [];
|
|
222
|
-
return [{
|
|
223
|
-
kind,
|
|
224
|
-
resource: "*orphans*",
|
|
225
|
-
data: {
|
|
226
|
-
orphan_endpoints: orphans,
|
|
227
|
-
existing_resources: allResources.map((r) => ({ resource: r.resource, basePath: r.basePath })),
|
|
228
|
-
},
|
|
229
|
-
expected_response_shape: EXPECTED_SHAPES[kind],
|
|
230
|
-
}];
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const out: DumpBundle[] = [];
|
|
234
|
-
for (const slice of slices) {
|
|
235
|
-
if (!isSliceApplicable(kind, slice)) continue;
|
|
236
|
-
let data: unknown;
|
|
237
|
-
switch (kind) {
|
|
238
|
-
case "seed-bodies":
|
|
239
|
-
case "idempotency":
|
|
240
|
-
case "readback":
|
|
241
|
-
data = sliceData(slice);
|
|
242
|
-
break;
|
|
243
|
-
case "pagination": {
|
|
244
|
-
// ARV-235: when the list endpoint already declares standard
|
|
245
|
-
// page-style params, agent doesn't need to re-read the full
|
|
246
|
-
// params schema to produce the annotation — surface a precomputed
|
|
247
|
-
// hint so the response can be `{ type: page, page_param, limit_param }`
|
|
248
|
-
// without inspecting the spec slice further.
|
|
249
|
-
const hint = detectPaginationHint(slice);
|
|
250
|
-
const base = sliceData(slice) as Record<string, unknown>;
|
|
251
|
-
data = hint ? { ...base, pagination_hint: hint } : base;
|
|
252
|
-
break;
|
|
253
|
-
}
|
|
254
|
-
case "lifecycle":
|
|
255
|
-
data = {
|
|
256
|
-
...(sliceData(slice) as Record<string, unknown>),
|
|
257
|
-
action_endpoint_candidates: collectActionEndpoints(doc, slice),
|
|
258
|
-
};
|
|
259
|
-
break;
|
|
260
|
-
default: throw new Error(`unhandled kind: ${kind}`);
|
|
261
|
-
}
|
|
262
|
-
out.push({
|
|
263
|
-
kind,
|
|
264
|
-
resource: slice.resource,
|
|
265
|
-
data,
|
|
266
|
-
expected_response_shape: EXPECTED_SHAPES[kind],
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
return out;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function sliceData(slice: ResourceSlice): unknown {
|
|
273
|
-
return {
|
|
274
|
-
resource: slice.resource,
|
|
275
|
-
basePath: slice.basePath,
|
|
276
|
-
itemPath: slice.itemPath,
|
|
277
|
-
endpoints: slice.endpoints,
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* ARV-235: detect well-known page-style pagination params on the list
|
|
283
|
-
* endpoint so the dump's response shape becomes a one-liner for the
|
|
284
|
-
* agent. Returns null if the params don't match a known shape — the
|
|
285
|
-
* agent then walks the full slice as before.
|
|
286
|
-
*/
|
|
287
|
-
function detectPaginationHint(slice: ResourceSlice): null | {
|
|
288
|
-
detected_style: "page" | "offset";
|
|
289
|
-
page_param?: string;
|
|
290
|
-
limit_param?: string;
|
|
291
|
-
offset_param?: string;
|
|
292
|
-
note: string;
|
|
293
|
-
} {
|
|
294
|
-
const list = slice.endpoints.list;
|
|
295
|
-
if (!list || !list.parameters) return null;
|
|
296
|
-
const queryNames = new Set(
|
|
297
|
-
list.parameters.filter((p) => p.in === "query").map((p) => p.name.toLowerCase()),
|
|
298
|
-
);
|
|
299
|
-
const pageParam = ["page"].find((n) => queryNames.has(n));
|
|
300
|
-
const limitParam = ["per_page", "page_size", "pagesize", "limit"].find((n) => queryNames.has(n));
|
|
301
|
-
const offsetParam = ["offset", "skip", "start"].find((n) => queryNames.has(n));
|
|
302
|
-
if (pageParam) {
|
|
303
|
-
return {
|
|
304
|
-
detected_style: "page",
|
|
305
|
-
page_param: pageParam,
|
|
306
|
-
limit_param: limitParam,
|
|
307
|
-
note: "list endpoint declares page-style params; respond with { type: 'page', page_param, limit_param } to skip rereading the full spec slice.",
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
if (offsetParam) {
|
|
311
|
-
return {
|
|
312
|
-
detected_style: "offset",
|
|
313
|
-
offset_param: offsetParam,
|
|
314
|
-
limit_param: limitParam,
|
|
315
|
-
note: "list endpoint declares offset-style params; pagination check short-circuits offset/token (per m-20). Skip annotation for this resource or return { pagination: null }.",
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
return null;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function isSliceApplicable(kind: SubcommandKind, slice: ResourceSlice): boolean {
|
|
322
|
-
switch (kind) {
|
|
323
|
-
case "seed-bodies": return seedBodies.isApplicable(slice);
|
|
324
|
-
case "lifecycle": return lifecycle.isApplicable(slice);
|
|
325
|
-
case "idempotency": return idempotency.isApplicable(slice);
|
|
326
|
-
case "pagination": return pagination.isApplicable(slice);
|
|
327
|
-
case "readback": return readback.isApplicable(slice);
|
|
328
|
-
case "resources": return true;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function collectActionEndpoints(doc: OpenAPIV3.Document, slice: ResourceSlice): EndpointDump[] {
|
|
333
|
-
const out: EndpointDump[] = [];
|
|
334
|
-
const claimed = new Set<string>();
|
|
335
|
-
for (const role of ["list", "create", "read", "update", "delete"] as const) {
|
|
336
|
-
const ep = slice.endpoints[role];
|
|
337
|
-
if (ep) claimed.add(`${ep.method} ${ep.path}`);
|
|
338
|
-
}
|
|
339
|
-
const baseLen = slice.basePath.length;
|
|
340
|
-
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
341
|
-
if (!item || typeof item !== "object") continue;
|
|
342
|
-
if (!path.startsWith(slice.basePath)) continue;
|
|
343
|
-
const tail = path.slice(baseLen);
|
|
344
|
-
if (!/\/\{[^}]+\}\/[a-zA-Z]/.test(tail)) continue;
|
|
345
|
-
for (const method of ["get", "post", "put", "patch", "delete"]) {
|
|
346
|
-
const op = (item as Record<string, unknown>)[method];
|
|
347
|
-
if (!op) continue;
|
|
348
|
-
const key = `${method.toUpperCase()} ${path}`;
|
|
349
|
-
if (claimed.has(key)) continue;
|
|
350
|
-
out.push({
|
|
351
|
-
method: method.toUpperCase(),
|
|
352
|
-
path,
|
|
353
|
-
operationId: (op as { operationId?: string }).operationId,
|
|
354
|
-
summary: (op as { summary?: string }).summary,
|
|
355
|
-
description: ((op as { description?: string }).description ?? "").slice(0, 240),
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
return out;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// ─── Apply phase ─────────────────────────────────────────────────────
|
|
363
|
-
|
|
364
|
-
export interface ApplyOptions {
|
|
365
|
-
api: string;
|
|
366
|
-
kind: SubcommandKind;
|
|
367
|
-
input: string;
|
|
368
|
-
yes?: boolean;
|
|
369
|
-
force?: boolean;
|
|
370
|
-
json?: boolean;
|
|
371
|
-
dbPath?: string;
|
|
372
|
-
/** ARV-281: agent-loop regression protection. When set, apply drops
|
|
373
|
-
* any aspect-level field on a proposed patch when the existing
|
|
374
|
-
* overlay already has that field set (regardless of value). The
|
|
375
|
-
* agent's response can only ADD missing pieces — it can't overwrite
|
|
376
|
-
* a previously curated `seed_body` even by accident. `--force`
|
|
377
|
-
* ignores this flag (explicit override stays explicit). */
|
|
378
|
-
gapFillOnly?: boolean;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
export async function applyCommand(opts: ApplyOptions): Promise<number> {
|
|
382
|
-
const col = resolveApiCollection(opts.api, opts.dbPath);
|
|
383
|
-
if ("error" in col) { printError(col.error); return 2; }
|
|
384
|
-
if (!col.baseDir || !col.spec) {
|
|
385
|
-
printError(`API '${opts.api}' has no spec/base_dir registered.`);
|
|
386
|
-
return 2;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const doc = await readOpenApiSpec(col.spec);
|
|
390
|
-
const map = await readResourceMap(col.baseDir);
|
|
391
|
-
if (!map) {
|
|
392
|
-
printError(`API '${opts.api}' has no .api-resources.yaml.`);
|
|
393
|
-
return 2;
|
|
394
|
-
}
|
|
395
|
-
const slicesByName = new Map<string, ResourceSlice>();
|
|
396
|
-
for (const s of buildResourceSlices(doc, map.resources)) slicesByName.set(s.resource, s);
|
|
397
|
-
|
|
398
|
-
const inputText = await readInput(opts.input);
|
|
399
|
-
const documents = parseYamlDocuments(inputText);
|
|
400
|
-
if (documents.length === 0) {
|
|
401
|
-
printError(`No YAML documents found in input (${opts.input}).`);
|
|
402
|
-
return 2;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
if (opts.kind === "resources") {
|
|
406
|
-
return applyResources(col.baseDir, documents, opts);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const drafts: Array<{ patch: ResourcePatch; audit: Record<string, unknown> }> = [];
|
|
410
|
-
const errors: Array<{ resource: string; error: string }> = [];
|
|
411
|
-
|
|
412
|
-
for (const document of documents) {
|
|
413
|
-
const resourceName = (document as { resource?: string } | null)?.resource;
|
|
414
|
-
if (typeof resourceName !== "string") {
|
|
415
|
-
errors.push({ resource: "<unknown>", error: "document missing 'resource:' field" });
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
const slice = slicesByName.get(resourceName);
|
|
419
|
-
if (!slice) {
|
|
420
|
-
errors.push({ resource: resourceName, error: "resource not present in .api-resources.yaml — refresh-api first?" });
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
423
|
-
try {
|
|
424
|
-
drafts.push(parseByKind(opts.kind, document, slice));
|
|
425
|
-
} catch (err) {
|
|
426
|
-
errors.push({ resource: resourceName, error: (err as Error).message });
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
const nonEmpty = drafts.filter((d) => Object.keys(d.patch).filter((k) => k !== "resource").length > 0);
|
|
431
|
-
|
|
432
|
-
const overlay = await readLocalOverlay(col.baseDir);
|
|
433
|
-
const existing = (overlay.patches ?? []) as ResourcePatch[];
|
|
434
|
-
|
|
435
|
-
// ARV-281: gap-fill-only — drop aspect-level fields where the existing
|
|
436
|
-
// overlay already has a value set. Keeps the agent's response strictly
|
|
437
|
-
// additive vs. previous (heuristic + curated) edits. `--force` opts out
|
|
438
|
-
// of this guard, mirroring the existing conflict semantics.
|
|
439
|
-
const filteredPatches = opts.gapFillOnly && opts.force !== true
|
|
440
|
-
? nonEmpty.map((d) => filterToGaps(d.patch, existing))
|
|
441
|
-
: nonEmpty.map((d) => d.patch);
|
|
442
|
-
const gapDroppedFields = opts.gapFillOnly && opts.force !== true
|
|
443
|
-
? countGapDroppedFields(nonEmpty.map((d) => d.patch), filteredPatches)
|
|
444
|
-
: 0;
|
|
445
|
-
|
|
446
|
-
const merge = mergePatches(existing, filteredPatches, { force: opts.force === true });
|
|
447
|
-
|
|
448
|
-
const summary = {
|
|
449
|
-
api: opts.api,
|
|
450
|
-
kind: opts.kind,
|
|
451
|
-
inputDocuments: documents.length,
|
|
452
|
-
accepted: nonEmpty.length,
|
|
453
|
-
dropped: drafts.length - nonEmpty.length,
|
|
454
|
-
failures: errors,
|
|
455
|
-
changes: merge.changes.length,
|
|
456
|
-
conflicts: merge.conflicts.length,
|
|
457
|
-
/** ARV-281: aspect-level fields the agent proposed that gap-fill-only
|
|
458
|
-
* silently dropped because the existing overlay already had them
|
|
459
|
-
* set. Distinct from `conflicts` which is mergePatches's concept of
|
|
460
|
-
* field-level disagreement. */
|
|
461
|
-
gap_fill_dropped: gapDroppedFields,
|
|
462
|
-
};
|
|
463
|
-
|
|
464
|
-
const diff = renderChangesDiff(merge);
|
|
465
|
-
if (!opts.json) {
|
|
466
|
-
if (errors.length > 0) {
|
|
467
|
-
printWarning(`Failed to parse ${errors.length} document(s):`);
|
|
468
|
-
for (const e of errors) process.stdout.write(` ✗ ${e.resource}: ${e.error}\n`);
|
|
469
|
-
}
|
|
470
|
-
if (gapDroppedFields > 0) {
|
|
471
|
-
printWarning(`--gap-fill-only dropped ${gapDroppedFields} aspect-field(s) the agent proposed for resources that already had them set. Re-run with --force to overwrite anyway.`);
|
|
472
|
-
}
|
|
473
|
-
if (diff) {
|
|
474
|
-
process.stdout.write("\nProposed changes (and conflicts):\n");
|
|
475
|
-
process.stdout.write(diff + "\n\n");
|
|
476
|
-
} else {
|
|
477
|
-
process.stdout.write("No changes proposed.\n");
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
if (!opts.yes) {
|
|
482
|
-
if (!opts.json) process.stdout.write(`Dry-run. Re-run with --yes to write ${col.baseDir}/.api-resources.local.yaml.\n`);
|
|
483
|
-
if (opts.json) printJson(jsonOk("api annotate apply", { ...summary, written: false }));
|
|
484
|
-
return 0;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
if (merge.changes.length === 0 && merge.conflicts.length === 0) {
|
|
488
|
-
if (opts.json) printJson(jsonOk("api annotate apply", { ...summary, written: false }));
|
|
489
|
-
return 0;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
overlay.patches = merge.patches;
|
|
493
|
-
await writeLocalOverlay(col.baseDir, overlay);
|
|
494
|
-
await appendAuditLog(col.baseDir, {
|
|
495
|
-
timestamp: new Date().toISOString(),
|
|
496
|
-
kind: opts.kind,
|
|
497
|
-
drafts: drafts.map((d) => d.audit),
|
|
498
|
-
failures: errors,
|
|
499
|
-
});
|
|
500
|
-
if (!opts.json) printSuccess(`Wrote ${merge.changes.length} change(s) to ${col.baseDir}/.api-resources.local.yaml`);
|
|
501
|
-
if (merge.conflicts.length > 0 && !opts.force && !opts.json) {
|
|
502
|
-
printWarning(`${merge.conflicts.length} conflict(s) kept existing values. Re-run with --force to overwrite.`);
|
|
503
|
-
}
|
|
504
|
-
// ARV-217: after a successful apply, surface the basePaths the agent
|
|
505
|
-
// should keep in `checks run --include` so a narrowed scope doesn't
|
|
506
|
-
// silently skip the freshly annotated resources. Without this hint, the
|
|
507
|
-
// next stateful run reports "no pagination config" for every endpoint
|
|
508
|
-
// outside the include filter and the apply looks like a no-op.
|
|
509
|
-
if (!opts.json && merge.changes.length > 0) {
|
|
510
|
-
const annotatedBasePaths = new Set<string>();
|
|
511
|
-
for (const patch of merge.patches) {
|
|
512
|
-
const slice = slicesByName.get(patch.resource);
|
|
513
|
-
if (slice?.basePath) annotatedBasePaths.add(slice.basePath);
|
|
514
|
-
}
|
|
515
|
-
if (annotatedBasePaths.size > 0) {
|
|
516
|
-
const checkName = opts.kind === "pagination"
|
|
517
|
-
? "pagination_invariants"
|
|
518
|
-
: opts.kind === "lifecycle"
|
|
519
|
-
? "lifecycle_transitions"
|
|
520
|
-
: opts.kind === "idempotency"
|
|
521
|
-
? "idempotency_replay"
|
|
522
|
-
: opts.kind === "readback"
|
|
523
|
-
? "cross_call_references"
|
|
524
|
-
: opts.kind === "seed-bodies"
|
|
525
|
-
? "stateful"
|
|
526
|
-
: "stateful";
|
|
527
|
-
const includeAlt = [...annotatedBasePaths].map(escapeForPathRegex).join("|");
|
|
528
|
-
process.stdout.write(
|
|
529
|
-
`\nTip: to exercise these annotations, scope --include to cover the annotated basePath(s):\n` +
|
|
530
|
-
` zond checks run --api ${opts.api} --check ${checkName} --include 'path:^(${includeAlt})(/.*)?$'\n`,
|
|
531
|
-
);
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
if (opts.json) printJson(jsonOk("api annotate apply", { ...summary, written: true }));
|
|
535
|
-
return 0;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function escapeForPathRegex(path: string): string {
|
|
539
|
-
return path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
function parseByKind(kind: SubcommandKind, parsed: unknown, slice: ResourceSlice): { patch: ResourcePatch; audit: Record<string, unknown> } {
|
|
543
|
-
switch (kind) {
|
|
544
|
-
case "seed-bodies": return seedBodies.parseSeedBodyResponse(parsed, slice);
|
|
545
|
-
case "lifecycle": return lifecycle.parseLifecycleResponse(parsed, slice);
|
|
546
|
-
case "idempotency": return idempotency.parseIdempotencyResponse(parsed, slice);
|
|
547
|
-
case "pagination": return pagination.parsePaginationResponse(parsed, slice);
|
|
548
|
-
case "readback": return readback.parseReadbackResponse(parsed, slice);
|
|
549
|
-
case "resources": throw new Error("resources kind handled separately");
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
async function applyResources(apiDir: string, documents: unknown[], opts: ApplyOptions): Promise<number> {
|
|
554
|
-
if (documents.length !== 1) {
|
|
555
|
-
printWarning(`Expected 1 YAML document with 'extensions:'; got ${documents.length}. Using the first.`);
|
|
556
|
-
}
|
|
557
|
-
let result;
|
|
558
|
-
try { result = resourcesModule.parseResourcesResponse(documents[0]); }
|
|
559
|
-
catch (err) { printError((err as Error).message); return 2; }
|
|
560
|
-
|
|
561
|
-
if (!opts.json) {
|
|
562
|
-
process.stdout.write(`Proposed: ${result.audit.proposed}; high-confidence accepted: ${result.extensions.length}; dropped: ${result.audit.droppedLowConfidence}\n`);
|
|
563
|
-
}
|
|
564
|
-
if (result.extensions.length === 0) {
|
|
565
|
-
if (opts.json) printJson(jsonOk("api annotate apply", { written: false, accepted: 0 }));
|
|
566
|
-
return 0;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
const overlay = await readLocalOverlay(apiDir);
|
|
570
|
-
const existing = (overlay.extensions ?? []);
|
|
571
|
-
const existingNames = new Set(existing.map((e) => e.resource));
|
|
572
|
-
const newOnes = result.extensions.filter((e) => !existingNames.has(e.resource));
|
|
573
|
-
|
|
574
|
-
if (!opts.json) {
|
|
575
|
-
process.stdout.write(`\nNew resource extension(s):\n`);
|
|
576
|
-
for (const ext of newOnes) process.stdout.write(` + ${ext.resource} (${ext.basePath})\n`);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
if (!opts.yes) {
|
|
580
|
-
if (!opts.json) process.stdout.write(`\nDry-run. Re-run with --yes to write.\n`);
|
|
581
|
-
if (opts.json) printJson(jsonOk("api annotate apply", { written: false, accepted: newOnes.length }));
|
|
582
|
-
return 0;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
overlay.extensions = [...existing, ...newOnes];
|
|
586
|
-
await writeLocalOverlay(apiDir, overlay);
|
|
587
|
-
await appendAuditLog(apiDir, {
|
|
588
|
-
timestamp: new Date().toISOString(),
|
|
589
|
-
kind: "resources",
|
|
590
|
-
accepted: newOnes.length,
|
|
591
|
-
dropped: result.audit.droppedLowConfidence,
|
|
592
|
-
});
|
|
593
|
-
if (!opts.json) printSuccess(`Wrote ${newOnes.length} extension(s) to ${apiDir}/.api-resources.local.yaml`);
|
|
594
|
-
if (opts.json) printJson(jsonOk("api annotate apply", { written: true, accepted: newOnes.length }));
|
|
595
|
-
return 0;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
/**
|
|
599
|
-
* ARV-280: error-message shapes Stripe/etc emit when an account-level
|
|
600
|
-
* capability is missing rather than an overlay-level data issue.
|
|
601
|
-
* Conservative — we only tag a resource as `account_capability_missing`
|
|
602
|
-
* when the most recent attempts ALL match, because Stripe's
|
|
603
|
-
* order-of-validation can surface a transient data error first and
|
|
604
|
-
* then a capability error after the overlay is fixed.
|
|
605
|
-
*/
|
|
606
|
-
const HARD_BLOCKED_PATTERNS = [
|
|
607
|
-
/not\s+supported\s+for\s+country/i,
|
|
608
|
-
/not\s+available\s+in\s+your\s+account/i,
|
|
609
|
-
/capability/i,
|
|
610
|
-
/not\s+enabled/i,
|
|
611
|
-
/\bnot\s+onboarded\b/i,
|
|
612
|
-
/raw[- ]card[- ]data\s+(api|access)/i,
|
|
613
|
-
/you do not have access/i,
|
|
614
|
-
/signed up for connect/i,
|
|
615
|
-
];
|
|
616
|
-
|
|
617
|
-
/**
|
|
618
|
-
* ARV-330: replies from security/auth probes that POST with deliberately
|
|
619
|
-
* broken credentials. Their 401/403s say nothing about whether the
|
|
620
|
-
* resource is capability-blocked, so the classifier drops them before
|
|
621
|
-
* judging.
|
|
622
|
-
*/
|
|
623
|
-
const AUTH_NOISE_PATTERNS = [
|
|
624
|
-
/invalid api key/i,
|
|
625
|
-
/did not provide an api key/i,
|
|
626
|
-
/no api key provided/i,
|
|
627
|
-
/provided key '/i,
|
|
628
|
-
];
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* ARV-330: verdict on a resource's create-path POST history. Pure so it
|
|
632
|
-
* can be unit-tested without a DB. Returns `account_capability_missing`
|
|
633
|
-
* when the account-level gate is the blocker, else null.
|
|
634
|
-
*
|
|
635
|
-
* Logic:
|
|
636
|
-
* 1. Drop auth-probe noise (broken-cred 401/403s) — not about the resource.
|
|
637
|
-
* 2. Any 2xx among valid attempts → the resource IS seedable, never blocked.
|
|
638
|
-
* 3. A capability/onboarding gate is account-level and deterministic: one
|
|
639
|
-
* hit on a valid-key attempt (with no success) is enough. We don't
|
|
640
|
-
* require ALL attempts to match, because a generic-generator body can
|
|
641
|
-
* trigger a data-shaped 400 on one attempt while a leaner body hits
|
|
642
|
-
* the gate on another (the ARV-330 chicken-and-egg case).
|
|
643
|
-
*/
|
|
644
|
-
/**
|
|
645
|
-
* ARV-330: guard the loose `createUrlLikePattern` LIKE from counting
|
|
646
|
-
* child sub-resource POSTs. `%/v1/accounts%` also matches
|
|
647
|
-
* `/v1/accounts/{id}/reject` and `.../persons` — POSTs to *different*
|
|
648
|
-
* endpoints (often auth-probe noise) that would drown out the real
|
|
649
|
-
* create-path attempt. Match the recorded URL's path segment-for-segment
|
|
650
|
-
* against the create-path (`{param}` matches any single segment).
|
|
651
|
-
*/
|
|
652
|
-
export function urlMatchesCreatePath(requestUrl: string, createPath: string): boolean {
|
|
653
|
-
let path: string;
|
|
654
|
-
try { path = new URL(requestUrl).pathname; } catch { return false; }
|
|
655
|
-
const want = createPath.split("/").filter(Boolean);
|
|
656
|
-
const got = path.split("/").filter(Boolean);
|
|
657
|
-
if (want.length !== got.length) return false;
|
|
658
|
-
return want.every((seg, i) => seg.startsWith("{") || seg === got[i]);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
export function classifyAttempts(
|
|
662
|
-
attempts: Array<{ response_status: number | null; response_body: string | null }>,
|
|
663
|
-
): "account_capability_missing" | null {
|
|
664
|
-
const valid = attempts.filter((a) => {
|
|
665
|
-
if (a.response_status === 401 || a.response_status === 403) return false;
|
|
666
|
-
const msg = extractMessage(a.response_body);
|
|
667
|
-
return !(msg !== null && AUTH_NOISE_PATTERNS.some((re) => re.test(msg)));
|
|
668
|
-
});
|
|
669
|
-
if (valid.length === 0) return null;
|
|
670
|
-
if (valid.some((a) => a.response_status !== null && a.response_status >= 200 && a.response_status < 300)) return null;
|
|
671
|
-
const capabilityHit = valid.some((a) => {
|
|
672
|
-
const msg = extractMessage(a.response_body);
|
|
673
|
-
return msg !== null && HARD_BLOCKED_PATTERNS.some((re) => re.test(msg));
|
|
674
|
-
});
|
|
675
|
-
return capabilityHit ? "account_capability_missing" : null;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
function extractMessage(body: string | null): string | null {
|
|
679
|
-
if (!body) return null;
|
|
680
|
-
try {
|
|
681
|
-
const parsed = JSON.parse(body);
|
|
682
|
-
const m = parsed?.error?.message;
|
|
683
|
-
return typeof m === "string" ? m : null;
|
|
684
|
-
} catch {
|
|
685
|
-
return null;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// ─── ARV-281: gap-fill filtering ─────────────────────────────────────
|
|
690
|
-
|
|
691
|
-
/**
|
|
692
|
-
* Drop aspect-level fields on a proposed patch when the existing
|
|
693
|
-
* overlay (matched by resource name) already has that field set to a
|
|
694
|
-
* non-empty value. Returns a new patch object — never mutates input.
|
|
695
|
-
*/
|
|
696
|
-
export function filterToGaps(
|
|
697
|
-
proposed: ResourcePatch,
|
|
698
|
-
existing: ResourcePatch[],
|
|
699
|
-
): ResourcePatch {
|
|
700
|
-
const prior = existing.find((p) => p.resource === proposed.resource);
|
|
701
|
-
if (!prior) return proposed;
|
|
702
|
-
const out: ResourcePatch = { resource: proposed.resource };
|
|
703
|
-
for (const [key, value] of Object.entries(proposed) as Array<[string, unknown]>) {
|
|
704
|
-
if (key === "resource") continue;
|
|
705
|
-
const existingVal = (prior as unknown as Record<string, unknown>)[key];
|
|
706
|
-
if (isPresent(existingVal)) continue; // already-set field → skip
|
|
707
|
-
(out as Record<string, unknown>)[key] = value;
|
|
708
|
-
}
|
|
709
|
-
return out;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
function isPresent(v: unknown): boolean {
|
|
713
|
-
if (v === undefined || v === null) return false;
|
|
714
|
-
if (typeof v === "string") return v.length > 0;
|
|
715
|
-
if (Array.isArray(v)) return v.length > 0;
|
|
716
|
-
if (typeof v === "object") return Object.keys(v as Record<string, unknown>).length > 0;
|
|
717
|
-
return true;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
function countGapDroppedFields(
|
|
721
|
-
before: ResourcePatch[],
|
|
722
|
-
after: ResourcePatch[],
|
|
723
|
-
): number {
|
|
724
|
-
let n = 0;
|
|
725
|
-
for (let i = 0; i < before.length; i++) {
|
|
726
|
-
const b = before[i]!;
|
|
727
|
-
const a = after[i]!;
|
|
728
|
-
for (const k of Object.keys(b)) {
|
|
729
|
-
if (k === "resource") continue;
|
|
730
|
-
if (!(k in a)) n++;
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
return n;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// ─── I/O helpers ─────────────────────────────────────────────────────
|
|
737
|
-
|
|
738
|
-
async function readInput(source: string): Promise<string> {
|
|
739
|
-
if (source === "-") return await readStdin();
|
|
740
|
-
return await readFile(source, "utf-8");
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
async function readStdin(): Promise<string> {
|
|
744
|
-
const chunks: Buffer[] = [];
|
|
745
|
-
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
746
|
-
return Buffer.concat(chunks).toString("utf-8");
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
function parseYamlDocuments(text: string): unknown[] {
|
|
750
|
-
const trimmed = text.trim();
|
|
751
|
-
if (trimmed.length === 0) return [];
|
|
752
|
-
if (trimmed.startsWith("[") || trimmed.startsWith("- ")) {
|
|
753
|
-
const parsed = Bun.YAML.parse(trimmed);
|
|
754
|
-
if (Array.isArray(parsed)) return parsed;
|
|
755
|
-
return [parsed];
|
|
756
|
-
}
|
|
757
|
-
const segments = trimmed.split(/^---\s*$/m).map((s) => s.trim()).filter(Boolean);
|
|
758
|
-
return segments.map((s) => Bun.YAML.parse(s));
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
async function appendAuditLog(apiDir: string, record: Record<string, unknown>): Promise<void> {
|
|
762
|
-
const path = join(apiDir, ".api-resources.annotate.log.ndjson");
|
|
763
|
-
if (!existsSync(apiDir)) await mkdir(apiDir, { recursive: true });
|
|
764
|
-
await appendFile(path, JSON.stringify(record) + "\n", "utf-8");
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
void jsonError;
|
|
768
|
-
|
|
769
|
-
// ─── Commander registration ──────────────────────────────────────────
|
|
770
|
-
|
|
771
|
-
export function registerApiAnnotate(program: Command): void {
|
|
772
|
-
const api = program
|
|
773
|
-
.command("api")
|
|
774
|
-
.description("Per-API tooling — annotate the .api-resources.local.yaml overlay (ARV-187)");
|
|
775
|
-
|
|
776
|
-
const annotate = api
|
|
777
|
-
.command("annotate")
|
|
778
|
-
.description("Overlay authoring for .api-resources.local.yaml. Two subcommands: `dump` (emit spec slices) + `apply` (validate & merge the agent's YAML) — agent-in-the-loop, ARV-187. zond carries no inference; the agent writes the annotations, zond validates and merges.");
|
|
779
|
-
|
|
780
|
-
annotate
|
|
781
|
-
.command("dump")
|
|
782
|
-
.description("Emit per-resource spec slices + expected response shape on stdout (JSON). The agent decides how to prompt its LLM; zond carries no prompts.")
|
|
783
|
-
.option("--api <name>", "Target API (else falls back to global --api)")
|
|
784
|
-
.option("--seed-bodies", "Slices for seed_body{content_type, body}")
|
|
785
|
-
.option("--lifecycle", "Slices + action-endpoint candidates for lifecycle")
|
|
786
|
-
.option("--idempotency", "Slices for idempotency{header, scope, ...}")
|
|
787
|
-
.option("--pagination", "Slices for pagination{type, cursor_param, ...}")
|
|
788
|
-
.option("--readback", "Create+read pair for readback_diff")
|
|
789
|
-
.option("--resources", "Orphan-endpoint list for resource-graph extensions")
|
|
790
|
-
.option("--only <list>", "Comma-separated resource names — restrict scope", csv)
|
|
791
|
-
.option("--with-last-attempt", "ARV-277: enrich seed-bodies bundles with the most recent fixture-kind POST attempt (`request_body`/`response_status`/`response_body`/`attempted_at`) so the agent sees what zond tried last without re-running prepare-fixtures.")
|
|
792
|
-
.option("--history <N>", "ARV-278: with --with-last-attempt, return the most recent N attempts (newest first) under `attempt_history`. Surfaces error progression as the overlay was iterated.", (v) => Number.parseInt(v, 10))
|
|
793
|
-
.option("--db <path>", "SQLite db path override")
|
|
794
|
-
.action(async (rawOpts, cmd: Command) => {
|
|
795
|
-
const kind = pickKind(rawOpts);
|
|
796
|
-
if (!kind) {
|
|
797
|
-
printError("Pick one of --seed-bodies | --lifecycle | --idempotency | --pagination | --readback | --resources");
|
|
798
|
-
process.exitCode = 2;
|
|
799
|
-
return;
|
|
800
|
-
}
|
|
801
|
-
if (rawOpts.withLastAttempt === true && kind !== "seed-bodies") {
|
|
802
|
-
printWarning("--with-last-attempt is only meaningful with --seed-bodies; ignored.");
|
|
803
|
-
}
|
|
804
|
-
const apiName = resolveApiArg(rawOpts, cmd);
|
|
805
|
-
if (!apiName) { printError("No API selected."); process.exitCode = 2; return; }
|
|
806
|
-
process.exitCode = await dumpCommand({
|
|
807
|
-
api: apiName,
|
|
808
|
-
kind,
|
|
809
|
-
only: rawOpts.only,
|
|
810
|
-
dbPath: rawOpts.db,
|
|
811
|
-
json: globalJson(cmd),
|
|
812
|
-
withLastAttempt: rawOpts.withLastAttempt === true,
|
|
813
|
-
historyLimit: Number.isFinite(rawOpts.history) ? rawOpts.history : undefined,
|
|
814
|
-
});
|
|
815
|
-
});
|
|
816
|
-
|
|
817
|
-
annotate
|
|
818
|
-
.command("apply")
|
|
819
|
-
.description("Validate the agent's YAML responses, render a diff, and (with --yes) write into .api-resources.local.yaml.")
|
|
820
|
-
.option("--api <name>", "Target API (else falls back to global --api)")
|
|
821
|
-
.option("--seed-bodies", "Apply seed_body block")
|
|
822
|
-
.option("--lifecycle", "Apply lifecycle block")
|
|
823
|
-
.option("--idempotency", "Apply idempotency block")
|
|
824
|
-
.option("--pagination", "Apply pagination block")
|
|
825
|
-
.option("--readback", "Apply readback_diff block")
|
|
826
|
-
.option("--resources", "Apply orphan-resource extension list")
|
|
827
|
-
.option("--input <file>", "Path to the YAML responses file, or `-` for stdin", "-")
|
|
828
|
-
.option("--yes", "Write the proposed patches to disk (default: dry-run + diff)")
|
|
829
|
-
.option("--force", "Overwrite the existing value on field-level conflict")
|
|
830
|
-
.option("--gap-fill-only", "ARV-281: drop aspect-level fields the agent proposed for resources that already have that field set in the overlay. Keeps the agent's response strictly additive — protects curated `seed_body` blocks from accidental regression. Bypassed by --force.")
|
|
831
|
-
.option("--db <path>", "SQLite db path override")
|
|
832
|
-
.action(async (rawOpts, cmd: Command) => {
|
|
833
|
-
const kind = pickKind(rawOpts);
|
|
834
|
-
if (!kind) {
|
|
835
|
-
printError("Pick one of --seed-bodies | --lifecycle | --idempotency | --pagination | --readback | --resources");
|
|
836
|
-
process.exitCode = 2;
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
const apiName = resolveApiArg(rawOpts, cmd);
|
|
840
|
-
if (!apiName) { printError("No API selected."); process.exitCode = 2; return; }
|
|
841
|
-
process.exitCode = await applyCommand({
|
|
842
|
-
api: apiName,
|
|
843
|
-
kind,
|
|
844
|
-
input: rawOpts.input ?? "-",
|
|
845
|
-
yes: rawOpts.yes === true,
|
|
846
|
-
force: rawOpts.force === true,
|
|
847
|
-
gapFillOnly: rawOpts.gapFillOnly === true,
|
|
848
|
-
dbPath: rawOpts.db,
|
|
849
|
-
json: globalJson(cmd),
|
|
850
|
-
});
|
|
851
|
-
});
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
function pickKind(opts: Record<string, unknown>): SubcommandKind | null {
|
|
855
|
-
const flags: Array<[string, SubcommandKind]> = [
|
|
856
|
-
["seedBodies", "seed-bodies"],
|
|
857
|
-
["lifecycle", "lifecycle"],
|
|
858
|
-
["idempotency", "idempotency"],
|
|
859
|
-
["pagination", "pagination"],
|
|
860
|
-
["readback", "readback"],
|
|
861
|
-
["resources", "resources"],
|
|
862
|
-
];
|
|
863
|
-
const set = flags.filter(([f]) => opts[f] === true);
|
|
864
|
-
if (set.length !== 1) return null;
|
|
865
|
-
return set[0]![1];
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
function csv(v: string): string[] {
|
|
869
|
-
return v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function resolveApiArg(rawOpts: Record<string, unknown>, cmd: Command): string | null {
|
|
873
|
-
const fromFlag = rawOpts.api;
|
|
874
|
-
if (typeof fromFlag === "string" && fromFlag.length > 0) return fromFlag;
|
|
875
|
-
const fromParent = cmd.parent?.parent?.parent?.opts().api;
|
|
876
|
-
if (typeof fromParent === "string" && fromParent.length > 0) return fromParent;
|
|
877
|
-
const fromEnv = process.env.ZOND_API_GLOBAL;
|
|
878
|
-
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
879
|
-
return null;
|
|
880
|
-
}
|