@kirrosh/zond 0.21.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 +758 -3
  2. package/README.md +78 -15
  3. package/package.json +17 -10
  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 +55 -6
  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 +192 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +180 -8
  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 -47
  31. package/src/cli/commands/init/agents-md.ts +61 -0
  32. package/src/cli/commands/init/bootstrap.ts +108 -0
  33. package/src/cli/commands/init/index.ts +244 -0
  34. package/src/cli/commands/init/skills.ts +98 -0
  35. package/src/cli/commands/init/templates/agents.md +77 -0
  36. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  37. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +651 -0
  40. package/src/cli/commands/init/templates/zond-config.yml +14 -0
  41. package/src/cli/commands/prepare-fixtures.ts +135 -0
  42. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  43. package/src/cli/commands/probe/security.ts +454 -0
  44. package/src/cli/commands/probe/static.ts +255 -0
  45. package/src/cli/commands/probe/webhooks.ts +161 -0
  46. package/src/cli/commands/probe.ts +459 -0
  47. package/src/cli/commands/reference.ts +87 -0
  48. package/src/cli/commands/refresh-api.ts +169 -0
  49. package/src/cli/commands/remove-api.ts +150 -0
  50. package/src/cli/commands/report-bundle.ts +318 -0
  51. package/src/cli/commands/report.ts +241 -0
  52. package/src/cli/commands/request.ts +379 -4
  53. package/src/cli/commands/run.ts +911 -33
  54. package/src/cli/commands/session.ts +244 -0
  55. package/src/cli/commands/use.ts +74 -0
  56. package/src/cli/index.ts +36 -607
  57. package/src/cli/json-envelope.ts +112 -3
  58. package/src/cli/json-schemas.ts +263 -0
  59. package/src/cli/program.ts +218 -0
  60. package/src/cli/resolve.ts +105 -0
  61. package/src/cli/status-filter.ts +124 -0
  62. package/src/cli/util/api-context.ts +85 -0
  63. package/src/cli/version.ts +8 -0
  64. package/src/core/anti-fp/bootstrap.ts +34 -0
  65. package/src/core/anti-fp/index.ts +33 -0
  66. package/src/core/anti-fp/registry.ts +44 -0
  67. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  68. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  69. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  70. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  71. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  72. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  73. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  74. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  75. package/src/core/anti-fp/types.ts +68 -0
  76. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  77. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  78. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  79. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  80. package/src/core/checks/checks/cross_call_references.ts +134 -0
  81. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  82. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  83. package/src/core/checks/checks/ignored_auth.ts +211 -0
  84. package/src/core/checks/checks/index.ts +65 -0
  85. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  86. package/src/core/checks/checks/missing_required_header.ts +40 -0
  87. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  88. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  89. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  90. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  91. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  92. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  93. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  94. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  95. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  96. package/src/core/checks/checks/unsupported_method.ts +63 -0
  97. package/src/core/checks/checks/use_after_free.ts +78 -0
  98. package/src/core/checks/index.ts +30 -0
  99. package/src/core/checks/mode.ts +79 -0
  100. package/src/core/checks/recommended-action.ts +64 -0
  101. package/src/core/checks/registry.ts +78 -0
  102. package/src/core/checks/runner.ts +874 -0
  103. package/src/core/checks/sarif.ts +230 -0
  104. package/src/core/checks/stateful.ts +121 -0
  105. package/src/core/checks/types.ts +189 -0
  106. package/src/core/classifier/recommended-action.ts +222 -0
  107. package/src/core/context/current.ts +51 -0
  108. package/src/core/context/session.ts +78 -0
  109. package/src/core/coverage/loader.ts +185 -0
  110. package/src/core/coverage/reasons.ts +300 -0
  111. package/src/core/diagnostics/db-analysis.ts +161 -12
  112. package/src/core/diagnostics/failure-class.ts +120 -0
  113. package/src/core/diagnostics/failure-hints.ts +212 -9
  114. package/src/core/diagnostics/spec-pointer.ts +99 -0
  115. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  116. package/src/core/exporter/case-study/index.ts +270 -0
  117. package/src/core/exporter/curl.ts +40 -0
  118. package/src/core/exporter/exporter.ts +48 -0
  119. package/src/core/exporter/html-report/escape.ts +24 -0
  120. package/src/core/exporter/html-report/index.ts +479 -0
  121. package/src/core/exporter/html-report/script.ts +100 -0
  122. package/src/core/exporter/html-report/styles.ts +408 -0
  123. package/src/core/generator/chunker.ts +53 -15
  124. package/src/core/generator/coverage-phase.ts +0 -0
  125. package/src/core/generator/create-body.ts +89 -0
  126. package/src/core/generator/data-factory.ts +490 -33
  127. package/src/core/generator/describe.ts +1 -1
  128. package/src/core/generator/fixtures-builder.ts +325 -0
  129. package/src/core/generator/index.ts +7 -5
  130. package/src/core/generator/openapi-reader.ts +55 -3
  131. package/src/core/generator/path-param-disambig.ts +114 -0
  132. package/src/core/generator/resources-builder.ts +648 -0
  133. package/src/core/generator/schema-utils.ts +11 -3
  134. package/src/core/generator/serializer.ts +114 -15
  135. package/src/core/generator/suite-generator.ts +484 -77
  136. package/src/core/generator/types.ts +8 -0
  137. package/src/core/identity/identity-file.ts +129 -0
  138. package/src/core/lint/affects.ts +28 -0
  139. package/src/core/lint/config.ts +96 -0
  140. package/src/core/lint/format.ts +42 -0
  141. package/src/core/lint/index.ts +94 -0
  142. package/src/core/lint/reporter.ts +128 -0
  143. package/src/core/lint/rules/consistency.ts +158 -0
  144. package/src/core/lint/rules/heuristics.ts +97 -0
  145. package/src/core/lint/rules/strictness.ts +109 -0
  146. package/src/core/lint/types.ts +96 -0
  147. package/src/core/lint/walker.ts +248 -0
  148. package/src/core/meta/meta-store.ts +6 -73
  149. package/src/core/output/README.md +91 -0
  150. package/src/core/output/index.ts +13 -0
  151. package/src/core/output/run.ts +126 -0
  152. package/src/core/output/types.ts +129 -0
  153. package/src/core/parser/env-interpolation.ts +104 -0
  154. package/src/core/parser/filter.ts +57 -0
  155. package/src/core/parser/schema.ts +132 -5
  156. package/src/core/parser/types.ts +29 -2
  157. package/src/core/parser/variables.ts +0 -0
  158. package/src/core/parser/yaml-parser.ts +108 -13
  159. package/src/core/probe/bootstrap.ts +34 -0
  160. package/src/core/probe/dry-run-envelope.ts +57 -0
  161. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  162. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  163. package/src/core/probe/mass-assignment-template.ts +212 -0
  164. package/src/core/probe/method-probe.ts +164 -0
  165. package/src/core/probe/method-shared.ts +69 -0
  166. package/src/core/probe/negative-probe.ts +691 -0
  167. package/src/core/probe/orphan-tracker.ts +188 -0
  168. package/src/core/probe/path-discovery.ts +440 -0
  169. package/src/core/probe/probe-harness.ts +120 -0
  170. package/src/core/probe/registry.ts +89 -0
  171. package/src/core/probe/runner.ts +136 -0
  172. package/src/core/probe/security-probe-class.ts +201 -0
  173. package/src/core/probe/security-probe.ts +1453 -0
  174. package/src/core/probe/shared.ts +505 -0
  175. package/src/core/probe/static-probe-class.ts +125 -0
  176. package/src/core/probe/types.ts +165 -0
  177. package/src/core/probe/verdict-aggregator.ts +33 -0
  178. package/src/core/probe/webhooks-probe.ts +284 -0
  179. package/src/core/reporter/console.ts +69 -4
  180. package/src/core/reporter/index.ts +2 -3
  181. package/src/core/reporter/json.ts +15 -2
  182. package/src/core/reporter/junit.ts +27 -12
  183. package/src/core/reporter/ndjson.ts +37 -0
  184. package/src/core/reporter/types.ts +3 -0
  185. package/src/core/runner/assertions.ts +62 -2
  186. package/src/core/runner/async-pool.ts +108 -0
  187. package/src/core/runner/auth-path.ts +8 -0
  188. package/src/core/runner/ci-context.ts +72 -0
  189. package/src/core/runner/executor.ts +391 -52
  190. package/src/core/runner/form-encode.ts +51 -0
  191. package/src/core/runner/http-client.ts +115 -7
  192. package/src/core/runner/learn-drift.ts +293 -0
  193. package/src/core/runner/preflight-vars.ts +149 -0
  194. package/src/core/runner/progress-tracker.ts +73 -0
  195. package/src/core/runner/rate-limiter.ts +203 -0
  196. package/src/core/runner/run-kind.ts +39 -0
  197. package/src/core/runner/schema-validator.ts +312 -0
  198. package/src/core/runner/send-request.ts +153 -20
  199. package/src/core/runner/types.ts +38 -0
  200. package/src/core/secrets/registry.ts +164 -0
  201. package/src/core/secrets/secrets-file.ts +115 -0
  202. package/src/core/selectors/operation-filter.ts +144 -0
  203. package/src/core/setup-api.ts +419 -17
  204. package/src/core/severity/category.ts +94 -0
  205. package/src/core/severity/index.ts +121 -0
  206. package/src/core/spec/layers.ts +154 -0
  207. package/src/core/util/format-eta.ts +21 -0
  208. package/src/core/utils.ts +5 -1
  209. package/src/core/workspace/config.ts +129 -0
  210. package/src/core/workspace/manifest.ts +283 -0
  211. package/src/core/workspace/output-rotation.ts +62 -0
  212. package/src/core/workspace/root.ts +94 -0
  213. package/src/core/workspace/triage-path.ts +87 -0
  214. package/src/db/lint-runs.ts +47 -0
  215. package/src/db/migrate.ts +126 -0
  216. package/src/db/migrations/0001_run_kind.sql +25 -0
  217. package/src/db/migrations/sql.d.ts +4 -0
  218. package/src/db/queries/collections.ts +133 -0
  219. package/src/db/queries/coverage.ts +9 -0
  220. package/src/db/queries/dashboard.ts +59 -0
  221. package/src/db/queries/results.ts +128 -0
  222. package/src/db/queries/runs.ts +235 -0
  223. package/src/db/queries/sessions.ts +42 -0
  224. package/src/db/queries/settings.ts +28 -0
  225. package/src/db/queries/types.ts +172 -0
  226. package/src/db/queries.ts +72 -802
  227. package/src/db/schema.ts +179 -48
  228. package/src/cli/commands/export.ts +0 -144
  229. package/src/cli/commands/guide.ts +0 -127
  230. package/src/cli/commands/init.ts +0 -57
  231. package/src/cli/commands/serve.ts +0 -81
  232. package/src/cli/commands/sync.ts +0 -269
  233. package/src/cli/commands/update.ts +0 -189
  234. package/src/cli/commands/validate.ts +0 -34
  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 -21
  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,270 @@
1
+ import type { RunRecord, StoredStepResult } from "../../../db/queries.ts";
2
+ import type { FailureClass } from "../../diagnostics/failure-class.ts";
3
+ import { buildCurl } from "../curl.ts";
4
+
5
+ export interface CaseStudyOptions {
6
+ result: StoredStepResult;
7
+ run: RunRecord;
8
+ /** Pulled from OpenAPI `info.title` if available. */
9
+ specTitle?: string | null;
10
+ specVersion?: string | null;
11
+ zondVersion: string;
12
+ /** TASK-164 (m-9 P8): cap response/request bodies to N bytes. 0 or
13
+ * unset = no cap. The CLI wrapper defaults to 8 KB. */
14
+ bodyCapBytes?: number;
15
+ /** ARV-106/107: short registry slug (`--api <name>`). When set, the
16
+ * case-study renders a `zond request --api <name>` alternative below the
17
+ * curl repro and falls back to it for the "API" field when specTitle is
18
+ * null. */
19
+ apiName?: string | null;
20
+ /** ARV-107: loaded OpenAPI spec. When provided, the renderer auto-extracts
21
+ * the relevant operation block for the "What the spec says" section instead
22
+ * of leaving the `<TODO: paste …>` placeholder. */
23
+ specDoc?: OpenApiDocLike | null;
24
+ }
25
+
26
+ /** Minimal shape we touch from an OpenAPI spec — keeps this module
27
+ * framework-free. */
28
+ interface OpenApiDocLike {
29
+ paths?: Record<string, Record<string, unknown>> | null;
30
+ components?: { schemas?: Record<string, unknown> } | null;
31
+ }
32
+
33
+ function capBody(content: string | null | undefined, capBytes: number | undefined): string | null {
34
+ if (!content) return content ?? null;
35
+ if (!capBytes || capBytes <= 0 || content.length <= capBytes) return content;
36
+ const dropped = content.length - capBytes;
37
+ return `${content.slice(0, capBytes)}\n[truncated ${dropped} bytes; first ${capBytes} shown; full body in run DB]`;
38
+ }
39
+
40
+ const CLASS_HUMAN: Record<FailureClass, string> = {
41
+ definitely_bug: "definitely_bug",
42
+ likely_bug: "likely_bug",
43
+ quirk: "quirk",
44
+ env_issue: "env_issue",
45
+ cascade: "cascade",
46
+ };
47
+
48
+ const TODO = (hint: string): string => `<TODO: ${hint}>`;
49
+
50
+ function tryPretty(s: string | null | undefined): string {
51
+ if (!s) return "";
52
+ try {
53
+ return JSON.stringify(JSON.parse(s), null, 2);
54
+ } catch {
55
+ return s;
56
+ }
57
+ }
58
+
59
+ function extractPath(url: string | null): string {
60
+ if (!url) return TODO("path");
61
+ try {
62
+ return new URL(url).pathname;
63
+ } catch {
64
+ return url;
65
+ }
66
+ }
67
+
68
+ function shortDescription(result: StoredStepResult): string {
69
+ if (result.failure_class_reason) return result.failure_class_reason;
70
+ if (result.error_message) return result.error_message;
71
+ if (result.response_status != null && result.response_status >= 500) {
72
+ return `unexpected ${result.response_status} from server`;
73
+ }
74
+ if (result.response_status != null && result.response_status >= 400) {
75
+ return `unexpected ${result.response_status} response`;
76
+ }
77
+ return TODO("one-line summary of the failure");
78
+ }
79
+
80
+ function tldrLine(result: StoredStepResult): string {
81
+ switch (result.failure_class) {
82
+ case "definitely_bug":
83
+ return "Backend bug — endpoint contradicts its own contract.";
84
+ case "likely_bug":
85
+ return "Suspicious behaviour — contract leaves it ambiguous, but the response is hard to defend.";
86
+ case "quirk":
87
+ return "Documented quirk worth flagging in onboarding docs / a spec PR.";
88
+ case "env_issue":
89
+ return "Environment / fixture issue — not an API bug, but blocks the test from running.";
90
+ default:
91
+ return TODO("one-sentence takeaway");
92
+ }
93
+ }
94
+
95
+ function whyItMatters(result: StoredStepResult): string {
96
+ switch (result.failure_class) {
97
+ case "definitely_bug":
98
+ case "likely_bug": {
99
+ const reason = result.failure_class_reason ?? TODO("explain why this is a bug — what user-visible impact?");
100
+ const failedAsserts = result.assertions.filter((a) => !a.passed);
101
+ const detail = failedAsserts.length > 0
102
+ ? failedAsserts
103
+ .map((a) => `- \`${a.rule}\` at \`${a.field}\`: expected \`${JSON.stringify(a.expected)}\`, got \`${JSON.stringify(a.actual)}\``)
104
+ .join("\n")
105
+ : "";
106
+ return detail ? `${reason}\n\nFailed assertions:\n\n${detail}` : reason;
107
+ }
108
+ case "quirk":
109
+ return `Spec implies one thing, server does another:\n\n- Expected: ${TODO("what the spec promised")}\n- Actual: ${TODO("what the server did")}`;
110
+ case "env_issue":
111
+ return result.failure_class_reason ?? TODO("describe what the test setup needed and didn't get");
112
+ default:
113
+ return TODO("explain why this finding matters to the reader");
114
+ }
115
+ }
116
+
117
+ function howZondFoundIt(result: StoredStepResult): string {
118
+ const prov = result.provenance;
119
+ if (!prov || prov.type === "manual") {
120
+ return `Manually authored test case: \`${result.suite_name}\` → \`${result.test_name}\`.`;
121
+ }
122
+ const generator = prov.generator ?? "openapi-generated";
123
+ const lines: string[] = [];
124
+ lines.push(`Generated by \`${generator}\` (${prov.type ?? "unknown"}).`);
125
+ if (prov.endpoint) lines.push(`Targeted endpoint: \`${prov.endpoint}\`.`);
126
+ if (prov.response_branch) lines.push(`Asserted response branch: \`${prov.response_branch}\`.`);
127
+ lines.push(`Suite: \`${result.suite_name}\` → \`${result.test_name}\`.`);
128
+ return lines.join("\n");
129
+ }
130
+
131
+ function matchSpecOperation(
132
+ spec: OpenApiDocLike,
133
+ method: string,
134
+ concretePath: string,
135
+ ): { template: string; operation: Record<string, unknown> } | null {
136
+ const paths = spec.paths;
137
+ if (!paths) return null;
138
+ const want = method.toLowerCase();
139
+ // Prefer an exact path hit (no path-params), then fall back to a regex-based
140
+ // template match. Both passes ignore query strings.
141
+ const cleanPath = concretePath.split("?")[0] ?? concretePath;
142
+ if (paths[cleanPath]) {
143
+ const op = (paths[cleanPath] as Record<string, unknown>)[want];
144
+ if (op && typeof op === "object") return { template: cleanPath, operation: op as Record<string, unknown> };
145
+ }
146
+ for (const [template, item] of Object.entries(paths)) {
147
+ if (template === cleanPath) continue;
148
+ if (!template.includes("{")) continue;
149
+ const re = new RegExp("^" + template.replace(/\{[^}]+\}/g, "[^/]+") + "$");
150
+ if (!re.test(cleanPath)) continue;
151
+ const op = (item as Record<string, unknown>)[want];
152
+ if (op && typeof op === "object") return { template, operation: op as Record<string, unknown> };
153
+ }
154
+ return null;
155
+ }
156
+
157
+ function specSnippet(result: StoredStepResult, specDoc?: OpenApiDocLike | null): string {
158
+ if (!result.spec_pointer && !result.spec_excerpt) {
159
+ // ARV-107: try to recover the operation slice from the loaded spec.
160
+ if (specDoc && result.request_method && result.request_url) {
161
+ const path = extractPath(result.request_url);
162
+ const match = matchSpecOperation(specDoc, result.request_method, path);
163
+ if (match) {
164
+ const ptr = `JSON pointer: \`#/paths/${match.template.replace(/\//g, "~1")}/${result.request_method.toLowerCase()}\`\n\n`;
165
+ return ptr + "```json\n" + JSON.stringify(match.operation, null, 2) + "\n```";
166
+ }
167
+ }
168
+ return TODO("paste the relevant slice of the OpenAPI spec");
169
+ }
170
+ const ptr = result.spec_pointer ? `JSON pointer: \`${result.spec_pointer}\`\n\n` : "";
171
+ const excerpt = result.spec_excerpt
172
+ ? "```json\n" + tryPretty(result.spec_excerpt) + "\n```"
173
+ : TODO("snippet not captured at run time");
174
+ return ptr + excerpt;
175
+ }
176
+
177
+ function provenanceLine(result: StoredStepResult): string {
178
+ const prov = result.provenance;
179
+ if (!prov) return `Test source: \`${result.suite_name}\` (no provenance metadata).`;
180
+ const parts: string[] = [];
181
+ if (prov.generator) parts.push(`generator \`${prov.generator}\``);
182
+ if (prov.spec) parts.push(`spec \`${prov.spec}\``);
183
+ if (prov.response_branch) parts.push(`branch \`${prov.response_branch}\``);
184
+ return parts.length > 0
185
+ ? `Provenance: ${parts.join(", ")}.`
186
+ : `Provenance: \`${prov.type ?? "unknown"}\`.`;
187
+ }
188
+
189
+ function buildZondRequestLine(apiName: string, result: StoredStepResult): string {
190
+ const method = (result.request_method ?? "GET").toUpperCase();
191
+ const path = extractPath(result.request_url);
192
+ const parts: string[] = [`zond request --api ${apiName} ${method} ${path}`];
193
+ if (result.request_body) {
194
+ const escaped = result.request_body.replace(/'/g, `'\\''`);
195
+ parts.push(` --json '${escaped}'`);
196
+ }
197
+ return parts.join(" \\\n");
198
+ }
199
+
200
+ export function renderCaseStudy(opts: CaseStudyOptions): string {
201
+ const { result, run, specTitle, specVersion, zondVersion, apiName } = opts;
202
+ const method = (result.request_method ?? TODO("HTTP method")).toUpperCase();
203
+ const path = extractPath(result.request_url);
204
+ const fc = result.failure_class ? CLASS_HUMAN[result.failure_class] : TODO("classification");
205
+
206
+ // ARV-107: cascade — explicit spec title, then registry slug (`--api`), then
207
+ // collection-id breadcrumb, then unrecoverable.
208
+ const apiLine = specTitle
209
+ ? `${specTitle}${specVersion ? ` ${specVersion}` : ""}`
210
+ : apiName
211
+ ? apiName
212
+ : (run.collection_id != null ? TODO("API title — spec was not loadable at export time") : TODO("API name"));
213
+
214
+ const responseStatus = result.response_status != null ? String(result.response_status) : "no response";
215
+ const cappedBody = capBody(result.response_body, opts.bodyCapBytes);
216
+ const responseBody = cappedBody
217
+ ? "```json\n" + tryPretty(cappedBody) + "\n```"
218
+ : (result.error_message ? `Network/runtime error: \`${result.error_message}\`` : "_(empty body)_");
219
+
220
+ // ARV-106: prefer the `zond request` form when an api slug is known
221
+ // (skill anti-curl rule). The curl block stays as a copy-paste fallback for
222
+ // recipients without zond installed, with a redacted Authorization header.
223
+ const reproAlt = apiName
224
+ ? `\n\n_Or with zond:_\n\n\`\`\`bash\n${buildZondRequestLine(apiName, result)}\n\`\`\``
225
+ : "";
226
+
227
+ return `# ${method} ${path} — ${shortDescription(result)}
228
+
229
+ ## TL;DR
230
+
231
+ - **What we found:** ${fc}
232
+ - **What went wrong:** ${tldrLine(result)}
233
+ - **Repro:** see below
234
+
235
+ ## Context
236
+
237
+ - **API:** ${apiLine}
238
+ - **Endpoint:** \`${method} ${path}\`
239
+ - ${provenanceLine(result)}
240
+
241
+ ### What the spec says
242
+
243
+ ${specSnippet(result, opts.specDoc)}
244
+
245
+ ## Repro
246
+
247
+ \`\`\`bash
248
+ ${buildCurl(result)}
249
+ \`\`\`${reproAlt}
250
+
251
+ ## What happened
252
+
253
+ - **Status:** ${responseStatus}
254
+ - **Duration:** ${result.duration_ms} ms
255
+
256
+ ${responseBody}
257
+
258
+ ## Why it matters
259
+
260
+ ${whyItMatters(result)}
261
+
262
+ ## How zond found it
263
+
264
+ ${howZondFoundIt(result)}
265
+
266
+ ---
267
+
268
+ _Generated by zond ${zondVersion} from run #${run.id} (result #${result.id})._
269
+ `;
270
+ }
@@ -0,0 +1,40 @@
1
+ import type { StoredStepResult } from "../../db/queries.ts";
2
+
3
+ export interface BuildCurlOptions {
4
+ /** "redacted" — emit `Authorization: Bearer <REDACTED>` placeholder so the
5
+ * reader knows the original request was authenticated (ARV-106). "omit" —
6
+ * legacy behaviour. Defaults to "redacted". */
7
+ authHeader?: "redacted" | "omit";
8
+ }
9
+
10
+ function isRemoteUrl(url: string | null | undefined): boolean {
11
+ if (!url) return false;
12
+ try {
13
+ const u = new URL(url);
14
+ if (u.protocol !== "http:" && u.protocol !== "https:") return false;
15
+ const host = u.hostname.toLowerCase();
16
+ return host !== "localhost" && host !== "127.0.0.1" && host !== "::1" && host !== "0.0.0.0";
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /** Single-quote-wrapped curl, safe-ish for shells. */
23
+ export function buildCurl(step: StoredStepResult, options: BuildCurlOptions = {}): string {
24
+ const parts: string[] = ["curl"];
25
+ const method = step.request_method?.toUpperCase();
26
+ if (method && method !== "GET") {
27
+ parts.push("-X", method);
28
+ }
29
+ const wantAuth = (options.authHeader ?? "redacted") === "redacted" && isRemoteUrl(step.request_url);
30
+ if (wantAuth) {
31
+ parts.push("-H", "'Authorization: Bearer <REDACTED — replace with your token>'");
32
+ }
33
+ if (step.request_body) {
34
+ const escaped = step.request_body.replace(/'/g, `'\\''`);
35
+ parts.push("-H", "'Content-Type: application/json'");
36
+ parts.push("-d", `'${escaped}'`);
37
+ }
38
+ parts.push(`'${step.request_url ?? ""}'`);
39
+ return parts.join(" ");
40
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Single sanitization seam for everything zond writes to disk or stdout.
3
+ *
4
+ * Background: m-10 (TASK-166..168) introduced a secrets registry and
5
+ * `redact()` helper. The first wave of work added `redact(...)` calls
6
+ * inside individual exporters and reporters, which means a new exporter
7
+ * is one missing call away from leaking a secret. TASK-186 collapses
8
+ * that risk: every exporter declares a pure `render()` and goes through
9
+ * the {@link runExporter} pipeline, which applies the sanitizer exactly
10
+ * once at the boundary.
11
+ *
12
+ * The interface is deliberately string-out only — every consumer
13
+ * (writeFile, console.log, HTTP body) accepts a string anyway.
14
+ */
15
+
16
+ import { redact } from "../secrets/registry.ts";
17
+
18
+ export interface Exporter<I, O = void> {
19
+ /** Stable identifier — used in logs and tests. */
20
+ readonly name: string;
21
+ /** Mime hint for the rendered payload. */
22
+ readonly mime: string;
23
+ /** Pure render — no I/O, no redaction. Receives caller-supplied opts. */
24
+ render(input: I, opts?: O): string;
25
+ }
26
+
27
+ /**
28
+ * Run an exporter's render through the sanitizer pipeline. This is the
29
+ * only place sanitization happens for exporter output — render() must
30
+ * NOT call `redact()` itself, and callers must NOT redact again on top.
31
+ *
32
+ * Sanitization is currently a single pass of {@link redact}; future
33
+ * sanitizer rules (e.g. identity scrubbing) will plug in here so every
34
+ * exporter inherits them automatically.
35
+ */
36
+ export function runExporter<I, O>(exporter: Exporter<I, O>, input: I, opts?: O): string {
37
+ return applySanitizer(exporter.render(input, opts));
38
+ }
39
+
40
+ /**
41
+ * Sanitizer pipeline used by {@link runExporter}. Exposed for the
42
+ * handful of sites that build their payload outside the exporter
43
+ * interface (e.g. probe digests assembled inline) so they can opt into
44
+ * the same single-pass redaction without duplicating logic.
45
+ */
46
+ export function applySanitizer(payload: string): string {
47
+ return redact(payload);
48
+ }
@@ -0,0 +1,24 @@
1
+ // Minimal HTML escaper for safe inlining of user-controlled strings
2
+ // (request URLs, response bodies, error messages) into the single-file report.
3
+
4
+ const HTML_ESCAPES: Record<string, string> = {
5
+ "&": "&amp;",
6
+ "<": "&lt;",
7
+ ">": "&gt;",
8
+ '"': "&quot;",
9
+ "'": "&#39;",
10
+ };
11
+
12
+ export function escapeHtml(input: string | null | undefined): string {
13
+ if (input == null) return "";
14
+ return String(input).replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch);
15
+ }
16
+
17
+ export function tryPrettyJson(s: string | null | undefined): string {
18
+ if (!s) return "";
19
+ try {
20
+ return JSON.stringify(JSON.parse(s), null, 2);
21
+ } catch {
22
+ return s;
23
+ }
24
+ }