@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
@@ -2,9 +2,11 @@ export interface RateLimiter {
2
2
  acquire(): Promise<void>;
3
3
  /**
4
4
  * Feed rate-limit metadata from the latest response back into the limiter.
5
- * When `remaining` falls at or below the threshold, the limiter postpones
6
- * the next acquire until the API's reset window expires. No-op when the
7
- * server reports plenty of headroom.
5
+ * Two effects: (1) when `remaining` falls at or below the threshold, the
6
+ * limiter postpones the next acquire until the API's reset window expires;
7
+ * (2) when the response carries a `RateLimit-Policy` (RFC 9568), the
8
+ * limiter learns the per-request spacing so subsequent parallel acquires
9
+ * are forced into single-file at burst=1.
8
10
  *
9
11
  * Optional so existing callers / mocks need not implement it.
10
12
  */
@@ -16,18 +18,37 @@ export interface RateLimitMeta {
16
18
  remaining?: number;
17
19
  /** Either seconds-until-reset (RFC draft) or a Unix epoch in seconds (GitHub style). */
18
20
  reset?: number;
19
- /** Window cap; used only for diagnostics. */
21
+ /** Window cap; used for diagnostics and spacing fallback. */
20
22
  limit?: number;
23
+ /**
24
+ * Minimum spacing between requests in ms — derived from `RateLimit-Policy:
25
+ * N;w=W` (RFC 9568). When set, the limiter raises its own interval to at
26
+ * least this value so a burst of parallel requests is paced one-by-one
27
+ * instead of overshooting the window.
28
+ */
29
+ intervalMs?: number;
21
30
  }
22
31
 
23
- /** When `remaining` is at or below this number we proactively pause until reset. */
24
- const THROTTLE_THRESHOLD = 5;
32
+ /**
33
+ * When `remaining` is at or below this number we proactively pause until reset.
34
+ * Conservative threshold — at 2, we still have buffer for one in-flight retry
35
+ * (if we paused at 5 we'd over-throttle on small windows like 5-req/1-sec
36
+ * policies, where every request would trigger a sleep).
37
+ */
38
+ const THROTTLE_THRESHOLD = 2;
39
+
40
+ /**
41
+ * Padding added to policy-derived spacing to absorb clock drift between the
42
+ * server's window and our local Date.now() (TASK-88). Without this, spacing
43
+ * exactly at `W/N` ms can still hit the window boundary on the wrong side.
44
+ */
45
+ const POLICY_SAFETY_MS = 50;
25
46
 
26
47
  /** Magnitudes above this are treated as Unix timestamps; below as relative
27
48
  * seconds. 10^9 seconds ≈ Sep 2001, so any real reset window is far below. */
28
49
  const UNIX_TS_BOUNDARY = 1_000_000_000;
29
50
 
30
- function applyMeta(prevNextAvailable: number, meta: RateLimitMeta, now: number): number {
51
+ function applyResetPause(prevNextAvailable: number, meta: RateLimitMeta, now: number): number {
31
52
  if (meta.remaining === undefined) return prevNextAvailable;
32
53
  if (meta.remaining > THROTTLE_THRESHOLD) return prevNextAvailable;
33
54
  if (meta.reset === undefined || !Number.isFinite(meta.reset)) return prevNextAvailable;
@@ -37,13 +58,19 @@ function applyMeta(prevNextAvailable: number, meta: RateLimitMeta, now: number):
37
58
 
38
59
  class IntervalRateLimiter implements RateLimiter {
39
60
  private nextAvailable = 0;
40
- private readonly intervalMs: number;
61
+ /** 0 means adaptive: no proactive throttling until a policy is learned
62
+ * from a response (matches the original AdaptiveRateLimiter behaviour). */
63
+ private intervalMs: number;
41
64
 
42
- constructor(reqPerSec: number) {
43
- if (!Number.isFinite(reqPerSec) || reqPerSec <= 0) {
44
- throw new Error(`Invalid rate limit: ${reqPerSec}`);
65
+ constructor(reqPerSec?: number) {
66
+ if (reqPerSec === undefined) {
67
+ this.intervalMs = 0;
68
+ } else {
69
+ if (!Number.isFinite(reqPerSec) || reqPerSec <= 0) {
70
+ throw new Error(`Invalid rate limit: ${reqPerSec}`);
71
+ }
72
+ this.intervalMs = 1000 / reqPerSec;
45
73
  }
46
- this.intervalMs = 1000 / reqPerSec;
47
74
  }
48
75
 
49
76
  async acquire(): Promise<void> {
@@ -57,21 +84,14 @@ class IntervalRateLimiter implements RateLimiter {
57
84
  }
58
85
 
59
86
  note(meta: RateLimitMeta, now: number = Date.now()): void {
60
- this.nextAvailable = applyMeta(this.nextAvailable, meta, now);
61
- }
62
- }
63
-
64
- class AdaptiveRateLimiter implements RateLimiter {
65
- private nextAvailable = 0;
66
-
67
- async acquire(): Promise<void> {
68
- const now = Date.now();
69
- const wait = this.nextAvailable - now;
70
- if (wait > 0) await Bun.sleep(wait);
71
- }
72
-
73
- note(meta: RateLimitMeta, now: number = Date.now()): void {
74
- this.nextAvailable = applyMeta(this.nextAvailable, meta, now);
87
+ if (meta.intervalMs !== undefined && meta.intervalMs > this.intervalMs) {
88
+ // Already-reserved slots were spaced at the OLD interval; push
89
+ // nextAvailable forward by the delta so the new spacing kicks in
90
+ // immediately rather than only on the next-next request.
91
+ this.nextAvailable += meta.intervalMs - this.intervalMs;
92
+ this.intervalMs = meta.intervalMs;
93
+ }
94
+ this.nextAvailable = applyResetPause(this.nextAvailable, meta, now);
75
95
  }
76
96
  }
77
97
 
@@ -83,17 +103,24 @@ export function createRateLimiter(reqPerSec: number | undefined): RateLimiter |
83
103
 
84
104
  /**
85
105
  * Adaptive limiter for `--rate-limit auto`. Issues no proactive throttling on
86
- * its own, but reacts to ratelimit-* response headers via `note()` and pauses
87
- * the request stream until the API's reset window elapses when headroom drops.
106
+ * its own initially, but reacts to ratelimit-* response headers via `note()`:
107
+ * (a) pauses the request stream until the API's reset window elapses when
108
+ * remaining headroom drops; (b) once a `RateLimit-Policy` is seen, paces
109
+ * subsequent requests at the policy's `W/N` spacing — this prevents bursts
110
+ * from blowing through small windows (e.g. 5-req/1-sec policies).
88
111
  */
89
112
  export function createAdaptiveRateLimiter(): RateLimiter {
90
- return new AdaptiveRateLimiter();
113
+ return new IntervalRateLimiter();
91
114
  }
92
115
 
93
116
  /**
94
- * Read RFC draft-ietf-httpapi-ratelimit-headers (`ratelimit-*`) plus the
95
- * GitHub / Stripe style `x-ratelimit-*` aliases out of a response header bag.
96
- * All keys are matched case-insensitively. Unparseable values are dropped.
117
+ * Read RFC 9568 `ratelimit-*` headers (was draft-ietf-httpapi-ratelimit-headers)
118
+ * plus the GitHub / Stripe style `x-ratelimit-*` aliases out of a response
119
+ * header bag. All keys are matched case-insensitively. Unparseable values are
120
+ * dropped.
121
+ *
122
+ * `RateLimit-Policy: N;w=W` is parsed into a per-request `intervalMs` of
123
+ * `(W/N)*1000 + POLICY_SAFETY_MS` so the limiter can pace bursts.
97
124
  */
98
125
  export function parseRateLimitHeaders(headers: Record<string, string>): RateLimitMeta {
99
126
  const lower: Record<string, string> = {};
@@ -107,13 +134,40 @@ export function parseRateLimitHeaders(headers: Record<string, string>): RateLimi
107
134
  const n = Number.parseFloat(match[0]);
108
135
  return Number.isFinite(n) ? n : undefined;
109
136
  };
137
+ const policy = lower["ratelimit-policy"] ?? lower["x-ratelimit-policy"];
138
+ const intervalMs = derivePolicyIntervalMs(policy);
110
139
  return {
111
140
  limit: num(lower["ratelimit-limit"] ?? lower["x-ratelimit-limit"]),
112
141
  remaining: num(lower["ratelimit-remaining"] ?? lower["x-ratelimit-remaining"]),
113
142
  reset: num(lower["ratelimit-reset"] ?? lower["x-ratelimit-reset"]),
143
+ intervalMs,
114
144
  };
115
145
  }
116
146
 
147
+ /**
148
+ * Parse `RateLimit-Policy: 5;w=1` (or comma-separated multi-policy — we honour
149
+ * the *strictest* one). Returns the implied per-request interval in ms,
150
+ * including a small safety margin. Returns undefined when the header is
151
+ * malformed or missing.
152
+ */
153
+ function derivePolicyIntervalMs(policy: string | undefined): number | undefined {
154
+ if (!policy) return undefined;
155
+ let strictest: number | undefined;
156
+ for (const item of policy.split(",")) {
157
+ const parts = item.trim().split(";").map(s => s.trim()).filter(Boolean);
158
+ if (parts.length === 0) continue;
159
+ const limit = Number.parseFloat(parts[0]!);
160
+ if (!Number.isFinite(limit) || limit <= 0) continue;
161
+ const wPart = parts.find(p => p.startsWith("w="));
162
+ if (!wPart) continue;
163
+ const window = Number.parseFloat(wPart.slice(2));
164
+ if (!Number.isFinite(window) || window <= 0) continue;
165
+ const interval = (window / limit) * 1000 + POLICY_SAFETY_MS;
166
+ if (strictest === undefined || interval > strictest) strictest = interval;
167
+ }
168
+ return strictest;
169
+ }
170
+
117
171
  export function parseRetryAfter(header: string | null | undefined, now: number = Date.now()): number | undefined {
118
172
  if (!header) return undefined;
119
173
  const trimmed = header.trim();
@@ -0,0 +1,45 @@
1
+ /**
2
+ * ARV-55: single source of truth for classifying a run by *what kind of
3
+ * suites it executed*. Before this module the answer was inferred from
4
+ * `suite_file` paths in three places (coverage's `isProbeOnlyRun`,
5
+ * `db diagnose` recommendation branching, and a handful of skill prompts).
6
+ *
7
+ * We resolve the kind once at INSERT-time and persist it in `runs.run_kind`;
8
+ * downstream filters become a column compare instead of a per-result regex.
9
+ *
10
+ * Encoding:
11
+ * - `probe` — every suite path lives under `apis/<api>/probes/` (or a
12
+ * bare `probes/` segment for ad-hoc setups). Coverage hides
13
+ * these by default — probe runs deliberately exercise a
14
+ * subset of endpoints and would otherwise read as a
15
+ * regression vs the prior smoke/CRUD run.
16
+ * - `check` — every suite path lives under `apis/<api>/checks/`. Mirrors
17
+ * the same logic: conformance checks don't reflect endpoint
18
+ * coverage breadth.
19
+ * - `request` — a single ad-hoc `zond request` invocation persisted into
20
+ * the session DB (ARV-265). Excluded from `pass-coverage`
21
+ * but counted toward `audit-coverage`.
22
+ * - `fixture` — `prepare-fixtures --cascade` discovery list-calls
23
+ * (ARV-265). Pure HTTP touches in service of fixture
24
+ * derivation; audit-coverage only.
25
+ * - `regular` — anything else, including mixed runs (probe + smoke). A
26
+ * mixed run is treated as regular because at least one
27
+ * suite contributed real coverage signal.
28
+ */
29
+
30
+ export type RunKind = "regular" | "probe" | "check" | "request" | "fixture";
31
+
32
+ const PROBE_SEGMENT_RE = /(^|\/)probes(\/|$)/;
33
+ const CHECK_SEGMENT_RE = /(^|\/)checks(\/|$)/;
34
+
35
+ export function detectRunKind(suiteFiles: ReadonlyArray<string | null | undefined>): RunKind {
36
+ // Empty / all-empty arrays default to 'regular' — the DB CHECK constraint
37
+ // refuses NULL so callers always receive a concrete kind.
38
+ const paths = suiteFiles
39
+ .filter((p): p is string => typeof p === "string" && p.length > 0);
40
+ if (paths.length === 0) return "regular";
41
+
42
+ if (paths.every((p) => PROBE_SEGMENT_RE.test(p))) return "probe";
43
+ if (paths.every((p) => CHECK_SEGMENT_RE.test(p))) return "check";
44
+ return "regular";
45
+ }
@@ -0,0 +1,308 @@
1
+ import Ajv from "ajv";
2
+ import type { ErrorObject, ValidateFunction, AnySchema } from "ajv";
3
+ import type { OpenAPIV3 } from "openapi-types";
4
+ import { makeAjv } from "../util/ajv.ts";
5
+ import { specPathToRegex } from "../generator/coverage-scanner.ts";
6
+ import type { AssertionResult } from "./types.ts";
7
+
8
+ export interface SchemaValidator {
9
+ validate(method: string, path: string, status: number, body: unknown): AssertionResult[];
10
+ /** TASK-142: surface whether an endpoint and a response branch matched.
11
+ * Lets ad-hoc callers (`zond request --validate-schema`) distinguish
12
+ * "no spec entry for this URL" from "spec entry exists, body is valid". */
13
+ inspect(method: string, path: string, status: number): {
14
+ matchedEndpoint: { method: string; path: string } | null;
15
+ matchedResponseStatus: string | null;
16
+ hasJsonSchema: boolean;
17
+ };
18
+ }
19
+
20
+ interface EndpointEntry {
21
+ method: string;
22
+ path: string;
23
+ regex: RegExp;
24
+ responses: OpenAPIV3.ResponsesObject;
25
+ }
26
+
27
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const;
28
+
29
+ export function createSchemaValidator(doc: OpenAPIV3.Document): SchemaValidator {
30
+ const isV31 = typeof doc.openapi === "string" && doc.openapi.startsWith("3.1");
31
+ // OpenAPI 3.1 → JSON Schema Draft 2020-12; 3.0 → Draft 4/7-ish.
32
+ // verbose:true exposes parentSchema on each error so humanize() can render
33
+ // the full required-set alongside the missing field name (TASK-277).
34
+ const ajv = makeAjv(isV31, { strict: false, allErrors: true, verbose: true });
35
+ applyStrictFormats(ajv);
36
+
37
+ const endpoints: EndpointEntry[] = [];
38
+ if (doc.paths) {
39
+ for (const [pathTpl, pathItem] of Object.entries(doc.paths)) {
40
+ if (!pathItem) continue;
41
+ const regex = specPathToRegex(pathTpl);
42
+ for (const m of HTTP_METHODS) {
43
+ const op = (pathItem as Record<string, unknown>)[m] as OpenAPIV3.OperationObject | undefined;
44
+ if (!op || !op.responses) continue;
45
+ endpoints.push({ method: m.toUpperCase(), path: pathTpl, regex, responses: op.responses });
46
+ }
47
+ }
48
+ // Sort by specificity so concrete paths (e.g. /users/me) win over templated
49
+ // ones (/users/{id}) regardless of spec declaration order. Tie-breaker is
50
+ // stable insertion order (Array.sort is stable in modern engines).
51
+ endpoints.sort((a, b) => paramCount(a.path) - paramCount(b.path));
52
+ }
53
+
54
+ // ARV-214: per-schema compile is the dominant cost of --validate-schema
55
+ // on big dereferenced specs (github 14 MiB → minutes per response).
56
+ // The cache below is keyed by schema *object reference*, so endpoints
57
+ // that share a $ref source after dereference hit it on the second
58
+ // access. The slow_compile budget warns the user the first time a
59
+ // schema crosses 1s — the same compile only happens once per schema
60
+ // anyway thanks to the cache, so the warning is per-source, not
61
+ // per-call.
62
+ const SLOW_COMPILE_MS = Number(process.env.ZOND_VALIDATE_SCHEMA_SLOW_COMPILE_MS ?? "1000");
63
+ // Hard byte cap stops the run from hanging minutes on a pathological
64
+ // single response schema (post-deref github Repository / kubernetes
65
+ // pod-spec). Returning a no-op validator + a single warning is much
66
+ // friendlier than blocking on `ajv.compile` for an unbounded time.
67
+ // Default chosen from the bench in /tmp/bench-convert.ts: a 572 KiB
68
+ // schema compiles in ~800 ms; 1 MiB ≈ 1.4 s; beyond that we'd rather
69
+ // skip and tell the user.
70
+ const MAX_SCHEMA_BYTES = Number(process.env.ZOND_VALIDATE_SCHEMA_MAX_BYTES ?? String(1_048_576));
71
+ const compiled = new Map<unknown, ValidateFunction | null>();
72
+ const warnedSlow = new WeakSet<object>();
73
+ const warnedTooLarge = new WeakSet<object>();
74
+
75
+ function tooLargeSentinel(): null {
76
+ return null;
77
+ }
78
+
79
+ function compile(schema: AnySchema): ValidateFunction | null {
80
+ if (compiled.has(schema)) return compiled.get(schema) ?? null;
81
+ // Cheap pre-check: a schema whose JSON serialisation exceeds
82
+ // MAX_SCHEMA_BYTES is almost certainly going to take seconds-to-
83
+ // minutes to compile and produce noisy mid-body errors that aren't
84
+ // worth the wait. Skip it with a warning. JSON.stringify itself is
85
+ // O(n) but at MiB-scale completes in tens of ms — orders of
86
+ // magnitude cheaper than ajv.compile on the same payload.
87
+ if (MAX_SCHEMA_BYTES > 0) {
88
+ try {
89
+ const sz = JSON.stringify(schema)?.length ?? 0;
90
+ if (sz > MAX_SCHEMA_BYTES) {
91
+ if (typeof schema === "object" && schema && !warnedTooLarge.has(schema as object)) {
92
+ warnedTooLarge.add(schema as object);
93
+ const kib = Math.round(sz / 1024);
94
+ process.stderr.write(
95
+ `[zond] schema too large for --validate-schema (${kib} KiB > ${Math.round(MAX_SCHEMA_BYTES / 1024)} KiB) — skipping; raise via ZOND_VALIDATE_SCHEMA_MAX_BYTES.\n`,
96
+ );
97
+ }
98
+ compiled.set(schema, tooLargeSentinel());
99
+ return null;
100
+ }
101
+ } catch { /* circular or non-serialisable — let ajv try */ }
102
+ }
103
+ const prepared = isV31 ? schema : convertOpenApi30(schema);
104
+ const t0 = performance.now();
105
+ const fn = ajv.compile(prepared as AnySchema);
106
+ const elapsed = performance.now() - t0;
107
+ if (elapsed >= SLOW_COMPILE_MS && typeof schema === "object" && schema && !warnedSlow.has(schema as object)) {
108
+ warnedSlow.add(schema as object);
109
+ process.stderr.write(
110
+ `[zond] schema validator compile took ${elapsed.toFixed(0)} ms (warn ≥ ${SLOW_COMPILE_MS} ms) — see ZOND_VALIDATE_SCHEMA_MAX_BYTES if --validate-schema runs feel slow.\n`,
111
+ );
112
+ }
113
+ compiled.set(schema, fn);
114
+ return fn;
115
+ }
116
+
117
+ function findResponseSchema(method: string, path: string, status: number): OpenAPIV3.SchemaObject | undefined {
118
+ const upper = method.toUpperCase();
119
+ // Endpoints are pre-sorted by specificity (concrete paths first), so the
120
+ // first regex match is the most specific — /users/me beats /users/{id}.
121
+ const match = endpoints.find(e => e.method === upper && e.regex.test(path));
122
+ if (!match) return undefined;
123
+ const responses = match.responses;
124
+ const exact = responses[String(status)] as OpenAPIV3.ResponseObject | undefined;
125
+ const wildcard = responses[`${Math.floor(status / 100)}XX`] as OpenAPIV3.ResponseObject | undefined;
126
+ const fallback = responses.default as OpenAPIV3.ResponseObject | undefined;
127
+ const response = exact ?? wildcard ?? fallback;
128
+ if (!response || !response.content) return undefined;
129
+ const json = response.content["application/json"];
130
+ return (json?.schema as OpenAPIV3.SchemaObject | undefined) ?? undefined;
131
+ }
132
+
133
+ function inspectMatch(method: string, path: string, status: number) {
134
+ const upper = method.toUpperCase();
135
+ const ep = endpoints.find(e => e.method === upper && e.regex.test(path));
136
+ if (!ep) {
137
+ return { matchedEndpoint: null, matchedResponseStatus: null, hasJsonSchema: false };
138
+ }
139
+ const exact = ep.responses[String(status)] as OpenAPIV3.ResponseObject | undefined;
140
+ const wildcard = ep.responses[`${Math.floor(status / 100)}XX`] as OpenAPIV3.ResponseObject | undefined;
141
+ const fallback = ep.responses.default as OpenAPIV3.ResponseObject | undefined;
142
+ const matchedKey = exact ? String(status) : wildcard ? `${Math.floor(status / 100)}XX` : fallback ? "default" : null;
143
+ const response = exact ?? wildcard ?? fallback;
144
+ const hasJsonSchema = !!(response?.content?.["application/json"]?.schema);
145
+ return {
146
+ matchedEndpoint: { method: ep.method, path: ep.path },
147
+ matchedResponseStatus: matchedKey,
148
+ hasJsonSchema,
149
+ };
150
+ }
151
+
152
+ return {
153
+ validate(method, path, status, body) {
154
+ const schema = findResponseSchema(method, path, status);
155
+ if (!schema) return [];
156
+ let validator: ValidateFunction | null;
157
+ try {
158
+ validator = compile(schema);
159
+ } catch (err) {
160
+ return [{
161
+ field: "body",
162
+ rule: "schema.compile_error",
163
+ passed: false,
164
+ actual: undefined,
165
+ expected: err instanceof Error ? err.message : String(err),
166
+ }];
167
+ }
168
+ // ARV-214: schema crossed ZOND_VALIDATE_SCHEMA_MAX_BYTES — surface
169
+ // the skip as a passing assertion so the run stays green and the
170
+ // user knows validation was bypassed for this body.
171
+ if (validator === null) {
172
+ return [{
173
+ field: "body",
174
+ rule: "schema.skipped_too_large",
175
+ passed: true,
176
+ actual: undefined,
177
+ expected: "schema exceeded ZOND_VALIDATE_SCHEMA_MAX_BYTES — see stderr warning",
178
+ kind: "schema",
179
+ }];
180
+ }
181
+ const ok = validator(body);
182
+ if (ok) return [];
183
+ const errors = validator.errors ?? [];
184
+ return errors.map(e => ajvErrorToAssertion(e, body));
185
+ },
186
+ inspect: inspectMatch,
187
+ };
188
+ }
189
+
190
+ function paramCount(path: string): number {
191
+ let n = 0;
192
+ for (const seg of path.split("/")) if (/^\{[^}]+\}$/.test(seg)) n++;
193
+ return n;
194
+ }
195
+
196
+ function ajvErrorToAssertion(err: ErrorObject, body: unknown): AssertionResult {
197
+ const ptr = err.instancePath || "";
198
+ // Field key like "body" or "body.user.email" for parity with checkAssertions.
199
+ const field = ptr ? `body${ptr.replace(/\//g, ".")}` : "body";
200
+ const actual = ptr ? getByJsonPointer(body, ptr) : body;
201
+ return {
202
+ field,
203
+ rule: `schema.${err.keyword}`,
204
+ passed: false,
205
+ actual,
206
+ expected: humanize(err),
207
+ kind: "schema",
208
+ };
209
+ }
210
+
211
+ function humanize(err: ErrorObject): string {
212
+ switch (err.keyword) {
213
+ case "required": {
214
+ const missing = (err.params as { missingProperty: string }).missingProperty;
215
+ // verbose:true gives us the parent schema; surface the full required-set
216
+ // so the user can see drift at a glance instead of decoding the message
217
+ // one missing-field-error at a time (TASK-277).
218
+ const parent = (err as ErrorObject & { parentSchema?: unknown }).parentSchema;
219
+ const required =
220
+ parent && typeof parent === "object" && Array.isArray((parent as { required?: unknown }).required)
221
+ ? ((parent as { required: string[] }).required)
222
+ : undefined;
223
+ const tail = required ? `; expected required: [${required.join(", ")}]` : "";
224
+ return `missing required field "${missing}"${tail}`;
225
+ }
226
+ case "type":
227
+ return `type ${(err.params as { type: string | string[] }).type}`;
228
+ case "enum":
229
+ return `one of ${JSON.stringify((err.params as { allowedValues: unknown[] }).allowedValues)}`;
230
+ case "format":
231
+ return `format "${(err.params as { format: string }).format}"`;
232
+ case "additionalProperties":
233
+ return `no additional property "${(err.params as { additionalProperty: string }).additionalProperty}"`;
234
+ case "const":
235
+ return `const ${JSON.stringify((err.params as { allowedValue: unknown }).allowedValue)}`;
236
+ case "minLength":
237
+ case "maxLength":
238
+ case "minimum":
239
+ case "maximum":
240
+ case "exclusiveMinimum":
241
+ case "exclusiveMaximum":
242
+ case "multipleOf":
243
+ case "pattern":
244
+ return `${err.keyword} ${JSON.stringify((err.params as Record<string, unknown>)[err.keyword] ?? "")}`.trim();
245
+ default:
246
+ return err.message ?? err.keyword;
247
+ }
248
+ }
249
+
250
+ function getByJsonPointer(obj: unknown, pointer: string): unknown {
251
+ if (!pointer) return obj;
252
+ const segments = pointer.split("/").slice(1).map(s => s.replace(/~1/g, "/").replace(/~0/g, "~"));
253
+ let cur: unknown = obj;
254
+ for (const seg of segments) {
255
+ if (cur && typeof cur === "object") {
256
+ cur = (cur as Record<string, unknown>)[seg];
257
+ } else {
258
+ return undefined;
259
+ }
260
+ }
261
+ return cur;
262
+ }
263
+
264
+ // RFC3339 §5.6: date-time = full-date "T" full-time. T (or t) is required as
265
+ // separator; offset is "Z" or "[+-]HH:MM" with explicit colon. ajv-formats
266
+ // accepts " " as separator and "+HH" without colon, which lets PostgreSQL-style
267
+ // timestamps ("2026-04-29 07:10:44.674675+00") slip through.
268
+ export const STRICT_RFC3339_DATE_TIME =
269
+ /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])[Tt](?:[01]\d|2[0-3]):[0-5]\d:(?:[0-5]\d|60)(?:\.\d+)?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
270
+
271
+ function applyStrictFormats(ajv: Ajv): void {
272
+ // Override ajv-formats' lax date-time with strict RFC3339.
273
+ ajv.addFormat("date-time", { type: "string", validate: STRICT_RFC3339_DATE_TIME });
274
+ }
275
+
276
+ /**
277
+ * Convert OpenAPI 3.0 schema to JSON Schema Draft 7-compatible:
278
+ * - `nullable: true` → add "null" to `type`.
279
+ * - Drop unsupported `example`, `xml`, `discriminator` keywords (ajv tolerates with strict:false).
280
+ */
281
+ function convertOpenApi30(schema: AnySchema): AnySchema {
282
+ if (!schema || typeof schema !== "object") return schema;
283
+ if (Array.isArray(schema)) {
284
+ return schema.map(s => convertOpenApi30(s as AnySchema)) as unknown as AnySchema;
285
+ }
286
+ const src = schema as Record<string, unknown>;
287
+ const out: Record<string, unknown> = {};
288
+ for (const [k, v] of Object.entries(src)) {
289
+ if (k === "nullable") continue;
290
+ if (k === "type" && src.nullable === true) {
291
+ if (Array.isArray(v)) {
292
+ out.type = [...v as unknown[], "null"];
293
+ } else if (typeof v === "string") {
294
+ out.type = [v, "null"];
295
+ } else {
296
+ out.type = v;
297
+ }
298
+ continue;
299
+ }
300
+ if (v && typeof v === "object") {
301
+ out[k] = convertOpenApi30(v as AnySchema);
302
+ } else {
303
+ out[k] = v;
304
+ }
305
+ }
306
+ // Standalone nullable: true with no explicit type → leave as-is (ajv accepts any).
307
+ return out as AnySchema;
308
+ }