@kirrosh/zond 0.22.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 (282) hide show
  1. package/CHANGELOG.md +811 -0
  2. package/README.md +59 -6
  3. package/package.json +9 -7
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/argv.ts +122 -0
  6. package/src/cli/commands/add-api.ts +146 -0
  7. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  8. package/src/cli/commands/api/annotate/index.ts +880 -0
  9. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  10. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  11. package/src/cli/commands/api/annotate/pagination.ts +64 -0
  12. package/src/cli/commands/api/annotate/prompts.ts +220 -0
  13. package/src/cli/commands/api/annotate/readback.ts +58 -0
  14. package/src/cli/commands/api/annotate/resources.ts +91 -0
  15. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  16. package/src/cli/commands/audit.ts +786 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +361 -0
  19. package/src/cli/commands/checks.ts +1072 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +236 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +823 -132
  25. package/src/cli/commands/db.ts +486 -12
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1356 -0
  28. package/src/cli/commands/doctor.ts +661 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +438 -47
  31. package/src/cli/commands/init/bootstrap.ts +34 -2
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +99 -3
  34. package/src/cli/commands/init/templates/agents.md +77 -64
  35. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  36. package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
  37. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +802 -125
  40. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  41. package/src/cli/commands/prepare-fixtures.ts +97 -0
  42. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  43. package/src/cli/commands/probe/mass-assignment.ts +594 -0
  44. package/src/cli/commands/probe/security.ts +537 -0
  45. package/src/cli/commands/probe/static.ts +255 -0
  46. package/src/cli/commands/probe/webhooks.ts +163 -0
  47. package/src/cli/commands/probe.ts +535 -0
  48. package/src/cli/commands/reference.ts +87 -0
  49. package/src/cli/commands/refresh-api.ts +227 -0
  50. package/src/cli/commands/remove-api.ts +150 -0
  51. package/src/cli/commands/report-bundle.ts +310 -0
  52. package/src/cli/commands/report.ts +241 -0
  53. package/src/cli/commands/request.ts +495 -4
  54. package/src/cli/commands/run.ts +870 -53
  55. package/src/cli/commands/schema-from-runs.ts +128 -0
  56. package/src/cli/commands/secrets.ts +133 -0
  57. package/src/cli/commands/session.ts +244 -0
  58. package/src/cli/commands/use.ts +18 -1
  59. package/src/cli/index.ts +20 -3
  60. package/src/cli/json-envelope.ts +92 -3
  61. package/src/cli/json-schemas.ts +314 -0
  62. package/src/cli/output.ts +17 -1
  63. package/src/cli/program.ts +199 -635
  64. package/src/cli/resolve.ts +105 -0
  65. package/src/cli/safe-live.ts +24 -0
  66. package/src/cli/status-filter.ts +114 -0
  67. package/src/cli/util/api-context.ts +85 -0
  68. package/src/cli/version.ts +5 -0
  69. package/src/core/audit/persist.ts +183 -0
  70. package/src/core/checks/budget.ts +59 -0
  71. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  72. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  73. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  74. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  75. package/src/core/checks/checks/cross_call_references.ts +147 -0
  76. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  77. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  78. package/src/core/checks/checks/idempotency_replay.ts +242 -0
  79. package/src/core/checks/checks/ignored_auth.ts +254 -0
  80. package/src/core/checks/checks/index.ts +68 -0
  81. package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
  82. package/src/core/checks/checks/missing_required_header.ts +40 -0
  83. package/src/core/checks/checks/negative_data_rejection.ts +148 -0
  84. package/src/core/checks/checks/not_a_server_error.ts +35 -0
  85. package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
  86. package/src/core/checks/checks/pagination_invariants.ts +419 -0
  87. package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
  88. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  89. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  90. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  91. package/src/core/checks/checks/status_code_conformance.ts +132 -0
  92. package/src/core/checks/checks/unsupported_method.ts +63 -0
  93. package/src/core/checks/checks/use_after_free.ts +78 -0
  94. package/src/core/checks/index.ts +30 -0
  95. package/src/core/checks/mode.ts +82 -0
  96. package/src/core/checks/recommended-action.ts +68 -0
  97. package/src/core/checks/registry.ts +78 -0
  98. package/src/core/checks/runner.ts +1461 -0
  99. package/src/core/checks/sarif.ts +230 -0
  100. package/src/core/checks/spec-findings.ts +308 -0
  101. package/src/core/checks/stateful.ts +121 -0
  102. package/src/core/checks/types.ts +305 -0
  103. package/src/core/checks/zond-extensions.ts +73 -0
  104. package/src/core/classifier/recommended-action.ts +251 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +216 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +293 -59
  110. package/src/core/diagnostics/failure-class.ts +140 -0
  111. package/src/core/diagnostics/failure-hints.ts +88 -89
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +155 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +38 -19
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/data-factory.ts +586 -22
  124. package/src/core/generator/describe.ts +1 -1
  125. package/src/core/generator/fixtures-builder.ts +332 -0
  126. package/src/core/generator/index.ts +5 -5
  127. package/src/core/generator/openapi-reader.ts +135 -7
  128. package/src/core/generator/path-param-disambig.ts +140 -0
  129. package/src/core/generator/resources-builder.ts +898 -0
  130. package/src/core/generator/schema-utils.ts +33 -3
  131. package/src/core/generator/serializer.ts +103 -13
  132. package/src/core/generator/suite-generator.ts +583 -122
  133. package/src/core/generator/types.ts +14 -0
  134. package/src/core/identity/identity-file.ts +0 -0
  135. package/src/core/lint/affects.ts +28 -0
  136. package/src/core/lint/config.ts +96 -0
  137. package/src/core/lint/format.ts +42 -0
  138. package/src/core/lint/index.ts +94 -0
  139. package/src/core/lint/reporter.ts +128 -0
  140. package/src/core/lint/rules/consistency.ts +158 -0
  141. package/src/core/lint/rules/heuristics.ts +97 -0
  142. package/src/core/lint/rules/strictness.ts +109 -0
  143. package/src/core/lint/types.ts +96 -0
  144. package/src/core/lint/walker.ts +248 -0
  145. package/src/core/meta/meta-store.ts +6 -73
  146. package/src/core/output/README.md +73 -0
  147. package/src/core/output/index.ts +13 -0
  148. package/src/core/output/run.ts +91 -0
  149. package/src/core/output/types.ts +122 -0
  150. package/src/core/parser/dynamic-values.ts +160 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +61 -0
  159. package/src/core/probe/mass-assignment/classify.ts +175 -0
  160. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  161. package/src/core/probe/mass-assignment/digest.ts +114 -0
  162. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  163. package/src/core/probe/mass-assignment/regression.ts +141 -0
  164. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  165. package/src/core/probe/mass-assignment/types.ts +135 -0
  166. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  167. package/src/core/probe/mass-assignment-probe.ts +27 -0
  168. package/src/core/probe/mass-assignment-template.ts +240 -0
  169. package/src/core/probe/method-probe.ts +43 -76
  170. package/src/core/probe/method-shared.ts +69 -0
  171. package/src/core/probe/negative-probe.ts +183 -149
  172. package/src/core/probe/orphan-tracker.ts +188 -0
  173. package/src/core/probe/path-discovery.ts +439 -0
  174. package/src/core/probe/probe-harness.ts +119 -0
  175. package/src/core/probe/registry.ts +89 -0
  176. package/src/core/probe/runner.ts +136 -0
  177. package/src/core/probe/security/baseline.ts +174 -0
  178. package/src/core/probe/security/classify.ts +341 -0
  179. package/src/core/probe/security/cleanup.ts +125 -0
  180. package/src/core/probe/security/detectors.ts +71 -0
  181. package/src/core/probe/security/digest.ts +104 -0
  182. package/src/core/probe/security/orchestrator.ts +398 -0
  183. package/src/core/probe/security/regression.ts +103 -0
  184. package/src/core/probe/security/types.ts +151 -0
  185. package/src/core/probe/security-probe-class.ts +207 -0
  186. package/src/core/probe/security-probe.ts +32 -0
  187. package/src/core/probe/shared.ts +531 -0
  188. package/src/core/probe/static-probe-class.ts +125 -0
  189. package/src/core/probe/types.ts +165 -0
  190. package/src/core/probe/verdict-aggregator.ts +33 -0
  191. package/src/core/probe/webhooks-probe.ts +282 -0
  192. package/src/core/reporter/console.ts +41 -2
  193. package/src/core/reporter/index.ts +2 -3
  194. package/src/core/reporter/json.ts +11 -1
  195. package/src/core/reporter/junit.ts +27 -12
  196. package/src/core/reporter/ndjson.ts +37 -0
  197. package/src/core/reporter/types.ts +3 -0
  198. package/src/core/runner/assertions.ts +59 -2
  199. package/src/core/runner/async-pool.ts +108 -0
  200. package/src/core/runner/auth-path.ts +8 -0
  201. package/src/core/runner/ci-context.ts +72 -0
  202. package/src/core/runner/executor.ts +265 -36
  203. package/src/core/runner/form-encode.ts +41 -0
  204. package/src/core/runner/http-client.ts +112 -2
  205. package/src/core/runner/learn-drift.ts +293 -0
  206. package/src/core/runner/preflight-vars.ts +153 -0
  207. package/src/core/runner/progress-tracker.ts +73 -0
  208. package/src/core/runner/rate-limiter.ts +87 -33
  209. package/src/core/runner/run-kind.ts +45 -0
  210. package/src/core/runner/schema-validator.ts +308 -0
  211. package/src/core/runner/send-request.ts +158 -20
  212. package/src/core/runner/types.ts +44 -0
  213. package/src/core/secrets/registry.ts +164 -0
  214. package/src/core/secrets/secrets-file.ts +115 -0
  215. package/src/core/selectors/operation-filter.ts +144 -0
  216. package/src/core/setup-api.ts +457 -20
  217. package/src/core/severity/category.ts +94 -0
  218. package/src/core/severity/index.ts +58 -0
  219. package/src/core/spec/infer-schema.ts +102 -0
  220. package/src/core/spec/layers.ts +154 -0
  221. package/src/core/spec/merge-specs.ts +156 -0
  222. package/src/core/spec/schema-from-runs.ts +117 -0
  223. package/src/core/spec/schema-overlay.ts +130 -0
  224. package/src/core/util/ajv.ts +13 -0
  225. package/src/core/util/format-eta.ts +21 -0
  226. package/src/core/util/headers.ts +9 -0
  227. package/src/core/util/url.ts +24 -0
  228. package/src/core/utils.ts +5 -1
  229. package/src/core/workspace/config.ts +129 -0
  230. package/src/core/workspace/fixture-gap-report.ts +84 -0
  231. package/src/core/workspace/fixture-gaps.ts +71 -0
  232. package/src/core/workspace/manifest.ts +283 -0
  233. package/src/core/workspace/output-rotation.ts +62 -0
  234. package/src/core/workspace/root.ts +13 -11
  235. package/src/core/workspace/triage-path.ts +87 -0
  236. package/src/db/lint-runs.ts +47 -0
  237. package/src/db/migrate.ts +128 -0
  238. package/src/db/migrations/0001_run_kind.sql +25 -0
  239. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  240. package/src/db/migrations/sql.d.ts +4 -0
  241. package/src/db/queries/collections.ts +133 -0
  242. package/src/db/queries/coverage.ts +9 -0
  243. package/src/db/queries/dashboard.ts +59 -0
  244. package/src/db/queries/results.ts +216 -0
  245. package/src/db/queries/runs.ts +289 -0
  246. package/src/db/queries/sessions.ts +42 -0
  247. package/src/db/queries/settings.ts +28 -0
  248. package/src/db/queries/types.ts +172 -0
  249. package/src/db/queries.ts +75 -802
  250. package/src/db/schema.ts +178 -50
  251. package/src/cli/commands/export.ts +0 -144
  252. package/src/cli/commands/guide.ts +0 -127
  253. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  254. package/src/cli/commands/probe-methods.ts +0 -108
  255. package/src/cli/commands/probe-validation.ts +0 -124
  256. package/src/cli/commands/serve.ts +0 -114
  257. package/src/cli/commands/sync.ts +0 -268
  258. package/src/cli/commands/update.ts +0 -189
  259. package/src/cli/commands/validate.ts +0 -34
  260. package/src/core/diagnostics/render-md.ts +0 -112
  261. package/src/core/exporter/postman.ts +0 -963
  262. package/src/core/generator/guide-builder.ts +0 -253
  263. package/src/core/meta/types.ts +0 -19
  264. package/src/core/parser/index.ts +0 -21
  265. package/src/core/runner/execute-run.ts +0 -132
  266. package/src/core/runner/index.ts +0 -12
  267. package/src/core/sync/spec-differ.ts +0 -38
  268. package/src/web/data/collection-state.ts +0 -362
  269. package/src/web/routes/api.ts +0 -314
  270. package/src/web/routes/dashboard.ts +0 -350
  271. package/src/web/routes/runs.ts +0 -64
  272. package/src/web/schemas.ts +0 -121
  273. package/src/web/server.ts +0 -134
  274. package/src/web/static/htmx.min.cjs +0 -1
  275. package/src/web/static/style.css +0 -1148
  276. package/src/web/views/endpoints-tab.ts +0 -174
  277. package/src/web/views/explorer-tab.ts +0 -402
  278. package/src/web/views/health-strip.ts +0 -92
  279. package/src/web/views/layout.ts +0 -48
  280. package/src/web/views/results.ts +0 -210
  281. package/src/web/views/runs-tab.ts +0 -126
  282. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Unified severity matrix (ARV-250, m-21 pivot).
3
+ *
4
+ * Single source of truth for severity classification across all
5
+ * finding-producing subsystems (lint, checks, probes). Replaces three
6
+ * divergent ladders (lint 3-tier, checks 4-tier, probes 4-tier).
7
+ *
8
+ * Principle: **no evidence — no high severity**. Severity reflects
9
+ * proven impact, not anomaly presence. CRITICAL exists in the type
10
+ * but is reserved for end-to-end exploit chains; producers without
11
+ * such chains must NOT emit it.
12
+ */
13
+
14
+ export type Severity = "critical" | "high" | "medium" | "low" | "info";
15
+
16
+ const SEVERITY_RANK: Record<Severity, number> = {
17
+ critical: 0,
18
+ high: 1,
19
+ medium: 2,
20
+ low: 3,
21
+ info: 4,
22
+ };
23
+
24
+ export function rankSeverity(s: Severity): number {
25
+ return SEVERITY_RANK[s];
26
+ }
27
+
28
+ /**
29
+ * Empty severity-bucket map. Use as starting tally; downstream code
30
+ * increments per finding.
31
+ */
32
+ export function emptySeverityBuckets(): Record<Severity, number> {
33
+ return { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
34
+ }
35
+
36
+ /**
37
+ * SARIF level mapping (sarif.ts previously hardcoded 4-tier; this
38
+ * keeps the same external semantics + adds 'info' → 'note').
39
+ */
40
+ export function severityToSarifLevel(s: Severity): "error" | "warning" | "note" {
41
+ if (s === "critical" || s === "high") return "error";
42
+ if (s === "medium") return "warning";
43
+ return "note"; // low + info
44
+ }
45
+
46
+ /**
47
+ * Console glyph for severity. Stable per-glyph keeps fb-loop diff
48
+ * compares clean.
49
+ */
50
+ export function severityGlyph(s: Severity): string {
51
+ switch (s) {
52
+ case "critical": return "🚨";
53
+ case "high": return "🔴";
54
+ case "medium": return "⚠️";
55
+ case "low": return "ℹ️";
56
+ case "info": return "·";
57
+ }
58
+ }
@@ -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,154 @@
1
+ /**
2
+ * ARV-122 (m-19, blocker-m-18): layered spec model.
3
+ *
4
+ * Today two artifact sources contribute to the resource map under
5
+ * `apis/<name>/`:
6
+ * 1. `.api-resources.yaml` — sha-tracked, regenerated by
7
+ * `refresh-api` from `spec.json` (upstream OpenAPI).
8
+ * 2. `.api-resources.local.yaml` — ARV-111 user extension that
9
+ * survives `refresh-api` (write-only endpoints, common SaaS-style
10
+ * ingest, …).
11
+ *
12
+ * `readResourceMap` in `cli/commands/discover.ts` merges them by hand:
13
+ * extensions win on `resource` name collision. m-18 (quicktype-derived
14
+ * and mitmproxy-derived schema layers) will add two more sources with
15
+ * different precedence and merge semantics — doing that ad-hoc would
16
+ * end the way ad-hoc output-format parsing did (ARV-97 family,
17
+ * resolved by m-19/ARV-116).
18
+ *
19
+ * This module is the same move for spec sources: declare each source
20
+ * once as a `SpecLayer`, with precedence and merge policy, and feed
21
+ * them through `composeSpec()`. The result is a `ComposedSpec` plus a
22
+ * `ProvenanceMap` (`resource → layer.id`) that downstream code can
23
+ * consult to explain *where* a field came from (m-18 follow-ups will
24
+ * surface this in `doctor` / `catalog --provenance`; this task only
25
+ * ships the internal API).
26
+ *
27
+ * Scope of this task: ONLY the resource-map dimension (the actual
28
+ * `ResourceYaml` shape used by every other module). Other dimensions
29
+ * (raw OpenAPI document, fixture manifest) keep their own loaders —
30
+ * generalising further before there's a second consumer would be
31
+ * premature.
32
+ */
33
+
34
+ /** How a layer's entries combine with already-resolved entries. */
35
+ export type MergePolicy =
36
+ /** Layer entries replace lower-precedence entries on key collision. */
37
+ | "override"
38
+ /** Layer entries are dropped if a lower-precedence entry already
39
+ * owns the key. (Useful for "default" layers that fill gaps but
40
+ * shouldn't shadow user input.) */
41
+ | "preserve"
42
+ /** Layer entries are appended even when a key already exists,
43
+ * producing duplicate keys. Reserved for future fixture layers;
44
+ * not used by the current two-layer resource composition. */
45
+ | "append";
46
+
47
+ /** Domain a layer contributes to. Today only `resources` is wired —
48
+ * m-18 will likely add `fixtures` (manifest merge) and `spec`
49
+ * (raw OpenAPI augmentation). Listed up front so layer factories
50
+ * carry the tag at construction. */
51
+ export type LayerScope = "resources" | "fixtures" | "spec";
52
+
53
+ /** A single typed source of spec data. */
54
+ export interface SpecLayer<T> {
55
+ /** Stable identifier surfaced in the provenance map (e.g.,
56
+ * `"upstream"`, `"extension"`, `"quicktype"`). Must be unique
57
+ * inside one `composeSpec` call. */
58
+ id: string;
59
+ /** Source path/URL for diagnostics. Optional — synthetic layers
60
+ * (in-memory test fixtures) can omit. */
61
+ path?: string;
62
+ /** Higher precedence wins on key collisions when `mergePolicy` is
63
+ * `"override"`. Ties: layer ordering in the input array. */
64
+ precedence: number;
65
+ /** Which composition this layer participates in. `composeSpec` does
66
+ * not currently route on scope — it expects callers to filter — but
67
+ * the field makes layer arrays self-describing. */
68
+ scope: LayerScope;
69
+ /** How this layer's entries combine with lower-precedence ones. */
70
+ mergePolicy: MergePolicy;
71
+ /** Load the layer's entries. Async to accommodate filesystem and
72
+ * network sources (an HTTP-fetched OpenAPI snapshot in the future
73
+ * could implement this directly). */
74
+ load: () => Promise<T[]> | T[];
75
+ }
76
+
77
+ /** Function deriving the merge key for an entry. Pulled out so callers
78
+ * can compose resource lists (key = resource name) and, later, fixture
79
+ * lists (key = var name) with the same engine. */
80
+ export type KeyFn<T> = (entry: T) => string;
81
+
82
+ /** Output of `composeSpec`. `entries` is the merged list in stable
83
+ * order (lowest precedence first, with higher-precedence overrides
84
+ * applied in-place); `provenance` maps the merged key back to the
85
+ * layer id that ultimately owns it. */
86
+ export interface ComposedSpec<T> {
87
+ entries: T[];
88
+ provenance: Map<string, string>;
89
+ }
90
+
91
+ /**
92
+ * Compose layers into a single entry list + provenance map.
93
+ *
94
+ * Order of operations:
95
+ * 1. Layers are sorted by ascending `precedence` (low → high). A
96
+ * stable sort is used so ties keep input order.
97
+ * 2. Each layer's entries are loaded in sequence (the loader is
98
+ * async-safe so a slow loader can't block parallel ones — but
99
+ * we keep ordering deterministic by awaiting in turn).
100
+ * 3. Entries are folded into the result Map keyed by `keyFn(entry)`:
101
+ * - `mergePolicy === "override"` → replace; provenance updates.
102
+ * - `mergePolicy === "preserve"` → only set if the key is new.
103
+ * - `mergePolicy === "append"` → key suffixed with `#<n>` to
104
+ * avoid collision; provenance still recorded.
105
+ *
106
+ * The result preserves insertion order of the underlying Map, which
107
+ * is the natural "lowest-precedence-first" view: callers that need a
108
+ * different ordering re-sort downstream.
109
+ */
110
+ export async function composeSpec<T>(
111
+ layers: SpecLayer<T>[],
112
+ keyFn: KeyFn<T>,
113
+ ): Promise<ComposedSpec<T>> {
114
+ const ordered = [...layers].sort((a, b) => a.precedence - b.precedence);
115
+
116
+ const seenIds = new Set<string>();
117
+ for (const l of ordered) {
118
+ if (seenIds.has(l.id)) {
119
+ throw new Error(`composeSpec: duplicate layer id "${l.id}"`);
120
+ }
121
+ seenIds.add(l.id);
122
+ }
123
+
124
+ const merged = new Map<string, T>();
125
+ const provenance = new Map<string, string>();
126
+ let appendCounter = 0;
127
+
128
+ for (const layer of ordered) {
129
+ const entries = await layer.load();
130
+ for (const entry of entries) {
131
+ const key = keyFn(entry);
132
+ switch (layer.mergePolicy) {
133
+ case "override":
134
+ merged.set(key, entry);
135
+ provenance.set(key, layer.id);
136
+ break;
137
+ case "preserve":
138
+ if (!merged.has(key)) {
139
+ merged.set(key, entry);
140
+ provenance.set(key, layer.id);
141
+ }
142
+ break;
143
+ case "append": {
144
+ const tagged = `${key}#${appendCounter++}`;
145
+ merged.set(tagged, entry);
146
+ provenance.set(tagged, layer.id);
147
+ break;
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ return { entries: Array.from(merged.values()), provenance };
154
+ }
@@ -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
+ }