@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,416 @@
1
+ /**
2
+ * `lifecycle_transitions` (m-20 ARV-172, ARV-219 added observation in m-21)
3
+ * — verify a resource's declared state machine on the live API.
4
+ *
5
+ * Two modes, gated on the `actions` field of the yaml manifest:
6
+ *
7
+ * ── Action-driven mode (preferred when CRUD allows mutation) ───────
8
+ * Requires `g.create && g.read`. For each declared `action`:
9
+ * 1. POST create → capture id + initial state.
10
+ * 2. Assert initial state ∈ declared `states[]`.
11
+ * 3. For each action in object-key order:
12
+ * a. POST <action.endpoint> with {id} substituted.
13
+ * b. GET resource → read `state.field`.
14
+ * c. Assert observed state == `action.expected_state` and the
15
+ * (previous, observed) hop is in `transitions`.
16
+ * d. POST action a second time (idempotency probe); state must
17
+ * not regress.
18
+ *
19
+ * ── Pure-observation mode (ARV-219, for read-only state machines) ──
20
+ * Triggers when `actions: {}` (or omitted). Requires `g.list`.
21
+ * GET list once, walk items, collect any state values not in
22
+ * `states[]`. Surfaces a single finding listing each undeclared
23
+ * state with sample item ids. Useful for APIs where zond cannot
24
+ * POST (read-only PAT, GitHub Issues without write scope, …) but
25
+ * the spec still declares a status enum — drift between observed
26
+ * and declared states is a contract-doc bug.
27
+ *
28
+ * Severity: HIGH. Failure classes share one finding (consistent with
29
+ * cross_call_references / idempotency_replay / pagination_invariants);
30
+ * evidence.kind discriminates.
31
+ *
32
+ * Anti-FP guards (both modes):
33
+ * • Yaml manifest validated at load (validateLifecycleManifest in
34
+ * resources-builder); a malformed manifest skips with the
35
+ * concrete error so the operator gets actionable feedback.
36
+ * • Non-2xx baseline (create or list) → broken-baseline skip.
37
+ * • Action POST non-2xx on first call → action-not-supported skip
38
+ * (the API may have authoritative server-side gating; not a
39
+ * contract bug).
40
+ * • Pure-observation: empty list response → skip (no data to
41
+ * observe), not a finding.
42
+ *
43
+ * Limitations:
44
+ * • Pure-observation cannot verify `transitions[]` — there is no
45
+ * time series in a single list call. The check only enforces
46
+ * `observed ⊆ declared`; the transition graph is purely
47
+ * documentation in observation mode.
48
+ */
49
+ import type { OpenAPIV3 } from "openapi-types";
50
+ import type { CrudStatefulCheck } from "../stateful.ts";
51
+ import type { LifecycleConfig, LifecycleAction } from "../../generator/resources-builder.ts";
52
+ import { validateLifecycleManifest } from "../../generator/resources-builder.ts";
53
+ import {
54
+ extractIdFromCreateResponse,
55
+ fillPathWithId,
56
+ fillPathParams,
57
+ serializeCheckBody,
58
+ resolveCreateBody,
59
+ } from "./_crud-helpers.ts";
60
+
61
+ function safeParse(v: unknown): unknown {
62
+ if (typeof v !== "string") return v;
63
+ try { return JSON.parse(v); } catch { return v; }
64
+ }
65
+
66
+ function readState(body: unknown, field: string): string | null {
67
+ if (!body || typeof body !== "object") return null;
68
+ const v = (body as Record<string, unknown>)[field];
69
+ return typeof v === "string" ? v : null;
70
+ }
71
+
72
+ function parseEndpointLabel(label: string): { method: string; path: string } | null {
73
+ const parts = label.trim().split(/\s+/);
74
+ if (parts.length < 2) return null;
75
+ return { method: parts[0]!.toUpperCase(), path: parts[1]! };
76
+ }
77
+
78
+ function transitionAllowed(cfg: LifecycleConfig, from: string, to: string): boolean {
79
+ // Same-state replay is always OK (idempotent action).
80
+ if (from === to) return true;
81
+ for (const t of cfg.transitions) {
82
+ if (t.from === from && t.to.includes(to)) return true;
83
+ }
84
+ return false;
85
+ }
86
+
87
+ interface Finding {
88
+ kind: string;
89
+ message: string;
90
+ extra?: Record<string, unknown>;
91
+ }
92
+
93
+ export const lifecycleTransitions: CrudStatefulCheck = {
94
+ id: "lifecycle_transitions",
95
+ severity: "high",
96
+ defaultExpected: "Declared lifecycle actions must move the resource through declared states without regression",
97
+ references: [{ id: "ARV-172" }],
98
+ phase: "crud",
99
+ applies(g) {
100
+ return Boolean((g.create && g.read) || g.list);
101
+ },
102
+ async run(g, h) {
103
+ if (h.bootstrapCleanupFailed) {
104
+ return { kind: "skip", reason: "bootstrap-cleanup failed — stateful checks paused" };
105
+ }
106
+ const cfg = h.resourceConfigs?.get(g.resource)?.lifecycle;
107
+ if (!cfg) return { kind: "skip", reason: "no lifecycle config for this resource" };
108
+
109
+ const manifestErrors = validateLifecycleManifest(cfg);
110
+ if (manifestErrors.length > 0) {
111
+ return { kind: "skip", reason: `lifecycle manifest invalid: ${manifestErrors[0]}` };
112
+ }
113
+
114
+ // ARV-219: actions-empty → pure-observation mode (read-only state
115
+ // machine). Requires a list endpoint to sample observed states.
116
+ if (Object.keys(cfg.actions).length === 0) {
117
+ if (!g.list) {
118
+ return { kind: "skip", reason: "lifecycle has no actions and no list endpoint — nothing to verify or observe" };
119
+ }
120
+ return runPureObservation(g, h, cfg);
121
+ }
122
+
123
+ // Action-driven mode requires both create + read.
124
+ if (!g.create || !g.read) {
125
+ return { kind: "skip", reason: "lifecycle actions declared but resource lacks create+read endpoints" };
126
+ }
127
+
128
+ const create = g.create!;
129
+ const read = g.read!;
130
+ const baseHeaders = { Accept: "application/json", ...h.authHeaders };
131
+ const stateSet = new Set(cfg.states);
132
+
133
+ // 1. Create — prefer seed_body (ARV-187) over generator.
134
+ const seedBody = h.resourceConfigs?.get(g.resource)?.seedBody;
135
+ const generated = resolveCreateBody(create, seedBody) ?? {};
136
+ const { body: createBody, contentType } = serializeCheckBody(
137
+ create,
138
+ generated,
139
+ h.pathVars,
140
+ seedBody?.contentType,
141
+ );
142
+ const createUrl = `${h.baseUrl.replace(/\/+$/, "")}${fillPathParams(create.path, h.pathVars)}`;
143
+ const createResp = await h.send({
144
+ method: "POST",
145
+ url: createUrl,
146
+ headers: { ...baseHeaders, "Content-Type": contentType },
147
+ body: createBody,
148
+ });
149
+ if (createResp.status < 200 || createResp.status >= 300) {
150
+ return { kind: "skip", reason: `create returned ${createResp.status} — broken-baseline guard` };
151
+ }
152
+ const createBodyParsed = createResp.body_parsed ?? safeParse(createResp.body);
153
+ const id = extractIdFromCreateResponse(createBodyParsed, g.idParam);
154
+ if (id == null) return { kind: "skip", reason: "could not extract id from create response" };
155
+
156
+ const findings: Finding[] = [];
157
+
158
+ let currentState = readState(createBodyParsed, cfg.field);
159
+ if (currentState == null) {
160
+ return { kind: "skip", reason: `state field "${cfg.field}" missing on create response — yaml mismatch or hidden field` };
161
+ }
162
+ if (!stateSet.has(currentState)) {
163
+ findings.push({
164
+ kind: "undeclared_state",
165
+ message: `initial state "${currentState}" not in declared states [${cfg.states.join(", ")}]`,
166
+ extra: { observed: currentState, declared: cfg.states },
167
+ });
168
+ }
169
+
170
+ const readUrlFor = (resId: string | number): string =>
171
+ `${h.baseUrl.replace(/\/+$/, "")}${fillPathWithId(fillPathParams(read.path, h.pathVars), g.idParam, resId)}`;
172
+
173
+ // 2. For each action: invoke, read, assert.
174
+ for (const [name, action] of Object.entries(cfg.actions) as Array<[string, LifecycleAction]>) {
175
+ const parsed = parseEndpointLabel(action.endpoint);
176
+ if (!parsed) {
177
+ findings.push({
178
+ kind: "action_endpoint_malformed",
179
+ message: `action "${name}".endpoint "${action.endpoint}" must be "METHOD /path"`,
180
+ });
181
+ continue;
182
+ }
183
+ const actionUrl = `${h.baseUrl.replace(/\/+$/, "")}${fillPathWithId(fillPathParams(parsed.path, h.pathVars), g.idParam, id)}`;
184
+ const actionBody = action.body
185
+ ? serializeCheckBody(create, action.body, h.pathVars)
186
+ : { body: "", contentType: "application/json" };
187
+ const actionHeaders: Record<string, string> = { ...baseHeaders };
188
+ if (action.body) actionHeaders["Content-Type"] = actionBody.contentType;
189
+
190
+ const firstResp = await h.send({
191
+ method: parsed.method,
192
+ url: actionUrl,
193
+ headers: actionHeaders,
194
+ body: action.body ? actionBody.body : undefined,
195
+ });
196
+ if (firstResp.status < 200 || firstResp.status >= 300) {
197
+ // Server-side gating; not a contract violation.
198
+ findings.push({
199
+ kind: "action_rejected",
200
+ message: `action "${name}" returned ${firstResp.status} on first call — server may gate this transition`,
201
+ extra: { action: name, status: firstResp.status },
202
+ });
203
+ continue;
204
+ }
205
+
206
+ // Read state after action.
207
+ const readResp = await h.send({ method: "GET", url: readUrlFor(id), headers: baseHeaders });
208
+ if (readResp.status < 200 || readResp.status >= 300) {
209
+ findings.push({
210
+ kind: "read_after_action_failed",
211
+ message: `GET after action "${name}" returned ${readResp.status}`,
212
+ extra: { action: name, read_status: readResp.status },
213
+ });
214
+ break;
215
+ }
216
+ const observedState = readState(readResp.body_parsed ?? safeParse(readResp.body), cfg.field);
217
+ if (observedState == null) {
218
+ findings.push({
219
+ kind: "state_field_missing",
220
+ message: `GET after action "${name}": state field "${cfg.field}" missing`,
221
+ extra: { action: name },
222
+ });
223
+ break;
224
+ }
225
+ if (!stateSet.has(observedState)) {
226
+ findings.push({
227
+ kind: "undeclared_state",
228
+ message: `action "${name}" produced state "${observedState}" not in declared states`,
229
+ extra: { action: name, observed: observedState },
230
+ });
231
+ } else {
232
+ if (!transitionAllowed(cfg, currentState, observedState)) {
233
+ findings.push({
234
+ kind: "forbidden_transition",
235
+ message: `action "${name}": ${currentState} → ${observedState} is not allowed by declared transitions`,
236
+ extra: { action: name, from: currentState, to: observedState },
237
+ });
238
+ }
239
+ if (observedState !== action.expectedState) {
240
+ findings.push({
241
+ kind: "wrong_expected_state",
242
+ message: `action "${name}": expected state "${action.expectedState}", observed "${observedState}"`,
243
+ extra: { action: name, expected: action.expectedState, observed: observedState },
244
+ });
245
+ }
246
+ }
247
+
248
+ // 3. Idempotency probe: invoke action again, state must not regress.
249
+ const secondResp = await h.send({
250
+ method: parsed.method,
251
+ url: actionUrl,
252
+ headers: actionHeaders,
253
+ body: action.body ? actionBody.body : undefined,
254
+ });
255
+ if (secondResp.status >= 500) {
256
+ findings.push({
257
+ kind: "double_action_5xx",
258
+ message: `action "${name}" 5xx'd on replay (${secondResp.status}) — should be idempotent or 4xx`,
259
+ extra: { action: name, status: secondResp.status },
260
+ });
261
+ } else if (secondResp.status >= 200 && secondResp.status < 300) {
262
+ // Replay accepted — state must remain the action's expected state.
263
+ const replayRead = await h.send({ method: "GET", url: readUrlFor(id), headers: baseHeaders });
264
+ if (replayRead.status >= 200 && replayRead.status < 300) {
265
+ const replayState = readState(replayRead.body_parsed ?? safeParse(replayRead.body), cfg.field);
266
+ if (replayState != null && replayState !== observedState) {
267
+ findings.push({
268
+ kind: "state_regression_on_replay",
269
+ message: `action "${name}" replay drifted state ${observedState} → ${replayState}`,
270
+ extra: { action: name, before_replay: observedState, after_replay: replayState },
271
+ });
272
+ }
273
+ }
274
+ }
275
+ // 4xx on replay is an acceptable "not-idempotent but safe" rejection.
276
+
277
+ currentState = observedState;
278
+ }
279
+
280
+ if (findings.length === 0) return { kind: "pass" };
281
+ const kinds = findings.map((f) => f.kind);
282
+ const message = findings.length === 1
283
+ ? findings[0]!.message
284
+ : `Lifecycle on ${g.resource}: ${findings.length} issue(s) — ${kinds.join(", ")}`;
285
+ return {
286
+ kind: "fail",
287
+ message,
288
+ evidence: {
289
+ resource: g.resource,
290
+ id,
291
+ kind: kinds.join("+"),
292
+ findings: findings.map((f) => ({ kind: f.kind, message: f.message, ...(f.extra ?? {}) })),
293
+ },
294
+ };
295
+ },
296
+ };
297
+
298
+ /** Item-array containers we recognise when a list endpoint returns
299
+ * `{ data: [...] }` etc. Mirrors `pagination_invariants` defaults so
300
+ * the two checks stay in sync on response-shape heuristics. */
301
+ const ITEMS_FIELD_FALLBACKS: ReadonlyArray<string> = ["data", "items", "results", "value"];
302
+
303
+ function extractListItems(body: unknown): unknown[] | null {
304
+ if (Array.isArray(body)) return body;
305
+ if (!body || typeof body !== "object") return null;
306
+ const obj = body as Record<string, unknown>;
307
+ for (const f of ITEMS_FIELD_FALLBACKS) {
308
+ const v = obj[f];
309
+ if (Array.isArray(v)) return v;
310
+ }
311
+ // ARV-219 follow-up: GitHub-shape responses use API-specific keys
312
+ // (`workflow_runs`, `check_runs`, `artifacts`, `installations`, …)
313
+ // alongside metadata fields like `total_count`. Pick the longest
314
+ // array-valued property as the items collection — that's the
315
+ // canonical shape across the GitHub REST API. Wrong-array picks
316
+ // surface as `state field missing on all items` (informative skip),
317
+ // never as a finding.
318
+ let best: unknown[] | null = null;
319
+ for (const v of Object.values(obj)) {
320
+ if (Array.isArray(v) && (!best || v.length > best.length)) {
321
+ best = v as unknown[];
322
+ }
323
+ }
324
+ return best;
325
+ }
326
+
327
+ function pickSampleId(item: unknown, idParam: string): string | null {
328
+ if (item == null || typeof item !== "object") return null;
329
+ const obj = item as Record<string, unknown>;
330
+ // Try the resource's id-param field, then a generic `id` fallback —
331
+ // GitHub's `/issues` returns `number`, Stripe's returns `id`; both
332
+ // serve as a human-readable sample handle in the finding evidence.
333
+ for (const k of [idParam, "id", "number", "uuid", "key"]) {
334
+ const v = obj[k];
335
+ if (typeof v === "string" || typeof v === "number") return String(v);
336
+ }
337
+ return null;
338
+ }
339
+
340
+ async function runPureObservation(
341
+ g: Parameters<CrudStatefulCheck["run"]>[0],
342
+ h: Parameters<CrudStatefulCheck["run"]>[1],
343
+ cfg: LifecycleConfig,
344
+ ): ReturnType<CrudStatefulCheck["run"]> {
345
+ const list = g.list!;
346
+ const baseHeaders = { Accept: "application/json", ...h.authHeaders };
347
+ const url = `${h.baseUrl.replace(/\/+$/, "")}${fillPathParams(list.path, h.pathVars)}`;
348
+
349
+ const resp = await h.send({ method: "GET", url, headers: baseHeaders });
350
+ if (resp.status < 200 || resp.status >= 300) {
351
+ return { kind: "skip", reason: `list returned ${resp.status} — broken-baseline guard (observation mode)` };
352
+ }
353
+ const body = resp.body_parsed ?? safeParse(resp.body);
354
+ const items = extractListItems(body);
355
+ if (items == null) {
356
+ return { kind: "skip", reason: "list response shape not recognised (expected array or {data|items|results|value: []})" };
357
+ }
358
+ if (items.length === 0) {
359
+ return { kind: "skip", reason: "list empty — no data to observe" };
360
+ }
361
+
362
+ const stateSet = new Set(cfg.states);
363
+ const missingField: string[] = [];
364
+ // Map of undeclared state → up to N sample ids for evidence; using
365
+ // a Map preserves observed-order so the finding mentions states
366
+ // in the order the API surfaced them.
367
+ const undeclared = new Map<string, string[]>();
368
+ const SAMPLE_CAP = 5;
369
+
370
+ for (const item of items) {
371
+ if (item == null || typeof item !== "object") continue;
372
+ const state = (item as Record<string, unknown>)[cfg.field];
373
+ const sampleId = pickSampleId(item, g.idParam) ?? "?";
374
+ if (typeof state !== "string") {
375
+ if (missingField.length < SAMPLE_CAP) missingField.push(sampleId);
376
+ continue;
377
+ }
378
+ if (!stateSet.has(state)) {
379
+ const ids = undeclared.get(state) ?? [];
380
+ if (ids.length < SAMPLE_CAP) ids.push(sampleId);
381
+ undeclared.set(state, ids);
382
+ }
383
+ }
384
+
385
+ // Field-missing is informational only — many APIs nest state under a
386
+ // sub-object the operator may have misspelled. Surface as a skip when
387
+ // EVERY item lacks the field (yaml mismatch), but don't fail the run
388
+ // when only some items lack it (could be a polymorphic schema).
389
+ if (undeclared.size === 0 && missingField.length === items.length) {
390
+ return {
391
+ kind: "skip",
392
+ reason: `state field "${cfg.field}" missing on all ${items.length} observed items — yaml mismatch or nested field`,
393
+ };
394
+ }
395
+
396
+ if (undeclared.size === 0) return { kind: "pass" };
397
+
398
+ const observedList = [...undeclared.entries()].map(([state, ids]) => ({
399
+ state,
400
+ sample_ids: ids,
401
+ occurrence_cap_hit: ids.length === SAMPLE_CAP,
402
+ }));
403
+ const stateNames = [...undeclared.keys()];
404
+ return {
405
+ kind: "fail",
406
+ message: `Lifecycle on ${g.resource} (observation mode): observed ${undeclared.size} undeclared state(s) — ${stateNames.join(", ")}`,
407
+ evidence: {
408
+ resource: g.resource,
409
+ kind: "undeclared_state",
410
+ mode: "observation",
411
+ observed_undeclared: observedList,
412
+ declared_states: cfg.states,
413
+ items_examined: items.length,
414
+ },
415
+ };
416
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `missing_required_header` — schemathesis-equivalent. Sends a request
3
+ * with one declared-required header dropped; the server must reject
4
+ * with a 4xx (400 / 401 / 403 / 406 / 422). Anything else (2xx, 3xx,
5
+ * 5xx) is a finding.
6
+ *
7
+ * The runner generates the probe case (kind="missing_required_header")
8
+ * only when at least one operation declares a required header — we
9
+ * skip otherwise to avoid a second wave of pointless requests.
10
+ */
11
+ import type { Check } from "../types.ts";
12
+
13
+ const ACCEPTABLE_REJECT_STATUSES = new Set([400, 401, 403, 406, 422, 412]);
14
+
15
+ export const missingRequiredHeader: Check = {
16
+ id: "missing_required_header",
17
+ severity: "high",
18
+ defaultExpected: "Server must reject the request with 4xx when a required header is missing",
19
+ references: [{ id: "OWASP-API-04" }],
20
+ caseKinds: ["missing_required_header"],
21
+ applies: (op) =>
22
+ op.parameters.some((p) => p.in === "header" && p.required === true),
23
+ run({ case: c, response }) {
24
+ const status = response.status;
25
+ if (status >= 500) {
26
+ return {
27
+ kind: "fail",
28
+ message: `Server 5xx'd when required header was dropped (${c.meta?.dropped_header}) — should be a 4xx rejection`,
29
+ evidence: { dropped_header: c.meta?.dropped_header, status },
30
+ };
31
+ }
32
+ if (ACCEPTABLE_REJECT_STATUSES.has(status)) return { kind: "pass" };
33
+ if (status >= 400 && status < 500) return { kind: "pass" }; // any 4xx counts
34
+ return {
35
+ kind: "fail",
36
+ message: `Server accepted request without required header "${c.meta?.dropped_header}" (status ${status})`,
37
+ evidence: { dropped_header: c.meta?.dropped_header, status },
38
+ };
39
+ },
40
+ };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `negative_data_rejection` (m-15 ARV-4) — schemathesis-equivalent.
3
+ * The runner builds a single-site negative case (one mutation against
4
+ * a valid body, see `_negative_mutator.ts`); if the server still
5
+ * accepts it (status outside 4xx/5xx + 401/403/404 admin set), we
6
+ * raise a finding with raw evidence. The agent triages / re-severitizes.
7
+ *
8
+ * Default expected: 400 / 401 / 403 / 404 / 422 / 428 / 5xx.
9
+ * 2xx and 3xx with our payload are findings.
10
+ *
11
+ * Severity matrix (ARV-284, dispatched per finding via outcome.severity
12
+ * — see `severityForEvidence` below):
13
+ *
14
+ * - MEDIUM: concrete schema breach silently accepted (maxLength+1,
15
+ * type-mismatch, pattern-violation, format-invalid,
16
+ * drop-required, uniqueItems-violation, drop-required-query).
17
+ * Server should reject; evidence single-signal but breach
18
+ * is concrete.
19
+ * - LOW: `additionalProperties-violation` (unknown body fields
20
+ * silently dropped is documented forward-compat in many
21
+ * APIs — Stripe by design) and `wrong-type` query params
22
+ * on GET list endpoints (often "invalid id → empty
23
+ * result" documented behaviour). Single-signal, ambiguous
24
+ * intent.
25
+ *
26
+ * Note: 5xx on a negative mutation does NOT escalate this check —
27
+ * `not_a_server_error` (separate check, severity HIGH by design) owns
28
+ * the 5xx signal, and `ACCEPTABLE()` below treats 5xx as a non-silent
29
+ * accept so this check passes for those cases. Avoids double-counting.
30
+ *
31
+ * Per ARV-250's proof-cap principle (no evidence → no high severity):
32
+ * single-signal proof caps at LOW; concrete schema breach escalates to
33
+ * MEDIUM. The declared `severity: "low"` is the natural fallback /
34
+ * proof-cap baseline; stronger findings use `outcome.severity` to
35
+ * override. The agent re-severitizes from the raw evidence.
36
+ */
37
+ import type { Check, CheckOutcome } from "../types.ts";
38
+ import type { Severity } from "../../severity/index.ts";
39
+
40
+ const ACCEPTABLE = (status: number): boolean => {
41
+ if (status === 400 || status === 401 || status === 403 || status === 404 || status === 422 || status === 428) return true;
42
+ if (status >= 500 && status < 600) return true; // 5xx accepted: a bug, but not a *silent* accept
43
+ return false;
44
+ };
45
+
46
+ /** Body-boundary labels we treat as LOW (often by-design vendor
47
+ * behaviour, single-signal, ambiguous intent). All other boundary
48
+ * labels emitted by `coverage-phase.ts:enumerateBoundaryCases` —
49
+ * maxLength+1, pattern-violation, uuid-invalid, drop-required:X, etc.
50
+ * — are concrete schema breaches and stay MEDIUM. */
51
+ const LOW_BODY_BOUNDARIES: ReadonlySet<string> = new Set([
52
+ "additionalProperties-violation",
53
+ ]);
54
+
55
+ /** Param-scenario labels (`coverage-phase.ts:enumerateParamBoundaryCases`)
56
+ * we treat as LOW. `wrong-type` is the classic "Stripe returns empty
57
+ * list for invalid id" pattern — many APIs document this; flagging
58
+ * HIGH/MEDIUM produces noise. `drop-required-query` stays MEDIUM (a
59
+ * declared-required param being silently optional is a concrete
60
+ * contract gap, not vendor convention). */
61
+ const LOW_PARAM_SCENARIOS_ON_GET: ReadonlySet<string> = new Set([
62
+ "wrong-type",
63
+ ]);
64
+
65
+ interface MutationMeta {
66
+ mutation?: string;
67
+ boundary?: string;
68
+ param_scenario?: string;
69
+ param_location?: string;
70
+ }
71
+
72
+ function severityForEvidence(
73
+ meta: MutationMeta | undefined,
74
+ method: string,
75
+ ): Severity {
76
+ if (!meta) return "low";
77
+
78
+ // Param-side mutations (query / path wrong-type, drop-required-query).
79
+ if (meta.mutation === "param-boundary") {
80
+ const scenario = meta.param_scenario ?? "";
81
+ if (
82
+ meta.param_location === "query"
83
+ && method.toUpperCase() === "GET"
84
+ && LOW_PARAM_SCENARIOS_ON_GET.has(scenario)
85
+ ) {
86
+ return "low";
87
+ }
88
+ // drop-required-query, wrong-type on non-GET, wrong-type on path
89
+ // params: concrete contract violations server should reject.
90
+ return "medium";
91
+ }
92
+
93
+ // Body-boundary mutations (additionalProperties, maxLength+1, etc).
94
+ if (meta.mutation === "boundary") {
95
+ const boundary = meta.boundary ?? "";
96
+ if (LOW_BODY_BOUNDARIES.has(boundary)) return "low";
97
+ return "medium";
98
+ }
99
+
100
+ // Unknown mutation kind — single-signal fallback.
101
+ return "low";
102
+ }
103
+
104
+ export const negativeDataRejection: Check = {
105
+ id: "negative_data_rejection",
106
+ // ARV-284: declared severity is the *natural* tier (proof-cap baseline
107
+ // per ARV-250 — single-signal caps at LOW). Per-finding severity is
108
+ // dispatched via `outcome.severity` in `run()` below, so summary
109
+ // tables can show HIGH for 5xx and MEDIUM for concrete schema breach
110
+ // without globally setting the check to HIGH (which masks calibration).
111
+ severity: "low",
112
+ defaultExpected: "Server must reject invalid bodies with 400/401/403/404/422/428 (or 5xx)",
113
+ references: [{ id: "OWASP-API-08" }],
114
+ caseKinds: ["negative_data"],
115
+ applies: (op) => Boolean(op.requestBodySchema),
116
+ run({ case: c, response }): CheckOutcome {
117
+ const meta = c.meta as MutationMeta | undefined;
118
+ const method = c.operation.method.toUpperCase();
119
+ const bodyless = method === "GET" || method === "HEAD" || method === "DELETE";
120
+ // ARV-345 (scope fix, like ARV-340): don't manufacture "accepted an invalid
121
+ // body" findings on methods with no request-body semantics. A GET/HEAD/DELETE
122
+ // that 2xx's a mutated body simply ignored the body — correct, not a gap.
123
+ if (bodyless && meta?.mutation === "boundary") {
124
+ return { kind: "skip", reason: "body mutation not applicable on GET/HEAD/DELETE (no request-body semantics)" };
125
+ }
126
+ // ARV-345: an unknown / wrong-type QUERY param a GET tolerates (2xx) is
127
+ // documented leniency ("invalid id → empty list"), not a rejectable invalid.
128
+ // drop-required-query stays in scope — a declared-required param silently
129
+ // optional IS a concrete contract gap.
130
+ if (
131
+ bodyless
132
+ && meta?.mutation === "param-boundary"
133
+ && meta?.param_location === "query"
134
+ && LOW_PARAM_SCENARIOS_ON_GET.has(meta?.param_scenario ?? "")
135
+ ) {
136
+ return { kind: "skip", reason: "unknown/wrong-type query param on GET is documented leniency, not a validation gap" };
137
+ }
138
+ if (ACCEPTABLE(response.status)) return { kind: "pass" };
139
+ return {
140
+ kind: "fail",
141
+ message: `Server accepted an invalid body (status ${response.status}) — single-site mutation: ${
142
+ meta?.mutation ?? "unknown"
143
+ } @ ${meta?.boundary ?? meta?.param_scenario ?? "?"}`,
144
+ evidence: { status: response.status, mutation: meta },
145
+ severity: severityForEvidence(meta, c.operation.method),
146
+ };
147
+ },
148
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `not_a_server_error` — schemathesis-equivalent baseline check: a
3
+ * well-formed request must never produce a 5xx response. Acts as the
4
+ * seed check for the ARV-1 scaffolding; the rest of the conformance
5
+ * suite lands in ARV-2.
6
+ */
7
+ import type { Check } from "../types.ts";
8
+
9
+ export const notAServerError: Check = {
10
+ id: "not_a_server_error",
11
+ severity: "high",
12
+ defaultExpected: "Server must not respond with 5xx for any well-formed request",
13
+ references: [
14
+ { id: "RFC-9110-15.6", url: "https://www.rfc-editor.org/rfc/rfc9110#name-server-error-5xx" },
15
+ ],
16
+ // ARV-340: evaluate malformed-input cases too, not just the positive
17
+ // baseline. A negative_data / missing-header request is still a
18
+ // well-formed HTTP request the server must handle with a 4xx — a 5xx
19
+ // there is an unhandled crash (live Stripe: GET /v1/billing/alerts 500
20
+ // on a bad query param slipped through when this defaulted to positive
21
+ // only). `unsupported_method` is excluded on purpose: 501/405 to an
22
+ // undeclared method is legitimate, not a server error.
23
+ caseKinds: ["positive", "negative_data", "missing_required_header"],
24
+ applies: () => true,
25
+ run({ response }) {
26
+ if (response.status >= 500 && response.status < 600) {
27
+ return {
28
+ kind: "fail",
29
+ message: `Server responded with ${response.status} (5xx) — request triggered an unhandled error`,
30
+ evidence: { status: response.status },
31
+ };
32
+ }
33
+ return { kind: "pass" };
34
+ },
35
+ };