@kirrosh/zond 0.22.0 → 0.23.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 (256) hide show
  1. package/CHANGELOG.md +648 -0
  2. package/README.md +58 -6
  3. package/package.json +9 -6
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -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 +262 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +178 -7
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -46
  31. package/src/cli/commands/init/bootstrap.ts +30 -1
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +56 -3
  34. package/src/cli/commands/init/templates/agents.md +65 -61
  35. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  36. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  37. package/src/cli/commands/init/templates/skills/zond.md +592 -125
  38. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  39. package/src/cli/commands/prepare-fixtures.ts +135 -0
  40. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  41. package/src/cli/commands/probe/security.ts +454 -0
  42. package/src/cli/commands/probe/static.ts +255 -0
  43. package/src/cli/commands/probe/webhooks.ts +161 -0
  44. package/src/cli/commands/probe.ts +459 -0
  45. package/src/cli/commands/reference.ts +87 -0
  46. package/src/cli/commands/refresh-api.ts +169 -0
  47. package/src/cli/commands/remove-api.ts +150 -0
  48. package/src/cli/commands/report-bundle.ts +318 -0
  49. package/src/cli/commands/report.ts +241 -0
  50. package/src/cli/commands/request.ts +379 -4
  51. package/src/cli/commands/run.ts +842 -53
  52. package/src/cli/commands/session.ts +244 -0
  53. package/src/cli/commands/use.ts +18 -1
  54. package/src/cli/index.ts +20 -3
  55. package/src/cli/json-envelope.ts +112 -3
  56. package/src/cli/json-schemas.ts +263 -0
  57. package/src/cli/program.ts +198 -635
  58. package/src/cli/resolve.ts +105 -0
  59. package/src/cli/status-filter.ts +124 -0
  60. package/src/cli/util/api-context.ts +85 -0
  61. package/src/cli/version.ts +5 -0
  62. package/src/core/anti-fp/bootstrap.ts +34 -0
  63. package/src/core/anti-fp/index.ts +33 -0
  64. package/src/core/anti-fp/registry.ts +44 -0
  65. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  66. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  67. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  68. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  69. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  70. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  71. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  72. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  73. package/src/core/anti-fp/types.ts +68 -0
  74. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  75. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  76. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  77. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  78. package/src/core/checks/checks/cross_call_references.ts +134 -0
  79. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  80. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  81. package/src/core/checks/checks/ignored_auth.ts +211 -0
  82. package/src/core/checks/checks/index.ts +65 -0
  83. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  84. package/src/core/checks/checks/missing_required_header.ts +40 -0
  85. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  86. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  87. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  88. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  89. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  90. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  91. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  92. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  93. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  94. package/src/core/checks/checks/unsupported_method.ts +63 -0
  95. package/src/core/checks/checks/use_after_free.ts +78 -0
  96. package/src/core/checks/index.ts +30 -0
  97. package/src/core/checks/mode.ts +79 -0
  98. package/src/core/checks/recommended-action.ts +64 -0
  99. package/src/core/checks/registry.ts +78 -0
  100. package/src/core/checks/runner.ts +874 -0
  101. package/src/core/checks/sarif.ts +230 -0
  102. package/src/core/checks/stateful.ts +121 -0
  103. package/src/core/checks/types.ts +189 -0
  104. package/src/core/classifier/recommended-action.ts +222 -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 +185 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +151 -11
  110. package/src/core/diagnostics/failure-class.ts +120 -0
  111. package/src/core/diagnostics/failure-hints.ts +212 -9
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +156 -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 +42 -16
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/create-body.ts +89 -0
  124. package/src/core/generator/data-factory.ts +445 -19
  125. package/src/core/generator/describe.ts +1 -1
  126. package/src/core/generator/fixtures-builder.ts +325 -0
  127. package/src/core/generator/index.ts +7 -5
  128. package/src/core/generator/openapi-reader.ts +37 -3
  129. package/src/core/generator/path-param-disambig.ts +114 -0
  130. package/src/core/generator/resources-builder.ts +648 -0
  131. package/src/core/generator/schema-utils.ts +11 -3
  132. package/src/core/generator/serializer.ts +103 -13
  133. package/src/core/generator/suite-generator.ts +419 -111
  134. package/src/core/generator/types.ts +8 -0
  135. package/src/core/identity/identity-file.ts +129 -0
  136. package/src/core/lint/affects.ts +28 -0
  137. package/src/core/lint/config.ts +96 -0
  138. package/src/core/lint/format.ts +42 -0
  139. package/src/core/lint/index.ts +94 -0
  140. package/src/core/lint/reporter.ts +128 -0
  141. package/src/core/lint/rules/consistency.ts +158 -0
  142. package/src/core/lint/rules/heuristics.ts +97 -0
  143. package/src/core/lint/rules/strictness.ts +109 -0
  144. package/src/core/lint/types.ts +96 -0
  145. package/src/core/lint/walker.ts +248 -0
  146. package/src/core/meta/meta-store.ts +6 -73
  147. package/src/core/output/README.md +91 -0
  148. package/src/core/output/index.ts +13 -0
  149. package/src/core/output/run.ts +126 -0
  150. package/src/core/output/types.ts +129 -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 +57 -0
  159. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  160. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  161. package/src/core/probe/mass-assignment-template.ts +212 -0
  162. package/src/core/probe/method-probe.ts +43 -76
  163. package/src/core/probe/method-shared.ts +69 -0
  164. package/src/core/probe/negative-probe.ts +183 -149
  165. package/src/core/probe/orphan-tracker.ts +188 -0
  166. package/src/core/probe/path-discovery.ts +440 -0
  167. package/src/core/probe/probe-harness.ts +120 -0
  168. package/src/core/probe/registry.ts +89 -0
  169. package/src/core/probe/runner.ts +136 -0
  170. package/src/core/probe/security-probe-class.ts +201 -0
  171. package/src/core/probe/security-probe.ts +1453 -0
  172. package/src/core/probe/shared.ts +505 -0
  173. package/src/core/probe/static-probe-class.ts +125 -0
  174. package/src/core/probe/types.ts +165 -0
  175. package/src/core/probe/verdict-aggregator.ts +33 -0
  176. package/src/core/probe/webhooks-probe.ts +284 -0
  177. package/src/core/reporter/console.ts +41 -2
  178. package/src/core/reporter/index.ts +2 -3
  179. package/src/core/reporter/json.ts +11 -1
  180. package/src/core/reporter/junit.ts +27 -12
  181. package/src/core/reporter/ndjson.ts +37 -0
  182. package/src/core/reporter/types.ts +3 -0
  183. package/src/core/runner/assertions.ts +58 -1
  184. package/src/core/runner/async-pool.ts +108 -0
  185. package/src/core/runner/auth-path.ts +8 -0
  186. package/src/core/runner/ci-context.ts +72 -0
  187. package/src/core/runner/executor.ts +264 -20
  188. package/src/core/runner/form-encode.ts +51 -0
  189. package/src/core/runner/http-client.ts +75 -2
  190. package/src/core/runner/learn-drift.ts +293 -0
  191. package/src/core/runner/preflight-vars.ts +149 -0
  192. package/src/core/runner/progress-tracker.ts +73 -0
  193. package/src/core/runner/rate-limiter.ts +89 -17
  194. package/src/core/runner/run-kind.ts +39 -0
  195. package/src/core/runner/schema-validator.ts +312 -0
  196. package/src/core/runner/send-request.ts +153 -20
  197. package/src/core/runner/types.ts +38 -0
  198. package/src/core/secrets/registry.ts +164 -0
  199. package/src/core/secrets/secrets-file.ts +115 -0
  200. package/src/core/selectors/operation-filter.ts +144 -0
  201. package/src/core/setup-api.ts +415 -16
  202. package/src/core/severity/category.ts +94 -0
  203. package/src/core/severity/index.ts +121 -0
  204. package/src/core/spec/layers.ts +154 -0
  205. package/src/core/util/format-eta.ts +21 -0
  206. package/src/core/utils.ts +5 -1
  207. package/src/core/workspace/config.ts +129 -0
  208. package/src/core/workspace/manifest.ts +283 -0
  209. package/src/core/workspace/output-rotation.ts +62 -0
  210. package/src/core/workspace/triage-path.ts +87 -0
  211. package/src/db/lint-runs.ts +47 -0
  212. package/src/db/migrate.ts +126 -0
  213. package/src/db/migrations/0001_run_kind.sql +25 -0
  214. package/src/db/migrations/sql.d.ts +4 -0
  215. package/src/db/queries/collections.ts +133 -0
  216. package/src/db/queries/coverage.ts +9 -0
  217. package/src/db/queries/dashboard.ts +59 -0
  218. package/src/db/queries/results.ts +128 -0
  219. package/src/db/queries/runs.ts +235 -0
  220. package/src/db/queries/sessions.ts +42 -0
  221. package/src/db/queries/settings.ts +28 -0
  222. package/src/db/queries/types.ts +172 -0
  223. package/src/db/queries.ts +72 -802
  224. package/src/db/schema.ts +178 -50
  225. package/src/cli/commands/export.ts +0 -144
  226. package/src/cli/commands/guide.ts +0 -127
  227. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  228. package/src/cli/commands/probe-methods.ts +0 -108
  229. package/src/cli/commands/probe-validation.ts +0 -124
  230. package/src/cli/commands/serve.ts +0 -114
  231. package/src/cli/commands/sync.ts +0 -268
  232. package/src/cli/commands/update.ts +0 -189
  233. package/src/cli/commands/validate.ts +0 -34
  234. package/src/core/diagnostics/render-md.ts +0 -112
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -19
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,248 @@
1
+ import type { OpenAPIV3 } from "openapi-types";
2
+
3
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const;
4
+
5
+ export type SchemaContextKind =
6
+ | "param-schema"
7
+ | "request-body"
8
+ | "response-body"
9
+ | "property";
10
+
11
+ export interface ParamContext {
12
+ kind: "parameter";
13
+ jsonpointer: string;
14
+ path: string;
15
+ method: string;
16
+ param: OpenAPIV3.ParameterObject;
17
+ }
18
+
19
+ export interface ResponseContext {
20
+ kind: "response";
21
+ jsonpointer: string;
22
+ path: string;
23
+ method: string;
24
+ status: string;
25
+ response: OpenAPIV3.ResponseObject;
26
+ }
27
+
28
+ export interface RequestBodyContext {
29
+ kind: "requestBody";
30
+ jsonpointer: string;
31
+ path: string;
32
+ method: string;
33
+ requestBody: OpenAPIV3.RequestBodyObject;
34
+ }
35
+
36
+ export interface SchemaContext {
37
+ kind: "schema";
38
+ jsonpointer: string;
39
+ path?: string;
40
+ method?: string;
41
+ origin: SchemaContextKind;
42
+ /** Property name if this schema is a value of `properties.<name>`. */
43
+ propertyName?: string;
44
+ schema: OpenAPIV3.SchemaObject;
45
+ }
46
+
47
+ export type WalkContext = ParamContext | ResponseContext | RequestBodyContext | SchemaContext;
48
+
49
+ export type Visitor = (ctx: WalkContext) => void;
50
+
51
+ /**
52
+ * RFC6901 segment encoder: `~` → `~0`, `/` → `~1`.
53
+ */
54
+ function escapePointerSegment(s: string | number): string {
55
+ return String(s).replace(/~/g, "~0").replace(/\//g, "~1");
56
+ }
57
+
58
+ /**
59
+ * Walk an OpenAPI 3.x document, invoking `visit` for parameters, request bodies,
60
+ * responses, and every nested schema (recursively through properties / items /
61
+ * combinators). Each call carries a stable RFC6901 jsonpointer so issues can
62
+ * point precisely to the source.
63
+ *
64
+ * Cycles (already-visited schema objects by reference) are short-circuited so
65
+ * `@readme/openapi-parser`-resolved $ref-cycles don't loop.
66
+ */
67
+ export function walk(doc: OpenAPIV3.Document, visit: Visitor): void {
68
+ if (!doc.paths) return;
69
+ for (const [path, pathItem] of Object.entries(doc.paths)) {
70
+ if (!pathItem) continue;
71
+ const pathPtr = `/paths/${escapePointerSegment(path)}`;
72
+
73
+ // Path-level parameters
74
+ if (pathItem.parameters) {
75
+ pathItem.parameters.forEach((p, idx) => {
76
+ const param = p as OpenAPIV3.ParameterObject;
77
+ const ptr = `${pathPtr}/parameters/${idx}`;
78
+ visit({ kind: "parameter", jsonpointer: ptr, path, method: "*", param });
79
+ if (param.schema) {
80
+ walkSchema(
81
+ param.schema as OpenAPIV3.SchemaObject,
82
+ `${ptr}/schema`,
83
+ { origin: "param-schema", path, method: "*" },
84
+ visit,
85
+ new Set(),
86
+ );
87
+ }
88
+ });
89
+ }
90
+
91
+ for (const m of HTTP_METHODS) {
92
+ const op = (pathItem as Record<string, unknown>)[m] as OpenAPIV3.OperationObject | undefined;
93
+ if (!op) continue;
94
+ const opPtr = `${pathPtr}/${m}`;
95
+ const method = m.toUpperCase();
96
+
97
+ // Operation-level parameters
98
+ if (op.parameters) {
99
+ op.parameters.forEach((p, idx) => {
100
+ const param = p as OpenAPIV3.ParameterObject;
101
+ const ptr = `${opPtr}/parameters/${idx}`;
102
+ visit({ kind: "parameter", jsonpointer: ptr, path, method, param });
103
+ if (param.schema) {
104
+ walkSchema(
105
+ param.schema as OpenAPIV3.SchemaObject,
106
+ `${ptr}/schema`,
107
+ { origin: "param-schema", path, method },
108
+ visit,
109
+ new Set(),
110
+ );
111
+ }
112
+ });
113
+ }
114
+
115
+ // Request body
116
+ if (op.requestBody) {
117
+ const rb = op.requestBody as OpenAPIV3.RequestBodyObject;
118
+ const rbPtr = `${opPtr}/requestBody`;
119
+ visit({ kind: "requestBody", jsonpointer: rbPtr, path, method, requestBody: rb });
120
+ if (rb.content) {
121
+ for (const [ct, mt] of Object.entries(rb.content)) {
122
+ if (mt.schema) {
123
+ walkSchema(
124
+ mt.schema as OpenAPIV3.SchemaObject,
125
+ `${rbPtr}/content/${escapePointerSegment(ct)}/schema`,
126
+ { origin: "request-body", path, method },
127
+ visit,
128
+ new Set(),
129
+ );
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ // Responses
136
+ if (op.responses) {
137
+ for (const [status, respObj] of Object.entries(op.responses)) {
138
+ const resp = respObj as OpenAPIV3.ResponseObject;
139
+ const respPtr = `${opPtr}/responses/${escapePointerSegment(status)}`;
140
+ visit({ kind: "response", jsonpointer: respPtr, path, method, status, response: resp });
141
+ if (resp.content) {
142
+ for (const [ct, mt] of Object.entries(resp.content)) {
143
+ if (mt.schema) {
144
+ walkSchema(
145
+ mt.schema as OpenAPIV3.SchemaObject,
146
+ `${respPtr}/content/${escapePointerSegment(ct)}/schema`,
147
+ { origin: "response-body", path, method },
148
+ visit,
149
+ new Set(),
150
+ );
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ interface WalkSchemaCtx {
161
+ origin: SchemaContextKind;
162
+ path?: string;
163
+ method?: string;
164
+ propertyName?: string;
165
+ }
166
+
167
+ function walkSchema(
168
+ schema: OpenAPIV3.SchemaObject,
169
+ pointer: string,
170
+ ctx: WalkSchemaCtx,
171
+ visit: Visitor,
172
+ visited: Set<unknown>,
173
+ ): void {
174
+ if (!schema || typeof schema !== "object") return;
175
+ if (visited.has(schema)) return;
176
+ visited.add(schema);
177
+
178
+ visit({
179
+ kind: "schema",
180
+ jsonpointer: pointer,
181
+ path: ctx.path,
182
+ method: ctx.method,
183
+ origin: ctx.origin,
184
+ propertyName: ctx.propertyName,
185
+ schema,
186
+ });
187
+
188
+ if (schema.properties) {
189
+ for (const [name, sub] of Object.entries(schema.properties)) {
190
+ walkSchema(
191
+ sub as OpenAPIV3.SchemaObject,
192
+ `${pointer}/properties/${escapePointerSegment(name)}`,
193
+ { origin: "property", path: ctx.path, method: ctx.method, propertyName: name },
194
+ visit,
195
+ visited,
196
+ );
197
+ }
198
+ }
199
+
200
+ const arraySchema = schema as OpenAPIV3.ArraySchemaObject;
201
+ if (arraySchema.items) {
202
+ walkSchema(
203
+ arraySchema.items as OpenAPIV3.SchemaObject,
204
+ `${pointer}/items`,
205
+ { ...ctx, propertyName: undefined },
206
+ visit,
207
+ visited,
208
+ );
209
+ }
210
+
211
+ for (const combinator of ["allOf", "anyOf", "oneOf"] as const) {
212
+ const arr = (schema as Record<string, unknown>)[combinator] as OpenAPIV3.SchemaObject[] | undefined;
213
+ if (Array.isArray(arr)) {
214
+ arr.forEach((sub, idx) => {
215
+ walkSchema(
216
+ sub,
217
+ `${pointer}/${combinator}/${idx}`,
218
+ { ...ctx, propertyName: undefined },
219
+ visit,
220
+ visited,
221
+ );
222
+ });
223
+ }
224
+ }
225
+
226
+ if (typeof schema.additionalProperties === "object" && schema.additionalProperties !== null) {
227
+ walkSchema(
228
+ schema.additionalProperties as OpenAPIV3.SchemaObject,
229
+ `${pointer}/additionalProperties`,
230
+ { ...ctx, propertyName: undefined },
231
+ visit,
232
+ visited,
233
+ );
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Normalise OpenAPI 3.0 `nullable: true` so callers can reason about a single
239
+ * type list. Returns the (possibly array) type without mutating the schema.
240
+ */
241
+ export function normalisedTypes(schema: OpenAPIV3.SchemaObject): string[] {
242
+ const t = (schema as { type?: string | string[] }).type;
243
+ const list: string[] = Array.isArray(t) ? [...t] : t ? [t] : [];
244
+ if ((schema as { nullable?: boolean }).nullable === true && !list.includes("null")) {
245
+ list.push("null");
246
+ }
247
+ return list;
248
+ }
@@ -1,78 +1,11 @@
1
- import { join } from "path";
2
1
  import { createHash } from "crypto";
3
- import type { ZondMeta, FileMeta } from "./types.ts";
4
- import type { RawSuite } from "../generator/serializer.ts";
5
- import { normalizePath } from "../generator/coverage-scanner.ts";
6
-
7
- const META_FILENAME = ".zond-meta.json";
8
-
9
- export async function readMeta(testsDir: string): Promise<ZondMeta | null> {
10
- const metaPath = join(testsDir, META_FILENAME);
11
- const file = Bun.file(metaPath);
12
- if (!(await file.exists())) return null;
13
- try {
14
- return JSON.parse(await file.text()) as ZondMeta;
15
- } catch {
16
- return null;
17
- }
18
- }
19
-
20
- export async function writeMeta(testsDir: string, meta: ZondMeta): Promise<void> {
21
- const metaPath = join(testsDir, META_FILENAME);
22
- await Bun.write(metaPath, JSON.stringify(meta, null, 2) + "\n");
23
- }
24
-
25
- export function hashSpec(specContent: string): string {
26
- return createHash("sha256").update(specContent).digest("hex");
27
- }
28
2
 
29
3
  /**
30
- * Derive suite type from tags array or filename.
4
+ * SHA-256 of the canonical (decycled) JSON form of an OpenAPI document.
5
+ * Used as the freshness hash recorded in `.api-catalog.yaml`,
6
+ * `.api-resources.yaml`, and `.api-fixtures.yaml` so `zond doctor` can
7
+ * detect drift between the local snapshot and its derived artifacts.
31
8
  */
32
- function detectSuiteType(suite: RawSuite): FileMeta["suiteType"] {
33
- const tags = suite.tags ?? [];
34
- if (tags.includes("auth")) return "auth";
35
- if (tags.includes("sanity")) return "sanity";
36
- if (tags.includes("crud")) return "crud";
37
- if (tags.includes("unsafe")) return "unsafe";
38
- return "smoke";
39
- }
40
-
41
- /**
42
- * Extract first tag from fileStem or suite folder for grouping.
43
- * e.g. fileStem "smoke-users" → tag "users"
44
- */
45
- function detectTag(suite: RawSuite): string | undefined {
46
- const stem = suite.fileStem ?? suite.name;
47
- const match = stem.match(/^(?:smoke|crud|auth|sanity|unsafe)-(.+?)(?:-unsafe)?$/);
48
- return match?.[1];
49
- }
50
-
51
- /**
52
- * Build normalized endpoint keys from a raw suite's test steps.
53
- * e.g. "GET /users/{*}", "POST /users"
54
- */
55
- function extractEndpointKeys(suite: RawSuite): string[] {
56
- const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
57
- const keys: string[] = [];
58
- for (const step of suite.tests) {
59
- for (const method of HTTP_METHODS) {
60
- const path = step[method] as string | undefined;
61
- if (path) {
62
- keys.push(`${method} ${normalizePath(path)}`);
63
- break;
64
- }
65
- }
66
- }
67
- return [...new Set(keys)];
68
- }
69
-
70
- export function buildFileMeta(suite: RawSuite, zondVersion: string): FileMeta {
71
- return {
72
- generatedAt: new Date().toISOString(),
73
- zondVersion,
74
- suiteType: detectSuiteType(suite),
75
- tag: detectTag(suite),
76
- endpoints: extractEndpointKeys(suite),
77
- };
9
+ export function hashSpec(specContent: string): string {
10
+ return createHash("sha256").update(specContent).digest("hex");
78
11
  }
@@ -0,0 +1,91 @@
1
+ # core/output — typed `--report` / `--output` / `--json` policy
2
+
3
+ `OutputSpec<Payload>` is the single source-of-truth for how a command
4
+ produces output. Per-command parsers (`checks run`, `probe security`,
5
+ `run`, …) are migrated in ARV-117/118/119 — this directory only ships
6
+ the infrastructure.
7
+
8
+ Closes the seven divergent-output bugs collected in
9
+ `strategy/lessons.md` §E (ARV-50, ARV-82, ARV-97, …) by replacing N
10
+ ad-hoc parsers with one resolver.
11
+
12
+ ## Policy matrix
13
+
14
+ The runner (`runCommandWithOutput`) reads `--report`, `--output`, and
15
+ `--json` from the CLI layer and resolves them to a `ResolvedOutput`
16
+ decision according to this matrix:
17
+
18
+ | Input | Format | Channel | Notes |
19
+ | ------------------------------------ | -------------- | ------------------- | ----- |
20
+ | _(nothing)_ | `defaultFormat`| from format policy | bare invocation |
21
+ | `--report <fmt>` | `<fmt>` (or alias) | from format policy | unknown → error |
22
+ | `--json` | first format with `envelopeWrap: true` | from format policy | falls back to `defaultFormat` when no envelope-wrap format exists |
23
+ | `--output <path>` | unchanged | `file` (path = resolved absolute) | `--output` always wins over `defaultChannel` |
24
+ | `--report sarif` (defaults to file) | `sarif` | `file` (`defaultFilename` if no `--output`) | ARV-5 default `zond-checks.sarif` |
25
+ | `--report ndjson` (defaults to stdout) | `ndjson` | `stdout` | event stream — see ARV-10 |
26
+ | `--report ndjson --output <path>` | `ndjson` | `file` | ARV-97 — explicit `--output` redirects the stream |
27
+ | `--json` + `--report <fmt>` | — | — | mutually exclusive (throws `OutputSpecError`) |
28
+
29
+ Aliases (`spec.aliases`) let a single CLI flag value resolve to
30
+ another format — used by `checks run` so `--report ndjson` continues to
31
+ mean "the `--ndjson` streaming flag", matching skill-prompt
32
+ expectations (ARV-63).
33
+
34
+ ## Building a spec
35
+
36
+ ```ts
37
+ import type { OutputSpec } from "@/core/output";
38
+
39
+ interface ChecksPayload { findings: Finding[]; summary: Summary }
40
+
41
+ export const CHECKS_RUN_OUTPUT: OutputSpec<ChecksPayload> = {
42
+ command: "checks run",
43
+ defaultFormat: "json",
44
+ formats: {
45
+ json: { defaultChannel: "stdout", envelopeWrap: true, description: "JSON envelope" },
46
+ sarif: { defaultChannel: "file", defaultFilename: "zond-checks.sarif" },
47
+ ndjson: { defaultChannel: "stdout", description: "event stream — one JSON per line" },
48
+ },
49
+ aliases: {
50
+ // `--report ndjson` is a friendly alias retained from skill prompts.
51
+ ndjson: "ndjson",
52
+ },
53
+ render: (format, payload) => {
54
+ if (format === "sarif") return generateSarifReport(payload);
55
+ if (format === "ndjson") return payload.findings.map(f => JSON.stringify(f)).join("\n");
56
+ return JSON.stringify(payload, null, 2);
57
+ },
58
+ };
59
+ ```
60
+
61
+ The CLI handler then does:
62
+
63
+ ```ts
64
+ const { resolved, exitCode } = await runCommandWithOutput(
65
+ CHECKS_RUN_OUTPUT,
66
+ cmd.opts<OutputOptions>(),
67
+ async () => runChecks({ /* ... */ }),
68
+ );
69
+ process.exit(exitCode);
70
+ ```
71
+
72
+ `resolveOutput()` can be called standalone (without rendering) when a
73
+ command wants to plug the resolution into its own streaming pipeline —
74
+ e.g. `checks run` opens an fd ahead of time and feeds events into it
75
+ incrementally; in that case the command consumes `resolved.path` and
76
+ `resolved.channel` and handles I/O itself.
77
+
78
+ ## Why the format set is open
79
+
80
+ Each command owns its own format vocabulary. `run` ships
81
+ `json`/`junit`; `checks run` ships `json`/`sarif`/`ndjson`; `probe *`
82
+ ships `json` only. Declaring formats per-spec instead of in a global
83
+ union avoids a leaky enum and keeps `--help` accurate per command.
84
+
85
+ ## Errors
86
+
87
+ `resolveOutput` throws `OutputSpecError` for policy violations
88
+ (`--json + --report`, unknown format, file-default without filename).
89
+ The CLI handler catches it and produces either `jsonError` or a human
90
+ `printError`, matching the rest of the codebase's input-error
91
+ behaviour (exit code 2).
@@ -0,0 +1,13 @@
1
+ /**
2
+ * ARV-116 (m-19): public surface of the output-spec module.
3
+ */
4
+ export type {
5
+ OutputChannel,
6
+ OutputFormat,
7
+ OutputOptions,
8
+ OutputSpec,
9
+ FormatPolicy,
10
+ ResolvedOutput,
11
+ } from "./types.ts";
12
+ export { OutputSpecError } from "./types.ts";
13
+ export { resolveOutput, runCommandWithOutput, type RunOutputResult } from "./run.ts";
@@ -0,0 +1,126 @@
1
+ /**
2
+ * ARV-116 (m-19): runner that turns an `OutputSpec` + CLI flags into
3
+ * a `ResolvedOutput`, then renders and writes the payload.
4
+ *
5
+ * Resolution order:
6
+ * 1. `--json` and `--report` are mutually exclusive — error.
7
+ * 2. If `--report` is set, look it up in `aliases` first, then in
8
+ * `formats`. Unknown → error (consistent with ARV-97; never
9
+ * silently swallow a typo).
10
+ * 3. If `--json` is set, pick the first envelope-wrapping format
11
+ * from the spec. Specs without one fall back to the
12
+ * `defaultFormat` (acceptable for commands like `run` whose
13
+ * `--json` is special-cased — see ARV-117).
14
+ * 4. Otherwise use `defaultFormat`.
15
+ * 5. Channel: `--output` forces file. Without it, take the format's
16
+ * `defaultChannel`. File channel without an explicit `--output`
17
+ * uses `defaultFilename` (relative paths resolved against cwd).
18
+ */
19
+ import { resolve as resolvePath } from "path";
20
+ import {
21
+ OutputSpecError,
22
+ type OutputOptions,
23
+ type OutputSpec,
24
+ type ResolvedOutput,
25
+ } from "./types.ts";
26
+
27
+ export function resolveOutput<P>(
28
+ spec: OutputSpec<P>,
29
+ opts: OutputOptions,
30
+ ): ResolvedOutput {
31
+ if (opts.json && typeof opts.report === "string" && opts.report.length > 0) {
32
+ throw new OutputSpecError(
33
+ "--json and --report are mutually exclusive — pick one output channel",
34
+ );
35
+ }
36
+
37
+ // Step 1: resolve the format.
38
+ let format: string;
39
+ if (typeof opts.report === "string" && opts.report.length > 0) {
40
+ const alias = spec.aliases?.[opts.report];
41
+ format = alias ?? opts.report;
42
+ } else if (opts.json) {
43
+ format = pickEnvelopeFormat(spec) ?? spec.defaultFormat;
44
+ } else {
45
+ format = spec.defaultFormat;
46
+ }
47
+
48
+ const policy = spec.formats[format];
49
+ if (!policy) {
50
+ const known = Object.keys(spec.formats).sort().join(", ");
51
+ throw new OutputSpecError(
52
+ `Unknown --report format: "${format}". Available for ${spec.command}: ${known}`,
53
+ );
54
+ }
55
+
56
+ // Step 2: resolve channel + path.
57
+ const explicitOutput = typeof opts.output === "string" && opts.output.length > 0
58
+ ? opts.output
59
+ : undefined;
60
+ let channel: "stdout" | "file";
61
+ let path: string | undefined;
62
+ if (explicitOutput) {
63
+ channel = "file";
64
+ path = resolvePath(explicitOutput);
65
+ } else if (policy.defaultChannel === "file") {
66
+ channel = "file";
67
+ path = policy.defaultFilename ? resolvePath(policy.defaultFilename) : undefined;
68
+ if (!path) {
69
+ throw new OutputSpecError(
70
+ `Format "${format}" defaults to file but has no defaultFilename and --output was not set`,
71
+ );
72
+ }
73
+ } else {
74
+ channel = "stdout";
75
+ }
76
+
77
+ return {
78
+ format,
79
+ channel,
80
+ path,
81
+ envelopeWrap: policy.envelopeWrap === true,
82
+ };
83
+ }
84
+
85
+ function pickEnvelopeFormat<P>(spec: OutputSpec<P>): string | undefined {
86
+ for (const [name, policy] of Object.entries(spec.formats)) {
87
+ if (policy.envelopeWrap) return name;
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ export interface RunOutputResult<P> {
93
+ resolved: ResolvedOutput;
94
+ payload: P;
95
+ exitCode: number;
96
+ }
97
+
98
+ /** Render and write the payload according to the resolved decision.
99
+ * Returns the resolved decision plus the exit code from the spec's
100
+ * `exitCodePolicy` (0 by default). The CLI handler typically passes
101
+ * the exit code straight to `process.exit`. */
102
+ export async function runCommandWithOutput<P>(
103
+ spec: OutputSpec<P>,
104
+ opts: OutputOptions,
105
+ produce: () => Promise<P>,
106
+ ): Promise<RunOutputResult<P>> {
107
+ const resolved = resolveOutput(spec, opts);
108
+ const payload = await produce();
109
+
110
+ if (!spec.render) {
111
+ throw new OutputSpecError(
112
+ `OutputSpec for "${spec.command}" has no render() hook — cannot serialise payload`,
113
+ );
114
+ }
115
+ const body = spec.render(resolved.format, payload);
116
+
117
+ if (resolved.channel === "file") {
118
+ await Bun.write(resolved.path!, body);
119
+ } else {
120
+ process.stdout.write(body);
121
+ if (!body.endsWith("\n")) process.stdout.write("\n");
122
+ }
123
+
124
+ const exitCode = spec.exitCodePolicy ? spec.exitCodePolicy(payload) : 0;
125
+ return { resolved, payload, exitCode };
126
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * ARV-116 (m-19): typed declaration of every `--report` / `--output` /
3
+ * `--json` combination a command supports.
4
+ *
5
+ * Background. Lesson §E of strategy/lessons.md collects seven separate
6
+ * bug reports about output-flag plumbing:
7
+ * - ARV-97 — `--report ndjson --output <path>` silently dropped events;
8
+ * - ARV-50 — probe `--dry-run` JSON shape diverged from `--report json`;
9
+ * - ARV-82 — `db runs --json` envelope `data` field shape disagreed
10
+ * with sibling commands;
11
+ * - …and four more along the same lines.
12
+ *
13
+ * Root cause: each command has its own ad-hoc flag parser. Some treat
14
+ * "ndjson" as an alias for `--ndjson`, some don't. Some honour
15
+ * `--output` for SARIF only, some for every format. Some wrap in a JSON
16
+ * envelope, some emit a raw stream. There is no single place to read
17
+ * "what does `command X` produce when I pass `--report Y --output Z`".
18
+ *
19
+ * This module gives that single place. A command declares an
20
+ * `OutputSpec<Payload>` once — listing the formats it supports, each
21
+ * format's default channel (stdout vs file), each format's default
22
+ * filename, and whether the format wraps in the standard `--json`
23
+ * envelope. A runner helper (`runCommandWithOutput`) consumes the spec
24
+ * plus the CLI flags and resolves them to a single `ResolvedOutput`
25
+ * decision, with consistent mutual-exclusion enforcement.
26
+ *
27
+ * The spec is the source-of-truth for ARV-120's build-time check:
28
+ * every `--json` command must declare an `OutputSpec` with an
29
+ * `envelopeWrap: true` entry, so the published schema and the runtime
30
+ * output cannot drift.
31
+ *
32
+ * This task only ships the infrastructure. Per-command migration
33
+ * (`run`, `checks`, `probe*`) lands in ARV-117/118/119.
34
+ */
35
+
36
+ /** Where a rendered payload lands. */
37
+ export type OutputChannel = "stdout" | "file";
38
+
39
+ /** A format name — `sarif` / `ndjson` / `json` / `junit` etc. The set is
40
+ * open: each command declares its own. */
41
+ export type OutputFormat = string;
42
+
43
+ /** Per-format policy: where the format writes by default, what filename
44
+ * to use when it writes to a file, and whether the payload is wrapped
45
+ * in the standard `{ ok, command, data, ... }` envelope. */
46
+ export interface FormatPolicy {
47
+ /** Default destination when neither `--output` nor channel-overriding
48
+ * flags are present. SARIF defaults to file (`zond-checks.sarif`);
49
+ * NDJSON defaults to stdout (one event per line for piping); JSON
50
+ * envelopes default to stdout. */
51
+ defaultChannel: OutputChannel;
52
+ /** Default filename when `defaultChannel === "file"`. Relative paths
53
+ * are resolved against cwd by the runner. Ignored when
54
+ * `defaultChannel === "stdout"` — the user has to opt in to a file
55
+ * via `--output`. */
56
+ defaultFilename?: string;
57
+ /** True for formats that should be wrapped in the shared envelope
58
+ * (`jsonOk` / `jsonError` from `cli/json-envelope.ts`). Streaming
59
+ * formats (NDJSON) and bespoke serialisations (SARIF, JUnit) are
60
+ * not envelope-wrapped — they have their own contracts. */
61
+ envelopeWrap?: boolean;
62
+ /** ARV-120: for `envelopeWrap` formats — basename of the JSON schema
63
+ * under `docs/json-schema/` that describes the envelope's `data`
64
+ * field. The build-time coverage test asserts the file exists, so
65
+ * a renamed/deleted schema fails CI together with the spec. */
66
+ envelopeSchemaFile?: string;
67
+ /** Optional human description, surfaced by `--help` generators and
68
+ * the README table. */
69
+ description?: string;
70
+ }
71
+
72
+ export interface OutputSpec<Payload = unknown> {
73
+ /** Command name — propagated into the JSON envelope's `command` field
74
+ * and used by error messages. */
75
+ command: string;
76
+ /** Supported formats keyed by name. Unknown formats fall through to
77
+ * an error (no silent acceptance, see ARV-97). */
78
+ formats: Record<OutputFormat, FormatPolicy>;
79
+ /** Default format when neither `--report` nor `--json` is set. */
80
+ defaultFormat: OutputFormat;
81
+ /** Optional alias map: `{ ndjson: 'ndjson' }` lets `--report ndjson`
82
+ * fold into the `--ndjson` flag (ARV-63 alias, retained because
83
+ * skill prompts ship it). Keys are flag values seen on the CLI;
84
+ * values are the resolved format name. */
85
+ aliases?: Record<string, OutputFormat>;
86
+ /** Optional pre-validated render hook. Called by the runner once
87
+ * the format is resolved. Receives the payload plus the resolved
88
+ * format and returns the serialized output. */
89
+ render?: (format: OutputFormat, payload: Payload) => string;
90
+ /** Optional exit-code policy. Receives the payload after a
91
+ * successful run; returns the process exit code (0 by default). */
92
+ exitCodePolicy?: (payload: Payload) => number;
93
+ }
94
+
95
+ /** Decision the runner makes after applying the spec to CLI flags. */
96
+ export interface ResolvedOutput {
97
+ /** Resolved format name (always one of `spec.formats`). */
98
+ format: OutputFormat;
99
+ /** Resolved destination. */
100
+ channel: OutputChannel;
101
+ /** Filesystem path when `channel === "file"`. Absolute path expected
102
+ * by callers — the runner resolves it against cwd. */
103
+ path?: string;
104
+ /** Whether the runner should wrap the payload in the standard
105
+ * envelope before writing. Mirrors `FormatPolicy.envelopeWrap`. */
106
+ envelopeWrap: boolean;
107
+ }
108
+
109
+ /** Inputs the runner reads from the CLI layer. Names mirror commander
110
+ * options so a command can pass `cmd.opts<OutputOptions>()` directly. */
111
+ export interface OutputOptions {
112
+ /** `--report <format>`. */
113
+ report?: string;
114
+ /** `--output <path>`. */
115
+ output?: string;
116
+ /** `--json`. */
117
+ json?: boolean;
118
+ }
119
+
120
+ /** Thrown by `resolveOutput` when the user-supplied flags violate the
121
+ * spec's policy (unknown format, mutually exclusive flags, …). The
122
+ * CLI layer catches this and emits the standard `jsonError` or
123
+ * human `printError`. */
124
+ export class OutputSpecError extends Error {
125
+ constructor(message: string) {
126
+ super(message);
127
+ this.name = "OutputSpecError";
128
+ }
129
+ }