@kirrosh/zond 0.23.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +164 -1
  2. package/README.md +8 -7
  3. package/package.json +2 -3
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/commands/add-api.ts +19 -7
  6. package/src/cli/commands/api/annotate/index.ts +359 -4
  7. package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
  8. package/src/cli/commands/api/annotate/overlay.ts +1 -1
  9. package/src/cli/commands/api/annotate/pagination.ts +10 -6
  10. package/src/cli/commands/api/annotate/prompts.ts +39 -2
  11. package/src/cli/commands/audit.ts +360 -54
  12. package/src/cli/commands/check.ts +15 -2
  13. package/src/cli/commands/checks.ts +352 -36
  14. package/src/cli/commands/cleanup.ts +4 -30
  15. package/src/cli/commands/coverage.ts +275 -57
  16. package/src/cli/commands/db.ts +311 -8
  17. package/src/cli/commands/discover.ts +281 -161
  18. package/src/cli/commands/doctor.ts +57 -3
  19. package/src/cli/commands/fixtures.ts +1 -1
  20. package/src/cli/commands/generate.ts +24 -7
  21. package/src/cli/commands/init/bootstrap.ts +4 -1
  22. package/src/cli/commands/init/index.ts +2 -2
  23. package/src/cli/commands/init/skills.ts +43 -0
  24. package/src/cli/commands/init/templates/agents.md +12 -3
  25. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  26. package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
  27. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  28. package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
  29. package/src/cli/commands/init/templates/skills/zond.md +274 -64
  30. package/src/cli/commands/init/templates/zond-config.yml +1 -1
  31. package/src/cli/commands/prepare-fixtures.ts +14 -52
  32. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  33. package/src/cli/commands/probe/mass-assignment.ts +101 -10
  34. package/src/cli/commands/probe/security.ts +95 -12
  35. package/src/cli/commands/probe/webhooks.ts +2 -0
  36. package/src/cli/commands/probe.ts +87 -11
  37. package/src/cli/commands/refresh-api.ts +59 -1
  38. package/src/cli/commands/report-bundle.ts +3 -11
  39. package/src/cli/commands/request.ts +116 -0
  40. package/src/cli/commands/run.ts +33 -5
  41. package/src/cli/commands/schema-from-runs.ts +128 -0
  42. package/src/cli/commands/secrets.ts +133 -0
  43. package/src/cli/json-envelope.ts +0 -20
  44. package/src/cli/json-schemas.ts +51 -0
  45. package/src/cli/output.ts +17 -1
  46. package/src/cli/program.ts +5 -4
  47. package/src/cli/safe-live.ts +24 -0
  48. package/src/cli/status-filter.ts +0 -10
  49. package/src/core/audit/persist.ts +183 -0
  50. package/src/core/checks/budget.ts +59 -0
  51. package/src/core/checks/checks/cross_call_references.ts +17 -4
  52. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  53. package/src/core/checks/checks/idempotency_replay.ts +1 -5
  54. package/src/core/checks/checks/ignored_auth.ts +44 -1
  55. package/src/core/checks/checks/index.ts +3 -0
  56. package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
  57. package/src/core/checks/checks/negative_data_rejection.ts +119 -16
  58. package/src/core/checks/checks/not_a_server_error.ts +8 -0
  59. package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
  60. package/src/core/checks/checks/pagination_invariants.ts +298 -117
  61. package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
  62. package/src/core/checks/checks/status_code_conformance.ts +78 -7
  63. package/src/core/checks/mode.ts +3 -0
  64. package/src/core/checks/recommended-action.ts +5 -1
  65. package/src/core/checks/runner.ts +614 -27
  66. package/src/core/checks/spec-findings.ts +308 -0
  67. package/src/core/checks/types.ts +117 -1
  68. package/src/core/checks/zond-extensions.ts +73 -0
  69. package/src/core/classifier/recommended-action.ts +35 -6
  70. package/src/core/coverage/loader.ts +31 -0
  71. package/src/core/diagnostics/db-analysis.ts +200 -106
  72. package/src/core/diagnostics/failure-class.ts +21 -1
  73. package/src/core/diagnostics/failure-hints.ts +4 -208
  74. package/src/core/diagnostics/suggested-fixes.ts +2 -3
  75. package/src/core/generator/chunker.ts +1 -8
  76. package/src/core/generator/data-factory.ts +199 -61
  77. package/src/core/generator/fixtures-builder.ts +38 -31
  78. package/src/core/generator/index.ts +0 -2
  79. package/src/core/generator/openapi-reader.ts +98 -4
  80. package/src/core/generator/path-param-disambig.ts +30 -4
  81. package/src/core/generator/resources-builder.ts +276 -26
  82. package/src/core/generator/schema-utils.ts +22 -0
  83. package/src/core/generator/suite-generator.ts +168 -15
  84. package/src/core/generator/types.ts +6 -0
  85. package/src/core/identity/identity-file.ts +0 -0
  86. package/src/core/output/README.md +11 -29
  87. package/src/core/output/index.ts +1 -1
  88. package/src/core/output/run.ts +0 -35
  89. package/src/core/output/types.ts +0 -7
  90. package/src/core/parser/dynamic-values.ts +160 -0
  91. package/src/core/parser/variables.ts +0 -0
  92. package/src/core/probe/dry-run-envelope.ts +4 -0
  93. package/src/core/probe/mass-assignment/classify.ts +175 -0
  94. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  95. package/src/core/probe/mass-assignment/digest.ts +114 -0
  96. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  97. package/src/core/probe/mass-assignment/regression.ts +141 -0
  98. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  99. package/src/core/probe/mass-assignment/types.ts +135 -0
  100. package/src/core/probe/mass-assignment-probe.ts +23 -1118
  101. package/src/core/probe/mass-assignment-template.ts +32 -4
  102. package/src/core/probe/path-discovery.ts +3 -4
  103. package/src/core/probe/probe-harness.ts +21 -22
  104. package/src/core/probe/security/baseline.ts +174 -0
  105. package/src/core/probe/security/classify.ts +341 -0
  106. package/src/core/probe/security/cleanup.ts +125 -0
  107. package/src/core/probe/security/detectors.ts +71 -0
  108. package/src/core/probe/security/digest.ts +104 -0
  109. package/src/core/probe/security/orchestrator.ts +398 -0
  110. package/src/core/probe/security/regression.ts +103 -0
  111. package/src/core/probe/security/types.ts +151 -0
  112. package/src/core/probe/security-probe-class.ts +8 -2
  113. package/src/core/probe/security-probe.ts +28 -1449
  114. package/src/core/probe/shared.ts +26 -0
  115. package/src/core/probe/webhooks-probe.ts +5 -7
  116. package/src/core/runner/assertions.ts +1 -1
  117. package/src/core/runner/executor.ts +3 -18
  118. package/src/core/runner/form-encode.ts +8 -18
  119. package/src/core/runner/http-client.ts +38 -1
  120. package/src/core/runner/preflight-vars.ts +19 -15
  121. package/src/core/runner/rate-limiter.ts +11 -29
  122. package/src/core/runner/run-kind.ts +7 -1
  123. package/src/core/runner/schema-validator.ts +2 -6
  124. package/src/core/runner/send-request.ts +11 -6
  125. package/src/core/runner/types.ts +6 -0
  126. package/src/core/setup-api.ts +53 -15
  127. package/src/core/severity/index.ts +0 -63
  128. package/src/core/spec/infer-schema.ts +102 -0
  129. package/src/core/spec/merge-specs.ts +156 -0
  130. package/src/core/spec/schema-from-runs.ts +117 -0
  131. package/src/core/spec/schema-overlay.ts +130 -0
  132. package/src/core/util/ajv.ts +13 -0
  133. package/src/core/util/headers.ts +9 -0
  134. package/src/core/util/url.ts +24 -0
  135. package/src/core/workspace/fixture-gap-report.ts +84 -0
  136. package/src/core/workspace/fixture-gaps.ts +71 -0
  137. package/src/core/workspace/root.ts +13 -11
  138. package/src/db/migrate.ts +2 -0
  139. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  140. package/src/db/queries/collections.ts +2 -2
  141. package/src/db/queries/results.ts +88 -0
  142. package/src/db/queries/runs.ts +56 -2
  143. package/src/db/queries.ts +3 -0
  144. package/src/db/schema.ts +7 -7
  145. package/src/cli/commands/bootstrap.ts +0 -710
  146. package/src/core/anti-fp/bootstrap.ts +0 -34
  147. package/src/core/anti-fp/index.ts +0 -33
  148. package/src/core/anti-fp/registry.ts +0 -44
  149. package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
  150. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
  151. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
  152. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
  153. package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
  154. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
  155. package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
  156. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
  157. package/src/core/anti-fp/types.ts +0 -68
  158. package/src/core/generator/create-body.ts +0 -89
@@ -0,0 +1,102 @@
1
+ /**
2
+ * ARV-175: infer a JSON Schema (draft-07 subset) from a set of sample
3
+ * response bodies. Built-in, zero-dependency — quicktype/genson would each
4
+ * drag in a large dependency tree, which contradicts zond's dumb-tool /
5
+ * minimal-deps charter (see src/CLAUDE.md). The `--engine` flag keeps the
6
+ * seam open if a heavier engine is ever wanted, but `builtin` is the default
7
+ * and the only one wired.
8
+ *
9
+ * Strategy — structural union over the samples:
10
+ * - primitives → { type }
11
+ * - arrays → { type: "array", items: <merge of every element> }
12
+ * - objects → { type: "object", properties, required }
13
+ * `required` = keys present in EVERY object sample (an
14
+ * intersection — a field missing from one sample is optional).
15
+ * - mixed types across samples → { type: [sorted, unique] } (or a bare
16
+ * type when they all agree). null folds into the type list so a
17
+ * sometimes-null field reads as `["null","string"]`.
18
+ *
19
+ * Not a full inference engine: no format detection, no enum mining, no
20
+ * anyOf for heterogeneous array items beyond a type union. Good enough to
21
+ * seed `response_schema_conformance` on specs that declare no response
22
+ * schema, which is the whole point (ARV-175 goal).
23
+ */
24
+
25
+ export type JsonSchema = Record<string, unknown>;
26
+
27
+ type JsonType = "null" | "boolean" | "integer" | "number" | "string" | "array" | "object";
28
+
29
+ function typeOf(v: unknown): JsonType {
30
+ if (v === null) return "null";
31
+ if (Array.isArray(v)) return "array";
32
+ const t = typeof v;
33
+ if (t === "boolean") return "boolean";
34
+ if (t === "number") return Number.isInteger(v) ? "integer" : "number";
35
+ if (t === "string") return "string";
36
+ return "object";
37
+ }
38
+
39
+ /** Infer a schema from one or more samples of the same logical value. */
40
+ export function inferSchema(samples: unknown[]): JsonSchema {
41
+ const nonEmpty = samples.filter((s) => s !== undefined);
42
+ if (nonEmpty.length === 0) return {};
43
+
44
+ const types = new Set<JsonType>();
45
+ for (const s of nonEmpty) types.add(typeOf(s));
46
+
47
+ // Object: merge properties across every object sample.
48
+ if (types.has("object")) {
49
+ const objSamples = nonEmpty.filter((s) => typeOf(s) === "object") as Array<Record<string, unknown>>;
50
+ const propSamples = new Map<string, unknown[]>();
51
+ for (const obj of objSamples) {
52
+ for (const [k, v] of Object.entries(obj)) {
53
+ if (!propSamples.has(k)) propSamples.set(k, []);
54
+ propSamples.get(k)!.push(v);
55
+ }
56
+ }
57
+ // required = keys present in ALL object samples (intersection).
58
+ const required = [...propSamples.keys()].filter(
59
+ (k) => objSamples.every((obj) => Object.prototype.hasOwnProperty.call(obj, k)),
60
+ );
61
+ const properties: JsonSchema = {};
62
+ for (const [k, vs] of propSamples) properties[k] = inferSchema(vs);
63
+
64
+ const schema: JsonSchema = { type: mergeType(types, "object") };
65
+ if (Object.keys(properties).length > 0) schema.properties = sortKeys(properties);
66
+ if (required.length > 0) schema.required = required.sort();
67
+ return schema;
68
+ }
69
+
70
+ // Array: items schema is the union of every element across every sample.
71
+ if (types.has("array")) {
72
+ const elements: unknown[] = [];
73
+ for (const s of nonEmpty) if (Array.isArray(s)) elements.push(...s);
74
+ const schema: JsonSchema = { type: mergeType(types, "array") };
75
+ if (elements.length > 0) schema.items = inferSchema(elements);
76
+ return schema;
77
+ }
78
+
79
+ // Primitives only.
80
+ return { type: mergeType(types) };
81
+ }
82
+
83
+ /** Collapse the observed type set into a single `type` value: a string when
84
+ * they agree (preferring `primary` if given, e.g. object/array), otherwise a
85
+ * sorted unique list. `integer`+`number` collapses to `number`. */
86
+ function mergeType(types: Set<JsonType>, primary?: JsonType): string | string[] {
87
+ const t = new Set(types);
88
+ if (t.has("number") && t.has("integer")) t.delete("integer");
89
+ if (primary && t.size > 1) {
90
+ // Object/array with a stray null etc. — keep the structural type plus null.
91
+ const rest = [...t].filter((x) => x !== primary);
92
+ if (rest.length === 1 && rest[0] === "null") return [primary, "null"].sort();
93
+ }
94
+ const arr = [...t].sort();
95
+ return arr.length === 1 ? arr[0]! : arr;
96
+ }
97
+
98
+ function sortKeys(obj: JsonSchema): JsonSchema {
99
+ const out: JsonSchema = {};
100
+ for (const k of Object.keys(obj).sort()) out[k] = obj[k];
101
+ return out;
102
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Deterministic union of two or more dereferenced OpenAPI documents into
3
+ * one (ARV-375). Motivation: an org running multiple API versions
4
+ * side-by-side (v1/v2, deprecated-but-live + current) wants combined
5
+ * coverage instead of re-scanning each version and reconciling reports by
6
+ * hand. Before this, that merge was a one-off python script per session.
7
+ *
8
+ * Policy — pure, deterministic, no judgment (belongs in zond core per the
9
+ * litmus test):
10
+ * - `paths`: union; on a path-key collision the LATER spec wins, and the
11
+ * collision is recorded so the caller can warn (silent shadowing of a
12
+ * real endpoint is a correctness trap).
13
+ * - `components.*`: union per sub-bucket (schemas, securitySchemes,
14
+ * parameters, responses, …); later wins. A component-name collision
15
+ * whose two definitions DIFFER (deep-unequal) is reported separately —
16
+ * that is the dangerous case (same name, different shape).
17
+ * - `servers`, `security`, `tags`: unioned & de-duped by value/name.
18
+ * - `info`: taken from the first spec; `version` becomes the unique
19
+ * source versions joined with `+` so the merged target is self-labelling.
20
+ *
21
+ * Inputs are assumed already dereferenced (readOpenApiSpec output), so path
22
+ * operations are self-contained — no cross-spec $ref resolution needed.
23
+ */
24
+
25
+ import type { OpenAPIV3 } from "openapi-types";
26
+
27
+ export interface MergeInput {
28
+ /** Source label (path or URL) for the merge summary. */
29
+ source: string;
30
+ doc: OpenAPIV3.Document;
31
+ }
32
+
33
+ export interface MergeSummary {
34
+ sources: { source: string; paths: number }[];
35
+ totalPaths: number;
36
+ /** Path keys declared by more than one spec (later-wins applied). */
37
+ pathCollisions: string[];
38
+ /** `components.<bucket>.<name>` whose definition differs across specs. */
39
+ schemaConflicts: string[];
40
+ }
41
+
42
+ export interface MergeResult {
43
+ merged: OpenAPIV3.Document;
44
+ summary: MergeSummary;
45
+ }
46
+
47
+ const COMPONENT_BUCKETS = [
48
+ "schemas",
49
+ "responses",
50
+ "parameters",
51
+ "examples",
52
+ "requestBodies",
53
+ "headers",
54
+ "securitySchemes",
55
+ "links",
56
+ "callbacks",
57
+ ] as const;
58
+
59
+ function deepEqual(a: unknown, b: unknown): boolean {
60
+ // Cheap structural compare — specs are plain JSON after dereference.
61
+ return JSON.stringify(a) === JSON.stringify(b);
62
+ }
63
+
64
+ export function mergeOpenApiDocs(inputs: MergeInput[]): MergeResult {
65
+ if (inputs.length === 0) throw new Error("mergeOpenApiDocs: no specs to merge");
66
+ const first = inputs[0]!.doc;
67
+
68
+ const paths: NonNullable<OpenAPIV3.Document["paths"]> = {};
69
+ const pathCollisions: string[] = [];
70
+ const sources: MergeSummary["sources"] = [];
71
+
72
+ for (const { source, doc } of inputs) {
73
+ const specPaths = doc.paths ?? {};
74
+ let count = 0;
75
+ for (const [p, item] of Object.entries(specPaths)) {
76
+ if (p in paths) pathCollisions.push(p);
77
+ paths[p] = item as OpenAPIV3.PathItemObject;
78
+ count++;
79
+ }
80
+ sources.push({ source, paths: count });
81
+ }
82
+
83
+ // Merge components bucket-by-bucket; flag same-name-different-shape.
84
+ const schemaConflicts: string[] = [];
85
+ const components: Record<string, Record<string, unknown>> = {};
86
+ for (const { doc } of inputs) {
87
+ const comps = (doc.components ?? {}) as Record<string, Record<string, unknown> | undefined>;
88
+ for (const bucket of COMPONENT_BUCKETS) {
89
+ const incoming = comps[bucket];
90
+ if (!incoming) continue;
91
+ const target = (components[bucket] ??= {});
92
+ for (const [name, def] of Object.entries(incoming)) {
93
+ if (name in target && !deepEqual(target[name], def)) {
94
+ schemaConflicts.push(`${bucket}.${name}`);
95
+ }
96
+ target[name] = def;
97
+ }
98
+ }
99
+ }
100
+
101
+ // Union servers by url, security by value, tags by name.
102
+ const servers = dedupeBy(
103
+ inputs.flatMap((i) => i.doc.servers ?? []),
104
+ (s) => s.url,
105
+ );
106
+ const security = dedupeBy(
107
+ inputs.flatMap((i) => i.doc.security ?? []),
108
+ (s) => JSON.stringify(s),
109
+ );
110
+ const tags = dedupeBy(
111
+ inputs.flatMap((i) => i.doc.tags ?? []),
112
+ (t) => t.name,
113
+ );
114
+
115
+ const versions = dedupe(inputs.map((i) => i.doc.info?.version).filter(Boolean) as string[]);
116
+
117
+ const merged: OpenAPIV3.Document = {
118
+ ...first,
119
+ openapi: first.openapi ?? "3.0.0",
120
+ info: {
121
+ ...first.info,
122
+ version: versions.join("+") || first.info?.version || "merged",
123
+ },
124
+ paths,
125
+ ...(Object.keys(components).length > 0 ? { components: components as OpenAPIV3.ComponentsObject } : {}),
126
+ ...(servers.length > 0 ? { servers } : {}),
127
+ ...(security.length > 0 ? { security } : {}),
128
+ ...(tags.length > 0 ? { tags } : {}),
129
+ };
130
+
131
+ return {
132
+ merged,
133
+ summary: {
134
+ sources,
135
+ totalPaths: Object.keys(paths).length,
136
+ pathCollisions: dedupe(pathCollisions),
137
+ schemaConflicts: dedupe(schemaConflicts),
138
+ },
139
+ };
140
+ }
141
+
142
+ function dedupe<T>(arr: T[]): T[] {
143
+ return [...new Set(arr)];
144
+ }
145
+
146
+ function dedupeBy<T>(arr: T[], key: (x: T) => string): T[] {
147
+ const seen = new Set<string>();
148
+ const out: T[] = [];
149
+ for (const x of arr) {
150
+ const k = key(x);
151
+ if (seen.has(k)) continue;
152
+ seen.add(k);
153
+ out.push(x);
154
+ }
155
+ return out;
156
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * ARV-175: extract 2xx response bodies from a persisted run and infer a
3
+ * JSON Schema per (endpoint, status). The output `patch.schema.json` is the
4
+ * input to `refresh-api --merge-schema` (ARV-176), which folds it into the
5
+ * spec overlay so `response_schema_conformance` has something to check on
6
+ * APIs whose upstream spec declares no response schemas.
7
+ */
8
+
9
+ import { inferSchema, type JsonSchema } from "./infer-schema.ts";
10
+ import { specPathToRegex, normalizePath } from "../generator/coverage-scanner.ts";
11
+ import type { EndpointInfo } from "../generator/types.ts";
12
+
13
+ export interface SchemaFromRunsResult {
14
+ /** endpoint label (`METHOD /path/{tpl}`) → status code → inferred schema. */
15
+ patch: Record<string, Record<string, JsonSchema>>;
16
+ /** Per-group accounting for the CLI to report. */
17
+ groups: Array<{
18
+ endpoint: string;
19
+ status: string;
20
+ samples: number;
21
+ emitted: boolean;
22
+ reason?: string;
23
+ }>;
24
+ }
25
+
26
+ export interface ResultRow {
27
+ request_method: string | null;
28
+ request_url: string | null;
29
+ response_status: number | null;
30
+ response_body: string | null;
31
+ }
32
+
33
+ /** Strip base URL, query, and trailing slash from a concrete request URL,
34
+ * leaving just the path for spec-template matching. */
35
+ function pathOf(rawUrl: string): string {
36
+ let p = rawUrl;
37
+ // Drop scheme+host if present.
38
+ const schemeIdx = p.indexOf("://");
39
+ if (schemeIdx !== -1) {
40
+ const afterScheme = p.slice(schemeIdx + 3);
41
+ const slash = afterScheme.indexOf("/");
42
+ p = slash === -1 ? "/" : afterScheme.slice(slash);
43
+ }
44
+ const q = p.indexOf("?");
45
+ if (q !== -1) p = p.slice(0, q);
46
+ return p.replace(/\/+$/, "") || "/";
47
+ }
48
+
49
+ /** Match a concrete path to the most specific spec endpoint (fewest params
50
+ * wins, so `/users/me` beats `/users/{id}`). Returns the endpoint label. */
51
+ function matchEndpoint(
52
+ method: string,
53
+ concretePath: string,
54
+ endpoints: EndpointInfo[],
55
+ ): string | null {
56
+ const norm = normalizePath(concretePath);
57
+ const candidates = endpoints
58
+ .filter((e) => e.method.toUpperCase() === method.toUpperCase())
59
+ .filter((e) => specPathToRegex(e.path).test(norm))
60
+ .sort((a, b) => paramCount(a.path) - paramCount(b.path));
61
+ const best = candidates[0];
62
+ return best ? `${best.method.toUpperCase()} ${best.path}` : null;
63
+ }
64
+
65
+ function paramCount(path: string): number {
66
+ return (path.match(/\{[^}]+\}/g) ?? []).length;
67
+ }
68
+
69
+ export interface SchemaFromRunsOptions {
70
+ results: ResultRow[];
71
+ endpoints: EndpointInfo[];
72
+ /** Minimum 2xx samples per (endpoint, status) group to emit a schema. */
73
+ minSamples: number;
74
+ }
75
+
76
+ export function schemaFromRuns(opts: SchemaFromRunsOptions): SchemaFromRunsResult {
77
+ const { results, endpoints, minSamples } = opts;
78
+ // (endpoint → status → parsed bodies)
79
+ const buckets = new Map<string, Map<string, unknown[]>>();
80
+ // Preserve unmatched URLs so the CLI can hint at spec/base-url drift.
81
+ for (const r of results) {
82
+ if (r.response_status == null || r.response_status < 200 || r.response_status >= 300) continue;
83
+ if (!r.response_body || !r.request_url || !r.request_method) continue;
84
+ let body: unknown;
85
+ try {
86
+ body = JSON.parse(r.response_body);
87
+ } catch {
88
+ continue; // non-JSON 2xx body — nothing to infer for application/json
89
+ }
90
+ const endpoint = matchEndpoint(r.request_method, pathOf(r.request_url), endpoints);
91
+ if (!endpoint) continue;
92
+ const status = String(r.response_status);
93
+ if (!buckets.has(endpoint)) buckets.set(endpoint, new Map());
94
+ const byStatus = buckets.get(endpoint)!;
95
+ if (!byStatus.has(status)) byStatus.set(status, []);
96
+ byStatus.get(status)!.push(body);
97
+ }
98
+
99
+ const patch: SchemaFromRunsResult["patch"] = {};
100
+ const groups: SchemaFromRunsResult["groups"] = [];
101
+ // Deterministic order: sort by endpoint then status.
102
+ for (const endpoint of [...buckets.keys()].sort()) {
103
+ const byStatus = buckets.get(endpoint)!;
104
+ for (const status of [...byStatus.keys()].sort()) {
105
+ const samples = byStatus.get(status)!;
106
+ if (samples.length < minSamples) {
107
+ groups.push({ endpoint, status, samples: samples.length, emitted: false, reason: `<${minSamples} samples` });
108
+ continue;
109
+ }
110
+ const schema = inferSchema(samples);
111
+ if (!patch[endpoint]) patch[endpoint] = {};
112
+ patch[endpoint]![status] = schema;
113
+ groups.push({ endpoint, status, samples: samples.length, emitted: true });
114
+ }
115
+ }
116
+ return { patch, groups };
117
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * ARV-176: response-schema overlay. `schema-from-runs` (ARV-175) emits a
3
+ * `patch.schema.json` of inferred response schemas keyed by `METHOD /path` →
4
+ * status. `refresh-api --merge-schema` folds that patch into a persistent
5
+ * overlay (`apis/<name>/.api-schema.local.yaml`) and applies it onto the
6
+ * freshly-pulled spec.json.
7
+ *
8
+ * Why a dedicated overlay file (not `.api-resources.local.yaml`): that file
9
+ * is resource-shaped (ResourceYaml[]); response schemas are a different
10
+ * dimension and shoehorning them in would muddy both. Same survives-refresh
11
+ * contract as ARV-111's resource overlay — refresh-api re-applies it on every
12
+ * run, so an upstream re-pull never loses the mined schemas.
13
+ */
14
+
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
18
+ import type { JsonSchema } from "./infer-schema.ts";
19
+
20
+ export const SCHEMA_OVERLAY_FILENAME = ".api-schema.local.yaml";
21
+
22
+ /** `METHOD /path` → status code → JSON Schema. */
23
+ export type ResponseSchemaPatch = Record<string, Record<string, JsonSchema>>;
24
+
25
+ interface SchemaOverlayFile {
26
+ version: 1;
27
+ response_schemas: ResponseSchemaPatch;
28
+ }
29
+
30
+ export function overlayPath(baseDir: string): string {
31
+ return join(baseDir, SCHEMA_OVERLAY_FILENAME);
32
+ }
33
+
34
+ /** Load the overlay for an API, or null when absent. */
35
+ export function loadSchemaOverlay(baseDir: string): ResponseSchemaPatch | null {
36
+ const p = overlayPath(baseDir);
37
+ if (!existsSync(p)) return null;
38
+ try {
39
+ const parsed = parseYaml(readFileSync(p, "utf-8")) as Partial<SchemaOverlayFile> | null;
40
+ if (!parsed || typeof parsed !== "object") return null;
41
+ return parsed.response_schemas ?? null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** Union two patches; entries in `incoming` win on collision. */
48
+ export function mergePatch(base: ResponseSchemaPatch | null, incoming: ResponseSchemaPatch): ResponseSchemaPatch {
49
+ const out: ResponseSchemaPatch = {};
50
+ for (const [ep, byStatus] of Object.entries(base ?? {})) out[ep] = { ...byStatus };
51
+ for (const [ep, byStatus] of Object.entries(incoming)) {
52
+ out[ep] = { ...(out[ep] ?? {}), ...byStatus };
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** Persist the overlay (sorted for a stable, diff-friendly file). */
58
+ export function saveSchemaOverlay(baseDir: string, patch: ResponseSchemaPatch): void {
59
+ const sorted: ResponseSchemaPatch = {};
60
+ for (const ep of Object.keys(patch).sort()) {
61
+ const byStatus = patch[ep]!;
62
+ sorted[ep] = {};
63
+ for (const st of Object.keys(byStatus).sort()) sorted[ep]![st] = byStatus[st]!;
64
+ }
65
+ const file: SchemaOverlayFile = { version: 1, response_schemas: sorted };
66
+ writeFileSync(overlayPath(baseDir), stringifyYaml(file), "utf-8");
67
+ }
68
+
69
+ export interface ApplyOverlayResult {
70
+ /** `METHOD /path status` labels that got a schema written. */
71
+ applied: string[];
72
+ /** Labels skipped because a schema already existed (no --force). */
73
+ preserved: string[];
74
+ /** Labels skipped because the endpoint no longer exists upstream. */
75
+ conflicts: string[];
76
+ }
77
+
78
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"];
79
+
80
+ /**
81
+ * Apply a response-schema patch onto a dereferenced OpenAPI doc, in place.
82
+ * Fills `responses.<status>.content['application/json'].schema` where it is
83
+ * absent (or with `force`). An endpoint/method missing from the doc is a
84
+ * conflict (upstream drift) and is skipped, not fabricated.
85
+ */
86
+ export function applySchemaOverlay(
87
+ doc: unknown,
88
+ patch: ResponseSchemaPatch,
89
+ opts: { force?: boolean } = {},
90
+ ): ApplyOverlayResult {
91
+ const result: ApplyOverlayResult = { applied: [], preserved: [], conflicts: [] };
92
+ const paths = (doc as { paths?: Record<string, unknown> }).paths;
93
+ if (!paths) {
94
+ for (const [ep, byStatus] of Object.entries(patch)) {
95
+ for (const st of Object.keys(byStatus)) result.conflicts.push(`${ep} ${st}`);
96
+ }
97
+ return result;
98
+ }
99
+
100
+ for (const [endpoint, byStatus] of Object.entries(patch)) {
101
+ const sp = endpoint.indexOf(" ");
102
+ const method = endpoint.slice(0, sp).toLowerCase();
103
+ const path = endpoint.slice(sp + 1);
104
+ const pathItem = paths[path] as Record<string, unknown> | undefined;
105
+ const op = pathItem && HTTP_METHODS.includes(method)
106
+ ? (pathItem[method] as { responses?: Record<string, unknown> } | undefined)
107
+ : undefined;
108
+
109
+ for (const [status, schema] of Object.entries(byStatus)) {
110
+ const label = `${endpoint} ${status}`;
111
+ if (!op) {
112
+ result.conflicts.push(label);
113
+ continue;
114
+ }
115
+ if (!op.responses) op.responses = {};
116
+ const responses = op.responses as Record<string, { content?: Record<string, { schema?: unknown }> }>;
117
+ if (!responses[status]) responses[status] = { content: {} } as { content: Record<string, { schema?: unknown }> };
118
+ const resp = responses[status]!;
119
+ if (!resp.content) resp.content = {};
120
+ const mt = resp.content["application/json"] ?? (resp.content["application/json"] = {});
121
+ if (mt.schema !== undefined && !opts.force) {
122
+ result.preserved.push(label);
123
+ continue;
124
+ }
125
+ mt.schema = schema;
126
+ result.applied.push(label);
127
+ }
128
+ }
129
+ return result;
130
+ }
@@ -0,0 +1,13 @@
1
+ import Ajv2020 from "ajv/dist/2020.js";
2
+ import Ajv from "ajv";
3
+ import addFormats from "ajv-formats";
4
+
5
+ /** OpenAPI 3.1 → JSON Schema Draft 2020-12 (Ajv2020); 3.0 → Draft 4/7-ish
6
+ * (plain Ajv). Both get the `ajv-formats` format keywords registered. */
7
+ export function makeAjv(isV31: boolean, opts: ConstructorParameters<typeof Ajv>[0] = {}): Ajv {
8
+ const ajv = isV31
9
+ ? new (Ajv2020 as unknown as typeof Ajv)(opts)
10
+ : new Ajv(opts);
11
+ addFormats(ajv);
12
+ return ajv;
13
+ }
@@ -0,0 +1,9 @@
1
+ /** Case-insensitive check whether a header is already present in the map.
2
+ * Deliberately not `new Headers(headers).has(name)` — the WHATWG Headers
3
+ * constructor throws on values with invalid characters (e.g. raw CRLF),
4
+ * which real interpolated var values can contain; a plain key scan never
5
+ * throws on the value shape. */
6
+ export function hasHeaderCI(headers: Record<string, string>, name: string): boolean {
7
+ const lower = name.toLowerCase();
8
+ return Object.keys(headers).some((k) => k.toLowerCase() === lower);
9
+ }
@@ -0,0 +1,24 @@
1
+ /** Concatenate baseUrl and path, stripping any trailing slashes from base. */
2
+ export function joinBaseAndPath(baseUrl: string | undefined, path: string): string {
3
+ if (!baseUrl) return path;
4
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
5
+ }
6
+
7
+ export type QueryValue = string | number | boolean;
8
+
9
+ /** Build a URL from base + path with an optional query record. Values are
10
+ * coerced to strings via String(); URL-encoding is delegated to
11
+ * URLSearchParams. */
12
+ export function buildUrl(
13
+ baseUrl: string | undefined,
14
+ path: string,
15
+ query?: Record<string, QueryValue>,
16
+ ): string {
17
+ let url = joinBaseAndPath(baseUrl, path);
18
+ if (query && Object.keys(query).length > 0) {
19
+ const params = new URLSearchParams();
20
+ for (const [k, v] of Object.entries(query)) params.append(k, String(v));
21
+ url += `?${params.toString()}`;
22
+ }
23
+ return url;
24
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * ARV-349/350: deterministic fixture-gap report for `prepare-fixtures`.
3
+ *
4
+ * prepare-fixtures is a single-pass discover (ARV-336 removed the autonomous
5
+ * seed engine). It fills FK ids it can resolve from list endpoints, but it
6
+ * used to neither fill NOR flag two other classes of gap, so suites ran with
7
+ * unresolved placeholders and produced noisy 400/404s:
8
+ *
9
+ * - undefinedVars (ARV-349): a suite references {{bank_code}} / {{tax_id}}
10
+ * that nothing produces — not an env value, not a prior-step capture, not
11
+ * a manifest entry. The user / agent must supply it.
12
+ * - unseededRoots (ARV-350): a REQUIRED manifest var (e.g. {{account}}) that
13
+ * is empty in env, referenced by a suite, and captured by no step — the
14
+ * dependency-chain HEAD that gates dependent CRUD suites (persons-crud,
15
+ * external_accounts-crud all skip: "required fixture {{account}} is empty").
16
+ * Source-agnostic on purpose: real manifests model this root as `path`
17
+ * required:true with an empty default, not necessarily `capture-chain`.
18
+ *
19
+ * REPORT ONLY — never invents a value (ARV-349 #2) and never auto-seeds
20
+ * (ARV-350 #2). Deterministic (same suites+env+manifest → same report), so it
21
+ * belongs in zond; supplying the values is the agent's/user's job.
22
+ */
23
+
24
+ import type { TestSuite } from "../parser/types.ts";
25
+ import {
26
+ preflightCheckVars,
27
+ collectCapturesAndSets,
28
+ collectStepRefs,
29
+ } from "../runner/preflight-vars.ts";
30
+
31
+ export interface FixtureGapReport {
32
+ /** Suite {{vars}} with no producer (not env, capture, param, or generator). */
33
+ undefinedVars: { variable: string; refs: number; suites: string[] }[];
34
+ /** Required manifest vars that are empty, suite-referenced, and step-unseeded. */
35
+ unseededRoots: { variable: string }[];
36
+ }
37
+
38
+ export function reportFixtureGaps(
39
+ suites: TestSuite[],
40
+ env: Record<string, string>,
41
+ requiredEmptyVars: Set<string>,
42
+ ): FixtureGapReport {
43
+ // Match runtime capture scoping: only `setup: true` suites share their
44
+ // captures into other suites (TestSuite.setup); a regular suite's captures
45
+ // stay local to itself. So a var is "seeded" for suite S iff env holds it,
46
+ // a setup suite captures it, or S itself captures it.
47
+ const setupProduced = new Set<string>();
48
+ for (const suite of suites) {
49
+ if (!suite.setup) continue;
50
+ for (const step of suite.tests) collectCapturesAndSets(step, setupProduced);
51
+ }
52
+
53
+ // Unseeded root = required + empty in env + SOME referencing suite cannot
54
+ // produce it (not a setup capture, not captured within that suite). This
55
+ // catches cross-suite roots like {{account}}: crud-accounts creates it, but
56
+ // persons-crud references it without a create → that suite skips at runtime.
57
+ const rootSet = new Set<string>();
58
+ for (const suite of suites) {
59
+ const seeded = new Set<string>(setupProduced);
60
+ for (const step of suite.tests) collectCapturesAndSets(step, seeded);
61
+ for (const step of suite.tests) {
62
+ for (const v of collectStepRefs(step)) {
63
+ if (requiredEmptyVars.has(v) && !seeded.has(v)) rootSet.add(v);
64
+ }
65
+ }
66
+ }
67
+ const unseededRoots = [...rootSet].sort().map(variable => ({ variable }));
68
+
69
+ // Undefined vars = preflight hits, minus the roots we already called out
70
+ // (a root absent from env would otherwise land in both buckets).
71
+ const byVar = new Map<string, { refs: number; suites: Set<string> }>();
72
+ for (const h of preflightCheckVars(suites, env)) {
73
+ if (rootSet.has(h.variable)) continue;
74
+ let e = byVar.get(h.variable);
75
+ if (!e) { e = { refs: 0, suites: new Set() }; byVar.set(h.variable, e); }
76
+ e.refs++;
77
+ e.suites.add(h.suite);
78
+ }
79
+ const undefinedVars = [...byVar.entries()]
80
+ .map(([variable, e]) => ({ variable, refs: e.refs, suites: [...e.suites].sort() }))
81
+ .sort((a, b) => a.variable.localeCompare(b.variable));
82
+
83
+ return { undefinedVars, unseededRoots };
84
+ }