@kirrosh/zond 0.23.0 → 0.26.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 +176 -1
- package/README.md +8 -7
- package/package.json +2 -3
- package/src/CLAUDE.md +112 -0
- package/src/cli/commands/add-api.ts +19 -7
- package/src/cli/commands/api/annotate/index.ts +359 -4
- package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
- package/src/cli/commands/api/annotate/overlay.ts +1 -1
- package/src/cli/commands/api/annotate/pagination.ts +10 -6
- package/src/cli/commands/api/annotate/prompts.ts +39 -2
- package/src/cli/commands/audit.ts +360 -54
- package/src/cli/commands/check.ts +15 -2
- package/src/cli/commands/checks.ts +352 -36
- package/src/cli/commands/cleanup.ts +4 -30
- package/src/cli/commands/coverage.ts +275 -57
- package/src/cli/commands/db.ts +311 -8
- package/src/cli/commands/discover.ts +281 -161
- package/src/cli/commands/doctor.ts +57 -3
- package/src/cli/commands/fixtures.ts +1 -1
- package/src/cli/commands/generate.ts +24 -7
- package/src/cli/commands/init/bootstrap.ts +4 -1
- package/src/cli/commands/init/index.ts +2 -2
- package/src/cli/commands/init/skills.ts +43 -0
- package/src/cli/commands/init/templates/agents.md +12 -3
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
- package/src/cli/commands/init/templates/skills/zond.md +274 -64
- package/src/cli/commands/init/templates/zond-config.yml +1 -1
- package/src/cli/commands/prepare-fixtures.ts +14 -52
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +101 -10
- package/src/cli/commands/probe/security.ts +95 -12
- package/src/cli/commands/probe/webhooks.ts +2 -0
- package/src/cli/commands/probe.ts +87 -11
- package/src/cli/commands/refresh-api.ts +59 -1
- package/src/cli/commands/report-bundle.ts +3 -11
- package/src/cli/commands/request.ts +116 -0
- package/src/cli/commands/run.ts +53 -5
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/json-envelope.ts +0 -20
- package/src/cli/json-schemas.ts +51 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +5 -4
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +0 -10
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/cross_call_references.ts +17 -4
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/idempotency_replay.ts +1 -5
- package/src/core/checks/checks/ignored_auth.ts +44 -1
- package/src/core/checks/checks/index.ts +3 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
- package/src/core/checks/checks/negative_data_rejection.ts +119 -16
- package/src/core/checks/checks/not_a_server_error.ts +8 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
- package/src/core/checks/checks/pagination_invariants.ts +298 -117
- package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
- package/src/core/checks/checks/status_code_conformance.ts +78 -7
- package/src/core/checks/mode.ts +3 -0
- package/src/core/checks/recommended-action.ts +5 -1
- package/src/core/checks/runner.ts +614 -27
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/types.ts +117 -1
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +35 -6
- package/src/core/coverage/loader.ts +31 -0
- package/src/core/diagnostics/db-analysis.ts +200 -106
- package/src/core/diagnostics/failure-class.ts +21 -1
- package/src/core/diagnostics/failure-hints.ts +4 -208
- package/src/core/diagnostics/suggested-fixes.ts +2 -3
- package/src/core/generator/chunker.ts +1 -8
- package/src/core/generator/data-factory.ts +199 -61
- package/src/core/generator/fixtures-builder.ts +38 -31
- package/src/core/generator/index.ts +0 -2
- package/src/core/generator/openapi-reader.ts +98 -4
- package/src/core/generator/path-param-disambig.ts +30 -4
- package/src/core/generator/resources-builder.ts +276 -26
- package/src/core/generator/schema-utils.ts +22 -0
- package/src/core/generator/suite-generator.ts +168 -15
- package/src/core/generator/types.ts +6 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/output/README.md +11 -29
- package/src/core/output/index.ts +1 -1
- package/src/core/output/run.ts +0 -35
- package/src/core/output/types.ts +0 -7
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/variables.ts +0 -0
- package/src/core/probe/dry-run-envelope.ts +4 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe.ts +23 -1118
- package/src/core/probe/mass-assignment-template.ts +32 -4
- package/src/core/probe/path-discovery.ts +3 -4
- package/src/core/probe/probe-harness.ts +21 -22
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +8 -2
- package/src/core/probe/security-probe.ts +28 -1449
- package/src/core/probe/shared.ts +26 -0
- package/src/core/probe/webhooks-probe.ts +5 -7
- package/src/core/runner/assertions.ts +1 -1
- package/src/core/runner/executor.ts +3 -18
- package/src/core/runner/form-encode.ts +8 -18
- package/src/core/runner/http-client.ts +38 -1
- package/src/core/runner/preflight-vars.ts +19 -15
- package/src/core/runner/rate-limiter.ts +11 -29
- package/src/core/runner/run-kind.ts +7 -1
- package/src/core/runner/schema-validator.ts +2 -6
- package/src/core/runner/send-request.ts +11 -6
- package/src/core/runner/types.ts +6 -0
- package/src/core/setup-api.ts +53 -15
- package/src/core/severity/index.ts +0 -63
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/db/migrate.ts +2 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/queries/collections.ts +2 -2
- package/src/db/queries/results.ts +88 -0
- package/src/db/queries/runs.ts +56 -2
- package/src/db/queries.ts +3 -0
- package/src/db/schema.ts +7 -7
- package/src/cli/commands/bootstrap.ts +0 -710
- package/src/core/anti-fp/bootstrap.ts +0 -34
- package/src/core/anti-fp/index.ts +0 -33
- package/src/core/anti-fp/registry.ts +0 -44
- package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
- package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
- package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
- package/src/core/anti-fp/types.ts +0 -68
- package/src/core/generator/create-body.ts +0 -89
|
@@ -69,6 +69,18 @@ export interface DumpOptions {
|
|
|
69
69
|
only?: string[];
|
|
70
70
|
json?: boolean;
|
|
71
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;
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
export async function dumpCommand(opts: DumpOptions): Promise<number> {
|
|
@@ -87,13 +99,67 @@ export async function dumpCommand(opts: DumpOptions): Promise<number> {
|
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
let resources = map.resources;
|
|
102
|
+
let unknownOnly: string[] = [];
|
|
90
103
|
if (opts.only && opts.only.length > 0) {
|
|
91
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));
|
|
92
107
|
resources = resources.filter((r) => wanted.has(r.resource));
|
|
93
108
|
}
|
|
94
109
|
const slices = buildResourceSlices(doc, resources);
|
|
95
110
|
const bundles = buildDumpBundles(opts.kind, slices, doc, map.resources);
|
|
96
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
|
+
|
|
97
163
|
if (opts.json) {
|
|
98
164
|
printJson(jsonOk("api annotate dump", { kind: opts.kind, bundles }));
|
|
99
165
|
} else {
|
|
@@ -102,6 +168,33 @@ export async function dumpCommand(opts: DumpOptions): Promise<number> {
|
|
|
102
168
|
return 0;
|
|
103
169
|
}
|
|
104
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
|
+
|
|
105
198
|
function buildDumpBundles(
|
|
106
199
|
kind: SubcommandKind,
|
|
107
200
|
slices: ResourceSlice[],
|
|
@@ -144,10 +237,20 @@ function buildDumpBundles(
|
|
|
144
237
|
switch (kind) {
|
|
145
238
|
case "seed-bodies":
|
|
146
239
|
case "idempotency":
|
|
147
|
-
case "pagination":
|
|
148
240
|
case "readback":
|
|
149
241
|
data = sliceData(slice);
|
|
150
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
|
+
}
|
|
151
254
|
case "lifecycle":
|
|
152
255
|
data = {
|
|
153
256
|
...(sliceData(slice) as Record<string, unknown>),
|
|
@@ -175,6 +278,46 @@ function sliceData(slice: ResourceSlice): unknown {
|
|
|
175
278
|
};
|
|
176
279
|
}
|
|
177
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
|
+
|
|
178
321
|
function isSliceApplicable(kind: SubcommandKind, slice: ResourceSlice): boolean {
|
|
179
322
|
switch (kind) {
|
|
180
323
|
case "seed-bodies": return seedBodies.isApplicable(slice);
|
|
@@ -226,6 +369,13 @@ export interface ApplyOptions {
|
|
|
226
369
|
force?: boolean;
|
|
227
370
|
json?: boolean;
|
|
228
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;
|
|
229
379
|
}
|
|
230
380
|
|
|
231
381
|
export async function applyCommand(opts: ApplyOptions): Promise<number> {
|
|
@@ -281,7 +431,19 @@ export async function applyCommand(opts: ApplyOptions): Promise<number> {
|
|
|
281
431
|
|
|
282
432
|
const overlay = await readLocalOverlay(col.baseDir);
|
|
283
433
|
const existing = (overlay.patches ?? []) as ResourcePatch[];
|
|
284
|
-
|
|
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 });
|
|
285
447
|
|
|
286
448
|
const summary = {
|
|
287
449
|
api: opts.api,
|
|
@@ -292,6 +454,11 @@ export async function applyCommand(opts: ApplyOptions): Promise<number> {
|
|
|
292
454
|
failures: errors,
|
|
293
455
|
changes: merge.changes.length,
|
|
294
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,
|
|
295
462
|
};
|
|
296
463
|
|
|
297
464
|
const diff = renderChangesDiff(merge);
|
|
@@ -300,6 +467,9 @@ export async function applyCommand(opts: ApplyOptions): Promise<number> {
|
|
|
300
467
|
printWarning(`Failed to parse ${errors.length} document(s):`);
|
|
301
468
|
for (const e of errors) process.stdout.write(` ✗ ${e.resource}: ${e.error}\n`);
|
|
302
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
|
+
}
|
|
303
473
|
if (diff) {
|
|
304
474
|
process.stdout.write("\nProposed changes (and conflicts):\n");
|
|
305
475
|
process.stdout.write(diff + "\n\n");
|
|
@@ -331,10 +501,44 @@ export async function applyCommand(opts: ApplyOptions): Promise<number> {
|
|
|
331
501
|
if (merge.conflicts.length > 0 && !opts.force && !opts.json) {
|
|
332
502
|
printWarning(`${merge.conflicts.length} conflict(s) kept existing values. Re-run with --force to overwrite.`);
|
|
333
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
|
+
}
|
|
334
534
|
if (opts.json) printJson(jsonOk("api annotate apply", { ...summary, written: true }));
|
|
335
535
|
return 0;
|
|
336
536
|
}
|
|
337
537
|
|
|
538
|
+
function escapeForPathRegex(path: string): string {
|
|
539
|
+
return path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
540
|
+
}
|
|
541
|
+
|
|
338
542
|
function parseByKind(kind: SubcommandKind, parsed: unknown, slice: ResourceSlice): { patch: ResourcePatch; audit: Record<string, unknown> } {
|
|
339
543
|
switch (kind) {
|
|
340
544
|
case "seed-bodies": return seedBodies.parseSeedBodyResponse(parsed, slice);
|
|
@@ -391,6 +595,144 @@ async function applyResources(apiDir: string, documents: unknown[], opts: ApplyO
|
|
|
391
595
|
return 0;
|
|
392
596
|
}
|
|
393
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
|
+
|
|
394
736
|
// ─── I/O helpers ─────────────────────────────────────────────────────
|
|
395
737
|
|
|
396
738
|
async function readInput(source: string): Promise<string> {
|
|
@@ -433,7 +775,7 @@ export function registerApiAnnotate(program: Command): void {
|
|
|
433
775
|
|
|
434
776
|
const annotate = api
|
|
435
777
|
.command("annotate")
|
|
436
|
-
.description("
|
|
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.");
|
|
437
779
|
|
|
438
780
|
annotate
|
|
439
781
|
.command("dump")
|
|
@@ -446,6 +788,8 @@ export function registerApiAnnotate(program: Command): void {
|
|
|
446
788
|
.option("--readback", "Create+read pair for readback_diff")
|
|
447
789
|
.option("--resources", "Orphan-endpoint list for resource-graph extensions")
|
|
448
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))
|
|
449
793
|
.option("--db <path>", "SQLite db path override")
|
|
450
794
|
.action(async (rawOpts, cmd: Command) => {
|
|
451
795
|
const kind = pickKind(rawOpts);
|
|
@@ -454,10 +798,19 @@ export function registerApiAnnotate(program: Command): void {
|
|
|
454
798
|
process.exitCode = 2;
|
|
455
799
|
return;
|
|
456
800
|
}
|
|
801
|
+
if (rawOpts.withLastAttempt === true && kind !== "seed-bodies") {
|
|
802
|
+
printWarning("--with-last-attempt is only meaningful with --seed-bodies; ignored.");
|
|
803
|
+
}
|
|
457
804
|
const apiName = resolveApiArg(rawOpts, cmd);
|
|
458
805
|
if (!apiName) { printError("No API selected."); process.exitCode = 2; return; }
|
|
459
806
|
process.exitCode = await dumpCommand({
|
|
460
|
-
api: apiName,
|
|
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,
|
|
461
814
|
});
|
|
462
815
|
});
|
|
463
816
|
|
|
@@ -474,6 +827,7 @@ export function registerApiAnnotate(program: Command): void {
|
|
|
474
827
|
.option("--input <file>", "Path to the YAML responses file, or `-` for stdin", "-")
|
|
475
828
|
.option("--yes", "Write the proposed patches to disk (default: dry-run + diff)")
|
|
476
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.")
|
|
477
831
|
.option("--db <path>", "SQLite db path override")
|
|
478
832
|
.action(async (rawOpts, cmd: Command) => {
|
|
479
833
|
const kind = pickKind(rawOpts);
|
|
@@ -490,6 +844,7 @@ export function registerApiAnnotate(program: Command): void {
|
|
|
490
844
|
input: rawOpts.input ?? "-",
|
|
491
845
|
yes: rawOpts.yes === true,
|
|
492
846
|
force: rawOpts.force === true,
|
|
847
|
+
gapFillOnly: rawOpts.gapFillOnly === true,
|
|
493
848
|
dbPath: rawOpts.db,
|
|
494
849
|
json: globalJson(cmd),
|
|
495
850
|
});
|
|
@@ -32,7 +32,7 @@ export const EXPECTED_OUTPUT_SHAPE = {
|
|
|
32
32
|
field: "string (response field holding state, e.g. 'status')",
|
|
33
33
|
states: "string[] (≥2 enum values)",
|
|
34
34
|
transitions: "[{from: state, to: state[]}]",
|
|
35
|
-
actions: "{ <verb>: { endpoint: 'METHOD /path', expected_state: state, body?: object } }",
|
|
35
|
+
actions: "{ <verb>: { endpoint: 'METHOD /path', expected_state: state, body?: object } } — return {} for read-only state machines (observation mode walks the list endpoint and asserts observed ⊆ states; cannot verify transitions in this mode)",
|
|
36
36
|
},
|
|
37
37
|
rationale: "string (optional)",
|
|
38
38
|
confidence: "low | medium | high",
|
|
@@ -14,6 +14,8 @@ const PaginationSchema = z.object({
|
|
|
14
14
|
limit_param: z.string().optional(),
|
|
15
15
|
default_limit: z.number().int().positive().optional(),
|
|
16
16
|
items_field: z.string().optional(),
|
|
17
|
+
page_param: z.string().optional(),
|
|
18
|
+
start_page: z.number().int().nonnegative().optional(),
|
|
17
19
|
});
|
|
18
20
|
|
|
19
21
|
const ResponseSchema = z.object({
|
|
@@ -26,13 +28,15 @@ const ResponseSchema = z.object({
|
|
|
26
28
|
export const EXPECTED_OUTPUT_SHAPE = {
|
|
27
29
|
resource: "string (echo input)",
|
|
28
30
|
pagination: {
|
|
29
|
-
type: "cursor | page | offset | token",
|
|
30
|
-
cursor_param: "string (optional — query param carrying cursor value)",
|
|
31
|
-
cursor_field: "string (optional —
|
|
32
|
-
has_more_field: "string (optional — boolean response field signalling more)",
|
|
33
|
-
limit_param: "string (optional)",
|
|
34
|
-
default_limit: "integer (optional)",
|
|
31
|
+
type: "cursor | page | offset | token (cursor and page are implemented; offset/token short-circuit)",
|
|
32
|
+
cursor_param: "string (optional — only for type=cursor; query param carrying cursor value)",
|
|
33
|
+
cursor_field: "string (optional — for cursor: next-cursor field; for page: dedupe key across pages; default `id`)",
|
|
34
|
+
has_more_field: "string (optional — only for type=cursor; boolean response field signalling more)",
|
|
35
|
+
limit_param: "string (optional — page size param; default `limit` for cursor, `per_page` for page)",
|
|
36
|
+
default_limit: "integer (optional — probe page size; default 2)",
|
|
35
37
|
items_field: "string (optional — response field carrying array)",
|
|
38
|
+
page_param: "string (optional — only for type=page; default `page`)",
|
|
39
|
+
start_page: "integer (optional — only for type=page; first page number, default 1; some APIs use 0)",
|
|
36
40
|
},
|
|
37
41
|
rationale: "string (optional)",
|
|
38
42
|
confidence: "low | medium | high",
|
|
@@ -95,8 +95,9 @@ function dumpEndpoint(doc: OpenAPIV3.Document, method: string, path: string): En
|
|
|
95
95
|
if (!op) return null;
|
|
96
96
|
|
|
97
97
|
const parameters: EndpointDump["parameters"] = [];
|
|
98
|
-
for (const
|
|
99
|
-
|
|
98
|
+
for (const raw of [...(pathItem.parameters ?? []), ...(op.parameters ?? [])]) {
|
|
99
|
+
const p = resolveParameter(doc, raw);
|
|
100
|
+
if (!p) continue;
|
|
100
101
|
parameters.push({
|
|
101
102
|
name: p.name,
|
|
102
103
|
in: p.in,
|
|
@@ -181,3 +182,39 @@ function truncate(s: string | undefined, n: number): string | undefined {
|
|
|
181
182
|
return s.length > n ? s.slice(0, n) + "…" : s;
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Resolve a parameter that may be inline or a $ref. Handles
|
|
187
|
+
* `#/components/parameters/X` (canonical) and, leniently,
|
|
188
|
+
* `#/components/headers/X` — some specs (Stripe) keep their
|
|
189
|
+
* `Idempotency-Key` declaration under `components.headers` and reuse
|
|
190
|
+
* it as a parameter $ref. Returns null on unresolvable refs.
|
|
191
|
+
*/
|
|
192
|
+
function resolveParameter(
|
|
193
|
+
doc: OpenAPIV3.Document,
|
|
194
|
+
raw: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject,
|
|
195
|
+
): OpenAPIV3.ParameterObject | null {
|
|
196
|
+
if (!("$ref" in raw)) return raw;
|
|
197
|
+
const ref = raw.$ref;
|
|
198
|
+
const paramMatch = ref.match(/^#\/components\/parameters\/(.+)$/);
|
|
199
|
+
if (paramMatch) {
|
|
200
|
+
const target = doc.components?.parameters?.[paramMatch[1]!];
|
|
201
|
+
if (target && !("$ref" in target)) return target as OpenAPIV3.ParameterObject;
|
|
202
|
+
}
|
|
203
|
+
const headerMatch = ref.match(/^#\/components\/headers\/(.+)$/);
|
|
204
|
+
if (headerMatch) {
|
|
205
|
+
const name = headerMatch[1]!;
|
|
206
|
+
const target = doc.components?.headers?.[name];
|
|
207
|
+
if (target && !("$ref" in target)) {
|
|
208
|
+
const h = target as OpenAPIV3.HeaderObject;
|
|
209
|
+
return {
|
|
210
|
+
name,
|
|
211
|
+
in: "header",
|
|
212
|
+
required: h.required,
|
|
213
|
+
description: h.description,
|
|
214
|
+
schema: h.schema,
|
|
215
|
+
} as OpenAPIV3.ParameterObject;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|