@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
|
@@ -2,7 +2,7 @@ import type { OpenAPIV3 } from "openapi-types";
|
|
|
2
2
|
import type { EndpointInfo, SecuritySchemeInfo, CrudGroup } from "./types.ts";
|
|
3
3
|
import type { RawSuite, RawStep } from "./serializer.ts";
|
|
4
4
|
import type { SourceMetadata } from "../parser/types.ts";
|
|
5
|
-
import { generateFromSchema, generateMultipartFromSchema } from "./data-factory.ts";
|
|
5
|
+
import { generateFromSchema, generateMultipartFromSchema, isFkFixtureField, canonicalVarName, effectiveObjectShape } from "./data-factory.ts";
|
|
6
6
|
import { groupEndpointsByTag } from "./chunker.ts";
|
|
7
7
|
import { getAuthHeaders as sharedGetAuthHeaders } from "../probe/shared.ts";
|
|
8
8
|
import { flattenToFormFields } from "../runner/form-encode.ts";
|
|
@@ -48,9 +48,16 @@ export function resourceVar(resource: string, suffix: string): string {
|
|
|
48
48
|
return `${singular.replace(/[^a-zA-Z0-9]+/g, "_")}_${suffix}`.toLowerCase();
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** Convert OpenAPI path params {param} to test interpolation {{param}}
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
/** Convert OpenAPI path params {param} to test interpolation {{param}}.
|
|
52
|
+
* When `ambiguous` is given (ARV-369), a param name reused across
|
|
53
|
+
* distinct resources becomes a resource-scoped var (`{{templates_code}}`)
|
|
54
|
+
* instead of the raw name, so `.env.yaml` can hold distinct values per
|
|
55
|
+
* resource instead of one value silently applying everywhere. */
|
|
56
|
+
function convertPath(path: string, ambiguous?: Set<string>): string {
|
|
57
|
+
if (!ambiguous || ambiguous.size === 0) {
|
|
58
|
+
return path.replace(/\{([^}]+)\}/g, "{{$1}}");
|
|
59
|
+
}
|
|
60
|
+
return path.replace(/\{([^}]+)\}/g, (_, name) => `{{${fixtureVarNameForPathParam(path, name, ambiguous)}}}`);
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
/**
|
|
@@ -96,6 +103,75 @@ function slugify(s: string): string {
|
|
|
96
103
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
97
104
|
}
|
|
98
105
|
|
|
106
|
+
/** Strip trailing API-version path segments (`v1`, `v30`, `version2`) so
|
|
107
|
+
* resource-name derivation doesn't mistake a version marker for the
|
|
108
|
+
* resource itself on APIs shaped `/api/<resource>/v{N}` (ARV-372). */
|
|
109
|
+
export function stripTrailingVersionSegments(segments: string[]): string[] {
|
|
110
|
+
const out = [...segments];
|
|
111
|
+
while (out.length > 1 && /^v(ersion)?\d+$/i.test(out[out.length - 1]!)) {
|
|
112
|
+
out.pop();
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Owning collection for a path-param: the last non-version path segment
|
|
118
|
+
* before its `{name}` placeholder. Used to disambiguate a path-param name
|
|
119
|
+
* (`code`, `id`, `key`) that's reused across genuinely distinct resources
|
|
120
|
+
* (ARV-369) — e.g. `/api/templates/v30/{code}` → "templates" vs
|
|
121
|
+
* `/api/macros/v30/{code}` → "macros". */
|
|
122
|
+
// ARV-376: read-by-id accessor markers — kept in sync with the set in
|
|
123
|
+
// path-param-disambig.ts. `/business-segment20/byid/{id}` is owned by
|
|
124
|
+
// `business-segment20`, not the `byid` accessor verb.
|
|
125
|
+
const ACCESSOR_MARKER_SEGS = new Set(["byid", "by-id", "by_id"]);
|
|
126
|
+
|
|
127
|
+
export function owningCollectionForPathParam(path: string, paramName: string): string | null {
|
|
128
|
+
const marker = `{${paramName}}`;
|
|
129
|
+
const idx = path.indexOf(marker);
|
|
130
|
+
if (idx === -1) return null;
|
|
131
|
+
const segments = stripTrailingVersionSegments(path.slice(0, idx).split("/").filter(Boolean));
|
|
132
|
+
// Skip trailing accessor markers so the owner is the collection, not the
|
|
133
|
+
// `byid`/`by-code` verb — otherwise a param behind `/byid/{id}` and its
|
|
134
|
+
// sibling `DELETE /{id}` disagree on owner and get double-scoped.
|
|
135
|
+
let end = segments.length - 1;
|
|
136
|
+
while (end >= 0 && ACCESSOR_MARKER_SEGS.has(segments[end]!.toLowerCase())) end--;
|
|
137
|
+
return end >= 0 ? segments[end]! : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Path-param names reused by more than one distinct owning collection
|
|
141
|
+
* across the whole spec — these need a resource-scoped fixture var name
|
|
142
|
+
* (`templates_code`) instead of the raw name (`code`), otherwise one
|
|
143
|
+
* `.env.yaml` value silently applies to unrelated resources (ARV-369). */
|
|
144
|
+
export function computeAmbiguousPathParams(endpoints: EndpointInfo[]): Set<string> {
|
|
145
|
+
const owners = new Map<string, Set<string>>();
|
|
146
|
+
for (const ep of endpoints) {
|
|
147
|
+
for (const p of ep.parameters) {
|
|
148
|
+
if (p.in !== "path" || p.required === false) continue;
|
|
149
|
+
const owner = owningCollectionForPathParam(ep.path, p.name) ?? "";
|
|
150
|
+
if (!owners.has(p.name)) owners.set(p.name, new Set());
|
|
151
|
+
owners.get(p.name)!.add(owner);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const ambiguous = new Set<string>();
|
|
155
|
+
for (const [name, ownerSet] of owners) {
|
|
156
|
+
if (ownerSet.size > 1) ambiguous.add(name);
|
|
157
|
+
}
|
|
158
|
+
return ambiguous;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Resource-scoped fixture var name for a path-param, when ambiguous;
|
|
162
|
+
* otherwise the raw param name (single-owner APIs — the common case —
|
|
163
|
+
* keep producing the same var names as before ARV-369). */
|
|
164
|
+
export function fixtureVarNameForPathParam(
|
|
165
|
+
path: string,
|
|
166
|
+
paramName: string,
|
|
167
|
+
ambiguous: Set<string>,
|
|
168
|
+
): string {
|
|
169
|
+
if (!ambiguous.has(paramName)) return paramName;
|
|
170
|
+
const owner = owningCollectionForPathParam(path, paramName);
|
|
171
|
+
if (!owner) return paramName;
|
|
172
|
+
return `${owner.replace(/[^a-zA-Z0-9]+/g, "_")}_${paramName}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
99
175
|
function escapeRegex(s: string): string {
|
|
100
176
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
101
177
|
}
|
|
@@ -242,6 +318,13 @@ function getSuiteHeaders(
|
|
|
242
318
|
// stays stateless from the caller's perspective.
|
|
243
319
|
let _suiteDefaultAuthVar: string | null = null;
|
|
244
320
|
|
|
321
|
+
/** Same module-scoping rationale as `_suiteDefaultAuthVar` above: computed
|
|
322
|
+
* once from the full endpoint list at the top of generateSuites (ARV-369
|
|
323
|
+
* needs the whole spec to detect a param-name collision), consulted by
|
|
324
|
+
* `generateStep`'s path conversion. Reset to empty at the end of
|
|
325
|
+
* generateSuites. */
|
|
326
|
+
let _ambiguousPathParams: Set<string> = new Set();
|
|
327
|
+
|
|
245
328
|
/** Common id-like field names looked up after `id` itself.
|
|
246
329
|
* TASK-139: many real-world APIs return `slug`, `uuid`, `version`, `key`,
|
|
247
330
|
* or `name` instead of an `id` field on create responses. Without these,
|
|
@@ -348,6 +431,42 @@ export function buildOpenApiSuiteSource(specPath?: string): SourceMetadata | und
|
|
|
348
431
|
// Public API
|
|
349
432
|
// ──────────────────────────────────────────────
|
|
350
433
|
|
|
434
|
+
/**
|
|
435
|
+
* ARV-45: swap FK-shaped required body fields for `{{fixture}}` references so
|
|
436
|
+
* their values come from `.env.yaml` instead of `{{$randomString}}`/`{{$uuid}}`
|
|
437
|
+
* junk that 400s and kills the CRUD chain at step 1. Field names that motivated
|
|
438
|
+
* this — `sequenceTypeCode`, `templateGroupCode` — are closed-vocab codes the
|
|
439
|
+
* generator can't guess. The var name is canonicalised (`sequence_type_code`)
|
|
440
|
+
* to match the manifest entry (fixtures-builder step 5); the HTTP body key
|
|
441
|
+
* keeps the raw spec spelling. Recurses into nested objects for nested FKs.
|
|
442
|
+
*
|
|
443
|
+
* Keep the detection predicate (`isFkFixtureField`) in lockstep with the
|
|
444
|
+
* manifest builder — every var the tests reference must appear in the manifest
|
|
445
|
+
* (decision-7).
|
|
446
|
+
*/
|
|
447
|
+
export function wireBodyFkRefs(schema: OpenAPIV3.SchemaObject, body: unknown): unknown {
|
|
448
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
|
449
|
+
// `effectiveObjectShape` merges allOf so allOf-wrapped bodies (the .NET/
|
|
450
|
+
// Swagger norm) resolve their properties — a direct schema.properties read
|
|
451
|
+
// misses every FK on those specs.
|
|
452
|
+
const { properties, required } = effectiveObjectShape(schema);
|
|
453
|
+
const out = body as Record<string, unknown>;
|
|
454
|
+
for (const [name, propSchema] of Object.entries(properties)) {
|
|
455
|
+
if (!(name in out)) continue;
|
|
456
|
+
if (required.has(name) && isFkFixtureField(name, propSchema)) {
|
|
457
|
+
out[name] = `{{${canonicalVarName(name)}}}`;
|
|
458
|
+
} else if (out[name] && typeof out[name] === "object" && !Array.isArray(out[name])) {
|
|
459
|
+
out[name] = wireBodyFkRefs(propSchema, out[name]);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Generate a request body from a schema with FK fields wired to fixtures. */
|
|
466
|
+
function generateBody(schema: OpenAPIV3.SchemaObject): unknown {
|
|
467
|
+
return wireBodyFkRefs(schema, generateFromSchema(schema));
|
|
468
|
+
}
|
|
469
|
+
|
|
351
470
|
/** Generate a single test step from an EndpointInfo */
|
|
352
471
|
export function generateStep(
|
|
353
472
|
ep: EndpointInfo,
|
|
@@ -355,7 +474,7 @@ export function generateStep(
|
|
|
355
474
|
): RawStep {
|
|
356
475
|
const method = ep.method.toUpperCase();
|
|
357
476
|
const name = ep.operationId ?? ep.summary ?? `${method} ${ep.path}`;
|
|
358
|
-
const path = convertPath(ep.path);
|
|
477
|
+
const path = convertPath(ep.path, _ambiguousPathParams);
|
|
359
478
|
|
|
360
479
|
const step: RawStep = {
|
|
361
480
|
name,
|
|
@@ -379,9 +498,9 @@ export function generateStep(
|
|
|
379
498
|
// the runner posts URL-encoded bodies with bracket notation. Without
|
|
380
499
|
// this, generate baked `json:` blocks and every POST 400'd with
|
|
381
500
|
// "wrong content type".
|
|
382
|
-
step.form = flattenToFormFields(
|
|
501
|
+
step.form = flattenToFormFields(generateBody(ep.requestBodySchema));
|
|
383
502
|
} else {
|
|
384
|
-
step.json =
|
|
503
|
+
step.json = generateBody(ep.requestBodySchema);
|
|
385
504
|
}
|
|
386
505
|
}
|
|
387
506
|
|
|
@@ -450,7 +569,13 @@ export function detectCrudGroupsWithDiagnostics(
|
|
|
450
569
|
|
|
451
570
|
for (const createEp of postEndpoints) {
|
|
452
571
|
const basePath = stripTrailingSlash(createEp.path);
|
|
453
|
-
|
|
572
|
+
// ARV-372: skip trailing version segments (`v1`, `v30`) before taking the
|
|
573
|
+
// last segment as the resource name — otherwise every collection on a
|
|
574
|
+
// spec shaped `/api/<resource>/v{N}` (a common convention) resolves to
|
|
575
|
+
// the SAME resource name ("v30"), and multiple CRUD suites collide on
|
|
576
|
+
// the same `crud-<resource>.yaml` output filename, silently overwriting
|
|
577
|
+
// each other.
|
|
578
|
+
const resource = stripTrailingVersionSegments(basePath.split("/").filter(Boolean)).pop() ?? "resource";
|
|
454
579
|
|
|
455
580
|
// Match `<basePath>/{param}` with optional trailing slash. Tolerates
|
|
456
581
|
// both `POST /alerts/` + `GET /alerts/{id}` and `POST /alerts` +
|
|
@@ -500,11 +625,15 @@ export function detectCrudGroupsWithDiagnostics(
|
|
|
500
625
|
ep => ["PUT", "PATCH"].includes(ep.method.toUpperCase()),
|
|
501
626
|
);
|
|
502
627
|
const del = resolvedItemEndpoints.find(ep => ep.method.toUpperCase() === "DELETE");
|
|
503
|
-
// List endpoint matches with the same trailing-slash tolerance.
|
|
628
|
+
// List endpoint matches with the same trailing-slash tolerance. ARV-376:
|
|
629
|
+
// also accept a `/list`, `/search`, `/find` verb suffix on the base path
|
|
630
|
+
// (`GET /api/deal-kind20/list`) — common in RPC-flavoured REST specs
|
|
631
|
+
// where the bare collection path has no GET.
|
|
632
|
+
const listStems = [basePath, `${basePath}/list`, `${basePath}/search`, `${basePath}/find`];
|
|
504
633
|
const list = endpoints.find(
|
|
505
634
|
ep =>
|
|
506
635
|
ep.method.toUpperCase() === "GET" &&
|
|
507
|
-
stripTrailingSlash(ep.path)
|
|
636
|
+
listStems.includes(stripTrailingSlash(ep.path)) &&
|
|
508
637
|
!ep.deprecated,
|
|
509
638
|
);
|
|
510
639
|
|
|
@@ -573,11 +702,31 @@ export function detectCrudGroupsWithDiagnostics(
|
|
|
573
702
|
}
|
|
574
703
|
|
|
575
704
|
/** Generate a CRUD chain suite from a CrudGroup */
|
|
705
|
+
/** ARV-368: does the create's success response actually carry the capture
|
|
706
|
+
* field? If not — a 204 no-body create, or a response schema without the id —
|
|
707
|
+
* the runtime capture is empty and `{{captureVar}}` falls back to the
|
|
708
|
+
* read-fixture of the SAME name (ARV-137 deliberately shares it). A PUT/DELETE
|
|
709
|
+
* then targets a *pre-existing* resource whose id the user harvested for read
|
|
710
|
+
* coverage — silent data-loss. Gate mutating chain steps on this so the suite
|
|
711
|
+
* can only ever update/delete what it actually captured, never live data. */
|
|
712
|
+
function createCapturesId(
|
|
713
|
+
create: EndpointInfo | undefined,
|
|
714
|
+
captureField: string,
|
|
715
|
+
): boolean {
|
|
716
|
+
if (!create) return false;
|
|
717
|
+
const props = getSuccessSchema(create)?.properties;
|
|
718
|
+
return !!props && captureField in props;
|
|
719
|
+
}
|
|
720
|
+
|
|
576
721
|
export function generateCrudSuite(
|
|
577
722
|
group: CrudGroup,
|
|
578
723
|
securitySchemes: SecuritySchemeInfo[],
|
|
579
724
|
): RawSuite {
|
|
580
725
|
const captureField = group.create ? getCaptureField(group.create, group.idParam) : "id";
|
|
726
|
+
// ARV-368: only chain PUT/DELETE when the create response yields the id we'd
|
|
727
|
+
// capture. Otherwise the capture is empty at runtime and the mutating step
|
|
728
|
+
// falls back to the shared read-fixture → deletes/overwrites pre-existing data.
|
|
729
|
+
const canChainMutations = createCapturesId(group.create, captureField);
|
|
581
730
|
// ARV-137: use the spec's path-param name as the capture var. Previously
|
|
582
731
|
// we synthesised `<resource>_id` via `resourceVar(...)`, which produced
|
|
583
732
|
// phantom manifest dupes whenever the spec named the path-param anything
|
|
@@ -622,8 +771,9 @@ export function generateCrudSuite(
|
|
|
622
771
|
tests.push(step);
|
|
623
772
|
}
|
|
624
773
|
|
|
625
|
-
// 3. Update
|
|
626
|
-
|
|
774
|
+
// 3. Update (ARV-368: only if the chain self-captures its id — else the
|
|
775
|
+
// fixture-fallback would overwrite a pre-existing resource)
|
|
776
|
+
if (group.update && canChainMutations) {
|
|
627
777
|
const method = group.update.method.toUpperCase();
|
|
628
778
|
const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
|
|
629
779
|
const etagVar = resourceVar(group.resource, "etag");
|
|
@@ -653,13 +803,14 @@ export function generateCrudSuite(
|
|
|
653
803
|
step.headers = { "If-Match": `"{{${etagVar}}}"` };
|
|
654
804
|
}
|
|
655
805
|
if (group.update.requestBodySchema) {
|
|
656
|
-
step.json =
|
|
806
|
+
step.json = generateBody(group.update.requestBodySchema);
|
|
657
807
|
}
|
|
658
808
|
tests.push(step);
|
|
659
809
|
}
|
|
660
810
|
|
|
661
|
-
// 4. Delete
|
|
662
|
-
|
|
811
|
+
// 4. Delete (ARV-368: only if the chain self-captures its id — else the
|
|
812
|
+
// fixture-fallback would DELETE a pre-existing resource → data-loss)
|
|
813
|
+
if (group.delete && canChainMutations) {
|
|
663
814
|
const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
|
|
664
815
|
const etagVar = resourceVar(group.resource, "etag");
|
|
665
816
|
|
|
@@ -938,6 +1089,7 @@ export function generateSuites(opts: {
|
|
|
938
1089
|
}): RawSuite[] {
|
|
939
1090
|
const { endpoints, securitySchemes, specPath, includeDeprecated, defaultAuthVar } = opts;
|
|
940
1091
|
_suiteDefaultAuthVar = defaultAuthVar ?? null;
|
|
1092
|
+
_ambiguousPathParams = computeAmbiguousPathParams(endpoints);
|
|
941
1093
|
|
|
942
1094
|
// Filter deprecated unless caller opted in. The list of skipped paths is
|
|
943
1095
|
// exposed separately via `getSkippedDeprecated` for stdout reporting.
|
|
@@ -1144,5 +1296,6 @@ export function generateSuites(opts: {
|
|
|
1144
1296
|
}
|
|
1145
1297
|
|
|
1146
1298
|
_suiteDefaultAuthVar = null; // ARV-212
|
|
1299
|
+
_ambiguousPathParams = new Set(); // ARV-369
|
|
1147
1300
|
return allSuites;
|
|
1148
1301
|
}
|
|
@@ -28,6 +28,12 @@ export interface EndpointInfo {
|
|
|
28
28
|
security: string[];
|
|
29
29
|
deprecated?: boolean;
|
|
30
30
|
requiresEtag?: boolean;
|
|
31
|
+
/** ARV-189 (m-21): vendor extensions starting with `x-` from the
|
|
32
|
+
* operation (and merged from the path item — operation wins on key
|
|
33
|
+
* collision). Used by the `x-zond-*` opt-in/skip rules so callers can
|
|
34
|
+
* declare check-level policy directly in the spec without an overlay
|
|
35
|
+
* yaml file. Empty/undefined when the spec carries no extensions. */
|
|
36
|
+
extensions?: Record<string, unknown>;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export interface SecuritySchemeInfo {
|
|
Binary file
|
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
# core/output — typed `--report` / `--output` / `--json` policy
|
|
2
2
|
|
|
3
3
|
`OutputSpec<Payload>` is the single source-of-truth for how a command
|
|
4
|
-
produces output.
|
|
5
|
-
`run`, …) are migrated in ARV-117/118/119 — this directory only ships
|
|
6
|
-
the infrastructure.
|
|
7
|
-
|
|
8
|
-
Closes the seven divergent-output bugs collected in
|
|
4
|
+
produces output. Closes the seven divergent-output bugs collected in
|
|
9
5
|
`strategy/lessons.md` §E (ARV-50, ARV-82, ARV-97, …) by replacing N
|
|
10
6
|
ad-hoc parsers with one resolver.
|
|
11
7
|
|
|
12
8
|
## Policy matrix
|
|
13
9
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
`resolveOutput(spec, opts)` reads `--report`, `--output`, and `--json`
|
|
11
|
+
from the CLI layer and resolves them to a `ResolvedOutput` decision
|
|
12
|
+
according to this matrix:
|
|
17
13
|
|
|
18
14
|
| Input | Format | Channel | Notes |
|
|
19
15
|
| ------------------------------------ | -------------- | ------------------- | ----- |
|
|
@@ -50,30 +46,16 @@ export const CHECKS_RUN_OUTPUT: OutputSpec<ChecksPayload> = {
|
|
|
50
46
|
// `--report ndjson` is a friendly alias retained from skill prompts.
|
|
51
47
|
ndjson: "ndjson",
|
|
52
48
|
},
|
|
53
|
-
render: (format, payload) => {
|
|
54
|
-
if (format === "sarif") return generateSarifReport(payload);
|
|
55
|
-
if (format === "ndjson") return payload.findings.map(f => JSON.stringify(f)).join("\n");
|
|
56
|
-
return JSON.stringify(payload, null, 2);
|
|
57
|
-
},
|
|
58
49
|
};
|
|
59
50
|
```
|
|
60
51
|
|
|
61
|
-
The CLI handler
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
);
|
|
69
|
-
process.exit(exitCode);
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
`resolveOutput()` can be called standalone (without rendering) when a
|
|
73
|
-
command wants to plug the resolution into its own streaming pipeline —
|
|
74
|
-
e.g. `checks run` opens an fd ahead of time and feeds events into it
|
|
75
|
-
incrementally; in that case the command consumes `resolved.path` and
|
|
76
|
-
`resolved.channel` and handles I/O itself.
|
|
52
|
+
The CLI handler calls `resolveOutput()` and renders/writes the payload
|
|
53
|
+
itself — each command's shape (streaming vs single-shot, envelope vs
|
|
54
|
+
raw) differs enough that a shared render/write runner added indirection
|
|
55
|
+
without removing per-command logic. `checks run` and `probe *` open an
|
|
56
|
+
fd from `resolved.path`/`resolved.channel` and feed output into it
|
|
57
|
+
incrementally; `run` renders a single payload and writes it once. See
|
|
58
|
+
any of `src/cli/commands/{run,checks,probe}.ts` for the pattern.
|
|
77
59
|
|
|
78
60
|
## Why the format set is open
|
|
79
61
|
|
package/src/core/output/index.ts
CHANGED
package/src/core/output/run.ts
CHANGED
|
@@ -89,38 +89,3 @@ function pickEnvelopeFormat<P>(spec: OutputSpec<P>): string | undefined {
|
|
|
89
89
|
return undefined;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export interface RunOutputResult<P> {
|
|
93
|
-
resolved: ResolvedOutput;
|
|
94
|
-
payload: P;
|
|
95
|
-
exitCode: number;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Render and write the payload according to the resolved decision.
|
|
99
|
-
* Returns the resolved decision plus the exit code from the spec's
|
|
100
|
-
* `exitCodePolicy` (0 by default). The CLI handler typically passes
|
|
101
|
-
* the exit code straight to `process.exit`. */
|
|
102
|
-
export async function runCommandWithOutput<P>(
|
|
103
|
-
spec: OutputSpec<P>,
|
|
104
|
-
opts: OutputOptions,
|
|
105
|
-
produce: () => Promise<P>,
|
|
106
|
-
): Promise<RunOutputResult<P>> {
|
|
107
|
-
const resolved = resolveOutput(spec, opts);
|
|
108
|
-
const payload = await produce();
|
|
109
|
-
|
|
110
|
-
if (!spec.render) {
|
|
111
|
-
throw new OutputSpecError(
|
|
112
|
-
`OutputSpec for "${spec.command}" has no render() hook — cannot serialise payload`,
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
const body = spec.render(resolved.format, payload);
|
|
116
|
-
|
|
117
|
-
if (resolved.channel === "file") {
|
|
118
|
-
await Bun.write(resolved.path!, body);
|
|
119
|
-
} else {
|
|
120
|
-
process.stdout.write(body);
|
|
121
|
-
if (!body.endsWith("\n")) process.stdout.write("\n");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const exitCode = spec.exitCodePolicy ? spec.exitCodePolicy(payload) : 0;
|
|
125
|
-
return { resolved, payload, exitCode };
|
|
126
|
-
}
|
package/src/core/output/types.ts
CHANGED
|
@@ -83,13 +83,6 @@ export interface OutputSpec<Payload = unknown> {
|
|
|
83
83
|
* skill prompts ship it). Keys are flag values seen on the CLI;
|
|
84
84
|
* values are the resolved format name. */
|
|
85
85
|
aliases?: Record<string, OutputFormat>;
|
|
86
|
-
/** Optional pre-validated render hook. Called by the runner once
|
|
87
|
-
* the format is resolved. Receives the payload plus the resolved
|
|
88
|
-
* format and returns the serialized output. */
|
|
89
|
-
render?: (format: OutputFormat, payload: Payload) => string;
|
|
90
|
-
/** Optional exit-code policy. Receives the payload after a
|
|
91
|
-
* successful run; returns the process exit code (0 by default). */
|
|
92
|
-
exitCodePolicy?: (payload: Payload) => number;
|
|
93
86
|
}
|
|
94
87
|
|
|
95
88
|
/** Decision the runner makes after applying the spec to CLI flags. */
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `#(funcName)` / `#(funcName(args))` dynamic-value substitution for
|
|
3
|
+
* loaded YAML values (ARV-190, m-21).
|
|
4
|
+
*
|
|
5
|
+
* Lets a workspace avoid stale hardcoded data — UUIDs that get reused
|
|
6
|
+
* by the API and pin existing rows, dates that expire days after
|
|
7
|
+
* checkout, idempotency keys that race against past runs. Functions
|
|
8
|
+
* are evaluated at LOAD time (per-run, not per-request), so a single
|
|
9
|
+
* `#(uuid)` reference resolves to the same value across every step
|
|
10
|
+
* inside one run — the idempotency-replay scenarios only work if the
|
|
11
|
+
* key stays stable for the run's lifetime.
|
|
12
|
+
*
|
|
13
|
+
* Supported functions:
|
|
14
|
+
* #(uuid) — fresh UUID v4 (stable within one run via cache)
|
|
15
|
+
* #(uuidStable(seed)) — deterministic UUID derived from seed (sha-256 → v4 shape)
|
|
16
|
+
* #(today) — YYYY-MM-DD now (UTC)
|
|
17
|
+
* #(todayPlus(N)) — today + N days (N may be negative)
|
|
18
|
+
* #(now) — ISO 8601 timestamp
|
|
19
|
+
* #(unix) — seconds since epoch
|
|
20
|
+
* #(alphanumeric(N)) — N random a-z0-9 chars
|
|
21
|
+
* #(env:VAR) — process.env.VAR (alias for ${VAR})
|
|
22
|
+
*
|
|
23
|
+
* Resolution order: env-interpolation (${VAR}) runs first because its
|
|
24
|
+
* default-value syntax can produce strings the dynamic resolver should
|
|
25
|
+
* see literally. Dynamic values run BEFORE @secret/@identity reference
|
|
26
|
+
* resolution so the function tokens never leak into a secret-typed
|
|
27
|
+
* value (and so secret-stored values that happen to look like `#(...)`
|
|
28
|
+
* stay opaque). Strings that have no `#(` substring short-circuit.
|
|
29
|
+
*
|
|
30
|
+
* Nested forms: a single value may carry multiple references mixed
|
|
31
|
+
* with literal text — `"req-#(uuid)-#(today)"` yields `req-<uuid>-2026-05-16`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
|
|
36
|
+
export interface DynamicValueContext {
|
|
37
|
+
/** Per-run cache keyed by raw expression (`#(uuid)`, `#(today)`). */
|
|
38
|
+
cache: Map<string, string>;
|
|
39
|
+
/** Source of env vars; defaults to process.env. Override in tests. */
|
|
40
|
+
env?: Record<string, string | undefined>;
|
|
41
|
+
/** File path the value came from — surfaced in error messages. */
|
|
42
|
+
filePath?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Build a fresh cache for one resolution pass. Exported so the caller
|
|
46
|
+
* can share a cache across multiple `resolveDynamicValuesDeep` calls
|
|
47
|
+
* that belong to the same run (e.g. workspace + per-API env files). */
|
|
48
|
+
export function newDynamicCache(): Map<string, string> {
|
|
49
|
+
return new Map();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Captures `#(funcName)` or `#(funcName(args))`. `args` may contain any
|
|
53
|
+
// chars except an unescaped closing paren — keep it simple, the funcs we
|
|
54
|
+
// support take string or integer args without nested parens.
|
|
55
|
+
const FUNC_RE = /#\(([A-Za-z_][A-Za-z0-9_]*)(?::([^)]*))?(?:\(([^)]*)\))?\)/g;
|
|
56
|
+
|
|
57
|
+
export function resolveDynamicValues(
|
|
58
|
+
text: string,
|
|
59
|
+
ctx: DynamicValueContext,
|
|
60
|
+
): string {
|
|
61
|
+
if (typeof text !== "string" || text.indexOf("#(") === -1) return text;
|
|
62
|
+
return text.replace(FUNC_RE, (full, name: string, colonArg: string | undefined, parenArg: string | undefined) => {
|
|
63
|
+
const cached = ctx.cache.get(full);
|
|
64
|
+
if (cached !== undefined) return cached;
|
|
65
|
+
const value = evaluate(name, colonArg ?? parenArg, full, ctx);
|
|
66
|
+
ctx.cache.set(full, value);
|
|
67
|
+
return value;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveDynamicValuesDeep(
|
|
72
|
+
obj: Record<string, unknown>,
|
|
73
|
+
ctx: DynamicValueContext,
|
|
74
|
+
): Record<string, string> {
|
|
75
|
+
const out: Record<string, string> = {};
|
|
76
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
77
|
+
if (typeof v === "string") {
|
|
78
|
+
out[k] = resolveDynamicValues(v, ctx);
|
|
79
|
+
} else {
|
|
80
|
+
out[k] = String(v);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function evaluate(
|
|
87
|
+
name: string,
|
|
88
|
+
arg: string | undefined,
|
|
89
|
+
fullExpr: string,
|
|
90
|
+
ctx: DynamicValueContext,
|
|
91
|
+
): string {
|
|
92
|
+
switch (name) {
|
|
93
|
+
case "uuid":
|
|
94
|
+
return crypto.randomUUID();
|
|
95
|
+
case "uuidStable": {
|
|
96
|
+
if (!arg) throw makeError(`#(uuidStable(seed)) requires a seed argument`, fullExpr, ctx);
|
|
97
|
+
return seedToUuid(arg);
|
|
98
|
+
}
|
|
99
|
+
case "today":
|
|
100
|
+
return new Date().toISOString().slice(0, 10);
|
|
101
|
+
case "todayPlus": {
|
|
102
|
+
if (!arg) throw makeError(`#(todayPlus(N)) requires an integer N`, fullExpr, ctx);
|
|
103
|
+
const n = Number.parseInt(arg, 10);
|
|
104
|
+
if (!Number.isFinite(n)) throw makeError(`#(todayPlus(${arg})) — N must be an integer`, fullExpr, ctx);
|
|
105
|
+
const d = new Date();
|
|
106
|
+
d.setUTCDate(d.getUTCDate() + n);
|
|
107
|
+
return d.toISOString().slice(0, 10);
|
|
108
|
+
}
|
|
109
|
+
case "now":
|
|
110
|
+
return new Date().toISOString();
|
|
111
|
+
case "unix":
|
|
112
|
+
return String(Math.floor(Date.now() / 1000));
|
|
113
|
+
case "alphanumeric": {
|
|
114
|
+
const n = arg ? Number.parseInt(arg, 10) : 8;
|
|
115
|
+
if (!Number.isFinite(n) || n <= 0 || n > 1024) {
|
|
116
|
+
throw makeError(`#(alphanumeric(${arg ?? ""})) — length must be 1..1024`, fullExpr, ctx);
|
|
117
|
+
}
|
|
118
|
+
return randomAlphanumeric(n);
|
|
119
|
+
}
|
|
120
|
+
case "env": {
|
|
121
|
+
if (!arg) throw makeError(`#(env:VAR) requires a variable name`, fullExpr, ctx);
|
|
122
|
+
const env = ctx.env ?? (process.env as Record<string, string | undefined>);
|
|
123
|
+
const v = env[arg];
|
|
124
|
+
if (v === undefined || v === "") {
|
|
125
|
+
throw makeError(`#(env:${arg}) is not set — define it in your shell or CI secret`, fullExpr, ctx);
|
|
126
|
+
}
|
|
127
|
+
return v;
|
|
128
|
+
}
|
|
129
|
+
default:
|
|
130
|
+
throw makeError(`unknown dynamic function "${name}" in ${fullExpr} — supported: uuid, uuidStable, today, todayPlus, now, unix, alphanumeric, env`, fullExpr, ctx);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function seedToUuid(seed: string): string {
|
|
135
|
+
// SHA-256 the seed, take first 16 bytes, format as UUID v4 shape so
|
|
136
|
+
// any consumer-side `format: uuid` validator accepts it. Variant +
|
|
137
|
+
// version bits are forced to match v4 — the value is still
|
|
138
|
+
// deterministic because the source bytes are stable per seed.
|
|
139
|
+
const hash = createHash("sha256").update(seed).digest();
|
|
140
|
+
const bytes = Buffer.from(hash.subarray(0, 16));
|
|
141
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x40; // version 4
|
|
142
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80; // RFC 4122 variant
|
|
143
|
+
const hex = bytes.toString("hex");
|
|
144
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ALPHANUM = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
148
|
+
|
|
149
|
+
function randomAlphanumeric(n: number): string {
|
|
150
|
+
let out = "";
|
|
151
|
+
for (let i = 0; i < n; i++) {
|
|
152
|
+
out += ALPHANUM[Math.floor(Math.random() * ALPHANUM.length)];
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function makeError(msg: string, expr: string, ctx: DynamicValueContext): Error {
|
|
158
|
+
const where = ctx.filePath ? ` (referenced from ${ctx.filePath})` : "";
|
|
159
|
+
return new Error(`Dynamic value ${expr}: ${msg}${where}`);
|
|
160
|
+
}
|
|
Binary file
|
|
@@ -53,5 +53,9 @@ export function formatDryRunDigest(plans: EndpointPlan[]): string {
|
|
|
53
53
|
const summary = summarizeDryRun(plans).summary;
|
|
54
54
|
lines.push("");
|
|
55
55
|
lines.push(`Plan: ${summary.planned} planned · ${summary.skipped} skipped · ${summary.totalEndpoints} total`);
|
|
56
|
+
// ARV-309: the plan above lists what *would* be attacked — no traffic was
|
|
57
|
+
// sent. Without this line a reader can't tell "ran, found nothing" from
|
|
58
|
+
// "never fired" (the plan reads like a findings list). State it explicitly.
|
|
59
|
+
lines.push("Dry-run: mutation probes NOT executed — no requests sent. Re-run without --dry-run to attack live.");
|
|
56
60
|
return lines.join("\n");
|
|
57
61
|
}
|