@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,479 @@
1
+ import type { RunRecord, StoredStepResult } from "../../../db/queries.ts";
2
+ import type { FailureClass } from "../../diagnostics/failure-class.ts";
3
+ import type { CoverageMatrix, ReasonCode, StatusClass } from "../../coverage/reasons.ts";
4
+ import { REPO_URL } from "../../../cli/version.ts";
5
+ import { escapeHtml, tryPrettyJson } from "./escape.ts";
6
+ import { buildCurl } from "../curl.ts";
7
+ import { STYLES } from "./styles.ts";
8
+ import { SCRIPT } from "./script.ts";
9
+
10
+ export interface RenderOptions {
11
+ run: RunRecord;
12
+ results: StoredStepResult[];
13
+ zondVersion: string;
14
+ generatedAt: Date;
15
+ /** Optional collection name for the title. */
16
+ collectionName?: string | null;
17
+ /** Optional resolved base_url (currently best-effort from results). */
18
+ baseUrl?: string | null;
19
+ /** Optional spec-aware coverage matrix with reason codes (TASK-109).
20
+ * When supplied, replaces the URL-only coverage map. */
21
+ coverageMatrix?: CoverageMatrix;
22
+ /** TASK-164 (m-9 P8): truncate request/response bodies to N bytes
23
+ * before rendering. Set to 0 (or omit) to keep full bodies. The
24
+ * default in the CLI wrapper is 8 KB. */
25
+ bodyCapBytes?: number;
26
+ }
27
+
28
+ const REASON_LABEL: Record<ReasonCode, string> = {
29
+ "covered": "covered",
30
+ "partial-failed": "partial",
31
+ "not-generated": "not generated",
32
+ "no-spec": "not in spec",
33
+ "deprecated": "deprecated",
34
+ "no-fixtures": "missing fixtures",
35
+ "ephemeral-only": "ephemeral-only",
36
+ "auth-scope-mismatch": "no auth token",
37
+ "tag-filtered": "filtered",
38
+ };
39
+
40
+ const FAILURE_CLASS_META: Record<FailureClass, { label: string; cls: string; emoji: string }> = {
41
+ definitely_bug: { label: "Definitely bug", cls: "fail", emoji: "🐞" },
42
+ likely_bug: { label: "Likely bug", cls: "warn", emoji: "⚠️" },
43
+ quirk: { label: "Quirk", cls: "info", emoji: "·" },
44
+ env_issue: { label: "Env issue", cls: "info", emoji: "🌐" },
45
+ cascade: { label: "Cascade", cls: "info", emoji: "↳" },
46
+ };
47
+
48
+ const STATUS_LABEL: Record<string, { label: string; cls: string }> = {
49
+ pass: { label: "PASS", cls: "solid-pass" },
50
+ fail: { label: "FAIL", cls: "solid-fail" },
51
+ error: { label: "ERROR", cls: "solid-fail" },
52
+ skip: { label: "SKIP", cls: "" },
53
+ };
54
+
55
+ function formatDate(iso: string | null | undefined): string {
56
+ if (!iso) return "—";
57
+ const d = new Date(iso);
58
+ if (Number.isNaN(d.getTime())) return iso;
59
+ return d.toLocaleString();
60
+ }
61
+
62
+ function formatDuration(ms: number | null | undefined): string {
63
+ if (ms == null) return "—";
64
+ if (ms < 1000) return `${ms} ms`;
65
+ return `${(ms / 1000).toFixed(2)} s`;
66
+ }
67
+
68
+ function pickBaseUrl(results: StoredStepResult[]): string | null {
69
+ for (const r of results) {
70
+ if (!r.request_url) continue;
71
+ try {
72
+ const u = new URL(r.request_url);
73
+ return `${u.protocol}//${u.host}`;
74
+ } catch {
75
+ // not absolute
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ interface CoverageRow {
82
+ endpoint: string;
83
+ buckets: Record<string, "ok" | "4xx" | "5xx" | "err" | undefined>;
84
+ }
85
+
86
+ const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
87
+
88
+ function buildCoverage(results: StoredStepResult[]): CoverageRow[] {
89
+ // Group by request_url path × method, pick worst observed status per cell.
90
+ const rows = new Map<string, CoverageRow>();
91
+ for (const r of results) {
92
+ if (!r.request_url || !r.request_method) continue;
93
+ let path: string;
94
+ try {
95
+ path = new URL(r.request_url).pathname;
96
+ } catch {
97
+ path = r.request_url;
98
+ }
99
+ const method = r.request_method.toUpperCase();
100
+ if (!METHODS.includes(method as typeof METHODS[number])) continue;
101
+ let row = rows.get(path);
102
+ if (!row) {
103
+ row = { endpoint: path, buckets: {} };
104
+ rows.set(path, row);
105
+ }
106
+ const status = r.response_status;
107
+ let cell: "ok" | "4xx" | "5xx" | "err";
108
+ if (r.status === "error" || status == null) cell = "err";
109
+ else if (status >= 500) cell = "5xx";
110
+ else if (status >= 400) cell = "4xx";
111
+ else cell = "ok";
112
+ const existing = row.buckets[method];
113
+ // Worst-of-cell precedence: err/5xx > 4xx > ok
114
+ const rank = { ok: 0, "4xx": 1, "5xx": 2, err: 3 } as const;
115
+ if (!existing || rank[cell] > rank[existing]) {
116
+ row.buckets[method] = cell;
117
+ }
118
+ }
119
+ return [...rows.values()].sort((a, b) => a.endpoint.localeCompare(b.endpoint));
120
+ }
121
+
122
+ function badgeForStatus(status: number | null): string {
123
+ if (status == null) return `<span class="badge fail">no resp</span>`;
124
+ const cls = status >= 500 ? "fail" : status >= 400 ? "warn" : "pass";
125
+ return `<span class="badge ${cls}">${status}</span>`;
126
+ }
127
+
128
+ function failureClassBadge(fc: FailureClass | null, reason: string | null): string {
129
+ if (!fc) return "";
130
+ const meta = FAILURE_CLASS_META[fc];
131
+ const title = reason ? ` title="${escapeHtml(reason)}"` : "";
132
+ return `<span class="badge ${meta.cls}"${title}>${meta.emoji} ${meta.label}</span>`;
133
+ }
134
+
135
+ function renderProvenance(prov: StoredStepResult["provenance"]): string {
136
+ if (!prov) return "";
137
+ const parts: string[] = [];
138
+ if (prov.type) parts.push(`<span class="badge">${escapeHtml(prov.type)}</span>`);
139
+ if (prov.generator) parts.push(`<span class="mono" style="font-size:11px;color:var(--fg-muted)">${escapeHtml(prov.generator)}</span>`);
140
+ if (prov.endpoint) parts.push(`<span class="mono" style="font-size:11px">${escapeHtml(prov.endpoint)}</span>`);
141
+ if (prov.response_branch) parts.push(`<span class="badge info">→ ${escapeHtml(prov.response_branch)}</span>`);
142
+ return parts.length > 0 ? `<div style="display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-bottom:8px">${parts.join("")}</div>` : "";
143
+ }
144
+
145
+ function renderSpecSnippet(pointer: string | null, excerpt: string | null): string {
146
+ if (!pointer && !excerpt) return "";
147
+ const ptrBlock = pointer
148
+ ? `<div class="code-label">Spec pointer</div><pre class="code">${escapeHtml(pointer)}</pre>`
149
+ : "";
150
+ const exBlock = excerpt
151
+ ? `<div class="code-label">Spec excerpt</div><pre class="code" data-lang="json">${escapeHtml(tryPrettyJson(excerpt))}</pre>`
152
+ : "";
153
+ return ptrBlock + exBlock;
154
+ }
155
+
156
+ function renderAssertions(asserts: StoredStepResult["assertions"]): string {
157
+ if (asserts.length === 0) {
158
+ return `<div class="empty" style="padding:16px">No assertions recorded.</div>`;
159
+ }
160
+ return `<ul class="asserts">${asserts.map((a) => {
161
+ const cls = a.passed ? "passed" : "failed";
162
+ const expected = a.expected !== undefined ? escapeHtml(JSON.stringify(a.expected)) : "";
163
+ const actual = a.actual !== undefined ? escapeHtml(JSON.stringify(a.actual)) : "";
164
+ const diff = !a.passed && (a.expected !== undefined || a.actual !== undefined)
165
+ ? `<div class="a-diff">
166
+ <div><span class="lbl">expected:</span> ${expected}</div>
167
+ <div><span class="lbl">actual:</span> ${actual}</div>
168
+ </div>`
169
+ : "";
170
+ return `<li class="${cls}">
171
+ <div class="a-head">
172
+ <span class="badge ${a.passed ? "pass" : "fail"} dot">${escapeHtml(a.rule || "assertion")}</span>
173
+ ${a.field ? `<span class="mono" style="font-size:11px;color:var(--fg-muted)">${escapeHtml(a.field)}</span>` : ""}
174
+ </div>
175
+ ${diff}
176
+ </li>`;
177
+ }).join("")}</ul>`;
178
+ }
179
+
180
+ function renderHeaders(rawJson: string | null): string {
181
+ if (!rawJson) return `<div class="empty" style="padding:16px">No headers.</div>`;
182
+ return `<pre class="code" data-lang="json">${escapeHtml(tryPrettyJson(rawJson))}</pre>`;
183
+ }
184
+
185
+ /**
186
+ * TASK-164 (m-9 P8): truncate body to N bytes when a positive cap is
187
+ * supplied. Returns the original string when cap ≤ 0 or content fits.
188
+ * Marker mirrors the existing DB-truncation marker so users see one
189
+ * consistent format.
190
+ */
191
+ export function capBody(content: string | null, capBytes: number | undefined): string | null {
192
+ if (!content) return content;
193
+ if (!capBytes || capBytes <= 0 || content.length <= capBytes) return content;
194
+ const head = content.slice(0, capBytes);
195
+ const dropped = content.length - capBytes;
196
+ return `${head}\n[truncated ${dropped} bytes; first ${capBytes} shown; full body in run DB]`;
197
+ }
198
+
199
+ function renderBody(label: string, content: string | null, capBytes?: number): string {
200
+ if (!content) return `<div class="code-label">${label}</div><div class="empty" style="padding:12px;font-size:11px">empty</div>`;
201
+ const capped = capBody(content, capBytes) ?? content;
202
+ const isJson = (() => { try { JSON.parse(capped); return true; } catch { return false; } })();
203
+ const lang = isJson ? "json" : "text";
204
+ const display = isJson ? tryPrettyJson(capped) : capped;
205
+ return `<div class="code-label">${label}</div><pre class="code" data-lang="${lang}">${escapeHtml(display)}</pre>`;
206
+ }
207
+
208
+ function buildIssueMarkdown(step: StoredStepResult, run: RunRecord): string {
209
+ const fc = step.failure_class ? FAILURE_CLASS_META[step.failure_class].label : "Unclassified";
210
+ const lines: string[] = [];
211
+ lines.push(`## ${step.test_name}`);
212
+ lines.push("");
213
+ lines.push(`**Endpoint:** \`${step.request_method ?? "?"} ${step.request_url ?? "?"}\` `);
214
+ lines.push(`**Status:** ${step.response_status ?? "—"} · **Result:** ${step.status} · **Class:** ${fc}`);
215
+ if (step.failure_class_reason) {
216
+ lines.push(`**Reason:** ${step.failure_class_reason}`);
217
+ }
218
+ lines.push(`**Run:** zond run #${run.id} (${run.started_at})`);
219
+ lines.push("");
220
+ lines.push("### Reproduce");
221
+ lines.push("```sh");
222
+ lines.push(buildCurl(step));
223
+ lines.push("```");
224
+ if (step.response_body) {
225
+ lines.push("");
226
+ lines.push("### Response body");
227
+ lines.push("```json");
228
+ lines.push(tryPrettyJson(step.response_body));
229
+ lines.push("```");
230
+ }
231
+ if (step.spec_pointer) {
232
+ lines.push("");
233
+ lines.push(`**OpenAPI pointer:** \`${step.spec_pointer}\``);
234
+ }
235
+ const failedA = step.assertions.filter((a) => !a.passed);
236
+ if (failedA.length > 0) {
237
+ lines.push("");
238
+ lines.push("### Failed assertions");
239
+ for (const a of failedA) {
240
+ lines.push(`- \`${a.rule}\` at \`${a.field}\`: expected ${JSON.stringify(a.expected)}, got ${JSON.stringify(a.actual)}`);
241
+ }
242
+ }
243
+ lines.push("");
244
+ lines.push("---");
245
+ lines.push(`_Generated by [zond](${REPO_URL})._`);
246
+ return lines.join("\n");
247
+ }
248
+
249
+ function renderFailureCard(step: StoredStepResult, run: RunRecord, capBytes?: number): string {
250
+ const method = (step.request_method ?? "—").toUpperCase();
251
+ const fcKey = step.failure_class ?? "unclassified";
252
+ const curl = buildCurl(step);
253
+ const issueMd = buildIssueMarkdown(step, run);
254
+
255
+ return `<li class="card" data-fclass="${escapeHtml(fcKey)}">
256
+ <button type="button" class="head">
257
+ <svg class="chev" viewBox="0 0 16 16" fill="currentColor"><path d="M5.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06L6.28 11.78a.75.75 0 0 1-1.06-1.06L7.94 8 5.22 5.28a.75.75 0 0 1 0-1.06Z"/></svg>
258
+ <span class="method ${method}">${escapeHtml(method)}</span>
259
+ <span class="name" title="${escapeHtml(step.test_name)}">${escapeHtml(step.test_name)}</span>
260
+ <span class="badges">
261
+ ${failureClassBadge(step.failure_class, step.failure_class_reason)}
262
+ ${badgeForStatus(step.response_status)}
263
+ <span class="badge ${STATUS_LABEL[step.status]?.cls ?? ""}">${STATUS_LABEL[step.status]?.label ?? step.status}</span>
264
+ </span>
265
+ </button>
266
+ <div class="body">
267
+ <div class="actions">
268
+ <button class="btn" data-copy="curl">📋 Copy curl</button>
269
+ <button class="btn" data-copy="issue">🐙 Copy as GitHub issue</button>
270
+ </div>
271
+ <div class="tabs">
272
+ <button class="active" data-tab="response">Response</button>
273
+ <button data-tab="request">Request</button>
274
+ <button data-tab="assertions">Assertions (${step.assertions.length})</button>
275
+ <button data-tab="source">Source</button>
276
+ </div>
277
+ <div class="panel active" data-tab="response">
278
+ <dl class="kv">
279
+ <dt>Status</dt><dd>${step.response_status ?? "—"}</dd>
280
+ <dt>Duration</dt><dd>${formatDuration(step.duration_ms)}</dd>
281
+ ${step.error_message ? `<dt>Error</dt><dd style="color:var(--fail)">${escapeHtml(step.error_message)}</dd>` : ""}
282
+ </dl>
283
+ <div class="code-label">Headers</div>${renderHeaders(step.response_headers)}
284
+ ${renderBody("Body", step.response_body, capBytes)}
285
+ </div>
286
+ <div class="panel" data-tab="request">
287
+ <dl class="kv">
288
+ <dt>Method</dt><dd class="mono">${escapeHtml(method)}</dd>
289
+ <dt>URL</dt><dd class="mono">${escapeHtml(step.request_url ?? "—")}</dd>
290
+ </dl>
291
+ ${renderBody("Body", step.request_body, capBytes)}
292
+ </div>
293
+ <div class="panel" data-tab="assertions">${renderAssertions(step.assertions)}</div>
294
+ <div class="panel" data-tab="source">
295
+ ${renderProvenance(step.provenance)}
296
+ ${renderSpecSnippet(step.spec_pointer, step.spec_excerpt)}
297
+ ${!step.provenance && !step.spec_pointer && !step.spec_excerpt ? `<div class="empty" style="padding:16px">No source metadata recorded.</div>` : ""}
298
+ </div>
299
+ <pre data-payload="curl" hidden>${escapeHtml(curl)}</pre>
300
+ <pre data-payload="issue" hidden>${escapeHtml(issueMd)}</pre>
301
+ </div>
302
+ </li>`;
303
+ }
304
+
305
+ function renderRing(passRate: number, totalLabel: string): string {
306
+ const r = 56;
307
+ const C = 2 * Math.PI * r;
308
+ const offset = C * (1 - passRate / 100);
309
+ const color = passRate >= 90 ? "var(--pass)" : passRate >= 60 ? "var(--warn)" : "var(--fail)";
310
+ return `<div class="ring">
311
+ <svg viewBox="0 0 140 140">
312
+ <circle class="track" cx="70" cy="70" r="${r}" fill="none" stroke-width="12"/>
313
+ <circle class="fill" cx="70" cy="70" r="${r}" fill="none" stroke-width="12"
314
+ stroke="${color}" stroke-linecap="round"
315
+ stroke-dasharray="${C.toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}"/>
316
+ </svg>
317
+ <div class="label"><div class="pct">${passRate.toFixed(0)}%</div><div class="lbl">${totalLabel}</div></div>
318
+ </div>`;
319
+ }
320
+
321
+ function renderCoverage(rows: CoverageRow[]): string {
322
+ if (rows.length === 0) return "";
323
+ const cellCls: Record<NonNullable<CoverageRow["buckets"][string]>, string> = {
324
+ ok: "s2",
325
+ "4xx": "s4",
326
+ "5xx": "s5",
327
+ err: "serr",
328
+ };
329
+ const cellLabel: Record<NonNullable<CoverageRow["buckets"][string]>, string> = {
330
+ ok: "2xx",
331
+ "4xx": "4xx",
332
+ "5xx": "5xx",
333
+ err: "ERR",
334
+ };
335
+ const header = `<div class="cov-row">
336
+ <div class="cov-cell head path">Endpoint</div>
337
+ ${METHODS.map((m) => `<div class="cov-cell head">${m}</div>`).join("")}
338
+ </div>`;
339
+ const body = rows.map((row) => {
340
+ const cells = METHODS.map((m) => {
341
+ const v = row.buckets[m];
342
+ return v
343
+ ? `<div class="cov-cell ${cellCls[v]}">${cellLabel[v]}</div>`
344
+ : `<div class="cov-cell empty">·</div>`;
345
+ }).join("");
346
+ return `<div class="cov-row"><div class="cov-cell path mono" title="${escapeHtml(row.endpoint)}">${escapeHtml(row.endpoint)}</div>${cells}</div>`;
347
+ }).join("");
348
+ return `<section>
349
+ <h2>Coverage map <span class="count">${rows.length} endpoint${rows.length === 1 ? "" : "s"} touched</span></h2>
350
+ <div class="cov-grid">${header}${body}</div>
351
+ </section>`;
352
+ }
353
+
354
+ function renderCoverageWithReasons(matrix: CoverageMatrix): string {
355
+ if (matrix.rows.length === 0) return "";
356
+ const classes: StatusClass[] = ["2xx", "4xx", "5xx"];
357
+ const cellCls = (s: "covered" | "partial" | "uncovered") =>
358
+ s === "covered" ? "s2" : s === "partial" ? "s4" : "su";
359
+ const header = `<div class="cov-row reasons">
360
+ <div class="cov-cell head path">Endpoint</div>
361
+ ${classes.map((c) => `<div class="cov-cell head">${c}</div>`).join("")}
362
+ </div>`;
363
+ const body = matrix.rows.map((row) => {
364
+ const cells = classes.map((c) => {
365
+ const cell = row.cells[c];
366
+ const reasonChips = cell.reasons.map((r) =>
367
+ `<span class="rchip" title="${escapeHtml(r)}">${escapeHtml(REASON_LABEL[r])}</span>`,
368
+ ).join("");
369
+ return `<div class="cov-cell ${cellCls(cell.status)} reasons">${reasonChips}</div>`;
370
+ }).join("");
371
+ const tagBadges = row.tags.length > 0
372
+ ? row.tags.map((t) => `<span class="badge muted">${escapeHtml(t)}</span>`).join("")
373
+ : "";
374
+ const deprecated = row.deprecated ? `<span class="badge warn">deprecated</span>` : "";
375
+ return `<div class="cov-row reasons">
376
+ <div class="cov-cell path mono"><span class="method-mini">${escapeHtml(row.method)}</span> ${escapeHtml(row.path)} ${deprecated}${tagBadges}</div>
377
+ ${cells}
378
+ </div>`;
379
+ }).join("");
380
+ const t = matrix.totals;
381
+ const pct = t.cells === 0 ? 0 : Math.round((t.covered / t.cells) * 1000) / 10;
382
+ return `<section>
383
+ <h2>Coverage map <span class="count">${pct}% covered · ${t.endpoints} endpoint${t.endpoints === 1 ? "" : "s"} × 3 classes</span></h2>
384
+ <div class="cov-grid wide">${header}${body}</div>
385
+ </section>`;
386
+ }
387
+
388
+ export function renderHtmlReport(opts: RenderOptions): string {
389
+ const { run, results, zondVersion, generatedAt, collectionName } = opts;
390
+ const failures = results.filter((r) => r.status !== "pass" && r.status !== "skip");
391
+ const passed = results.filter((r) => r.status === "pass").length;
392
+ const total = results.length;
393
+ const passRate = total > 0 ? (passed / total) * 100 : 0;
394
+ const baseUrl = opts.baseUrl ?? pickBaseUrl(results);
395
+ const errored = results.filter((r) => r.status === "error").length;
396
+ const coverage = buildCoverage(results);
397
+
398
+ // Failure-class breakdown
399
+ const fcCounts: Record<string, number> = {};
400
+ for (const f of failures) {
401
+ const k = f.failure_class ?? "unclassified";
402
+ fcCounts[k] = (fcCounts[k] ?? 0) + 1;
403
+ }
404
+ const fcKeys = Object.keys(fcCounts);
405
+
406
+ const title = collectionName
407
+ ? `${collectionName} · Run #${run.id}`
408
+ : `zond Run #${run.id}`;
409
+
410
+ const filterButtons = `<div class="filters">
411
+ <button class="active" data-filter="all">All (${failures.length})</button>
412
+ ${fcKeys.map((k) => {
413
+ const meta = k === "unclassified"
414
+ ? { label: "Unclassified", emoji: "?" }
415
+ : { label: FAILURE_CLASS_META[k as FailureClass].label, emoji: FAILURE_CLASS_META[k as FailureClass].emoji };
416
+ return `<button data-filter="${escapeHtml(k)}">${meta.emoji} ${meta.label} (${fcCounts[k]})</button>`;
417
+ }).join("")}
418
+ </div>`;
419
+
420
+ const failuresSection = failures.length === 0
421
+ ? `<section>
422
+ <h2>Failures</h2>
423
+ <div class="empty">🎉 All ${total} step${total === 1 ? "" : "s"} passed — nothing to investigate.</div>
424
+ </section>`
425
+ : `<section>
426
+ <h2>Failures <span class="count">${failures.length} of ${total} step${total === 1 ? "" : "s"}</span></h2>
427
+ ${fcKeys.length > 0 ? filterButtons : ""}
428
+ <ul class="cards">
429
+ ${failures.map((f) => renderFailureCard(f, run, opts.bodyCapBytes)).join("")}
430
+ </ul>
431
+ </section>`;
432
+
433
+ return `<!doctype html>
434
+ <html lang="en">
435
+ <head>
436
+ <meta charset="utf-8">
437
+ <meta name="viewport" content="width=device-width, initial-scale=1">
438
+ <title>${escapeHtml(title)}</title>
439
+ <meta name="generator" content="zond ${escapeHtml(zondVersion)}">
440
+ <style>${STYLES}</style>
441
+ </head>
442
+ <body>
443
+ <div class="container">
444
+ <header class="hero">
445
+ <div>
446
+ <h1>${escapeHtml(title)}</h1>
447
+ <div class="sub">${escapeHtml(run.environment ?? "no environment")} ${baseUrl ? `· <span class="mono">${escapeHtml(baseUrl)}</span>` : ""}</div>
448
+ <dl class="meta">
449
+ <div><dt>Started</dt><dd>${formatDate(run.started_at)}</dd></div>
450
+ <div><dt>Finished</dt><dd>${formatDate(run.finished_at)}</dd></div>
451
+ <div><dt>Duration</dt><dd>${formatDuration(run.duration_ms)}</dd></div>
452
+ <div><dt>Trigger</dt><dd>${escapeHtml(run.trigger ?? "—")}</dd></div>
453
+ <div><dt>Branch</dt><dd>${escapeHtml(run.branch ?? "—")}</dd></div>
454
+ <div><dt>Commit</dt><dd class="mono">${escapeHtml(run.commit_sha?.slice(0, 8) ?? "—")}</dd></div>
455
+ </dl>
456
+ </div>
457
+ ${renderRing(passRate, total === 0 ? "no tests" : `${passed}/${total} pass`)}
458
+ </header>
459
+
460
+ <div class="kpis">
461
+ <div class="kpi"><div class="n">${total}</div><div class="l">Total</div></div>
462
+ <div class="kpi pass"><div class="n">${passed}</div><div class="l">Passed</div></div>
463
+ <div class="kpi fail"><div class="n">${run.failed}</div><div class="l">Failed</div></div>
464
+ ${errored > 0 ? `<div class="kpi warn"><div class="n">${errored}</div><div class="l">Errored</div></div>` : ""}
465
+ ${run.skipped > 0 ? `<div class="kpi"><div class="n">${run.skipped}</div><div class="l">Skipped</div></div>` : ""}
466
+ </div>
467
+
468
+ ${failuresSection}
469
+ ${opts.coverageMatrix ? renderCoverageWithReasons(opts.coverageMatrix) : renderCoverage(coverage)}
470
+
471
+ <footer>
472
+ <span>zond <span class="mono">${escapeHtml(zondVersion)}</span> · generated ${escapeHtml(generatedAt.toISOString())}</span>
473
+ <span><a href="${escapeHtml(REPO_URL)}">${escapeHtml(REPO_URL.replace(/^https?:\/\//, ""))}</a></span>
474
+ </footer>
475
+ </div>
476
+ <script>${SCRIPT}</script>
477
+ </body>
478
+ </html>`;
479
+ }
@@ -0,0 +1,100 @@
1
+ // Inline JS for the report. No external deps. Uses RegExp() with string args
2
+ // to dodge backslash-escaping headaches inside the TS template literal.
3
+
4
+ export const SCRIPT = `
5
+ (() => {
6
+ // JSON highlighter: build regex from a string source.
7
+ var src = '("(?:\\\\\\\\.|[^"\\\\\\\\])*")(\\\\s*:)?|\\\\b(true|false|null)\\\\b|-?\\\\d+(?:\\\\.\\\\d+)?(?:[eE][+-]?\\\\d+)?';
8
+ var reJson = new RegExp(src, 'g');
9
+ function highlight(str) {
10
+ return str.replace(reJson, function (m, quoted, colon, kw) {
11
+ if (quoted) {
12
+ return colon
13
+ ? '<span class="j-key">' + quoted + '</span>' + colon
14
+ : '<span class="j-str">' + quoted + '</span>';
15
+ }
16
+ if (kw === 'true' || kw === 'false') return '<span class="j-bool">' + kw + '</span>';
17
+ if (kw === 'null') return '<span class="j-null">null</span>';
18
+ return '<span class="j-num">' + m + '</span>';
19
+ });
20
+ }
21
+ document.querySelectorAll('pre.code[data-lang="json"]').forEach(function (el) {
22
+ if (el.dataset.hl === '1') return;
23
+ el.dataset.hl = '1';
24
+ el.innerHTML = highlight(el.textContent || '');
25
+ });
26
+
27
+ // Card expand/collapse.
28
+ document.querySelectorAll('.card .head').forEach(function (btn) {
29
+ btn.addEventListener('click', function () {
30
+ btn.closest('.card').classList.toggle('open');
31
+ });
32
+ });
33
+
34
+ // Tabs (per card).
35
+ document.querySelectorAll('.tabs').forEach(function (tabs) {
36
+ tabs.querySelectorAll('button').forEach(function (btn) {
37
+ btn.addEventListener('click', function () {
38
+ var target = btn.dataset.tab;
39
+ var card = btn.closest('.card');
40
+ card.querySelectorAll('.tabs button').forEach(function (b) {
41
+ b.classList.toggle('active', b === btn);
42
+ });
43
+ card.querySelectorAll('.panel').forEach(function (p) {
44
+ p.classList.toggle('active', p.dataset.tab === target);
45
+ });
46
+ });
47
+ });
48
+ });
49
+
50
+ function flash(btn, ok) {
51
+ var orig = btn.dataset.label || btn.textContent;
52
+ btn.dataset.label = orig;
53
+ btn.textContent = ok ? '✓ Copied' : '✗ Failed';
54
+ btn.classList.add('copied');
55
+ setTimeout(function () {
56
+ btn.textContent = orig;
57
+ btn.classList.remove('copied');
58
+ }, 1500);
59
+ }
60
+ function copyText(text, btn) {
61
+ if (navigator.clipboard && navigator.clipboard.writeText) {
62
+ navigator.clipboard.writeText(text).then(
63
+ function () { flash(btn, true); },
64
+ function () { fallbackCopy(text, btn); },
65
+ );
66
+ } else {
67
+ fallbackCopy(text, btn);
68
+ }
69
+ }
70
+ function fallbackCopy(text, btn) {
71
+ var ta = document.createElement('textarea');
72
+ ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
73
+ document.body.appendChild(ta); ta.select();
74
+ try { document.execCommand('copy'); flash(btn, true); }
75
+ catch (e) { flash(btn, false); }
76
+ finally { document.body.removeChild(ta); }
77
+ }
78
+ document.querySelectorAll('[data-copy]').forEach(function (btn) {
79
+ btn.addEventListener('click', function () {
80
+ var sel = btn.dataset.copy;
81
+ var src = btn.closest('.card').querySelector('[data-payload="' + sel + '"]');
82
+ var text = src ? (src.textContent || '') : '';
83
+ copyText(text, btn);
84
+ });
85
+ });
86
+
87
+ // Failure-class filter.
88
+ var filterBtns = document.querySelectorAll('.filters [data-filter]');
89
+ filterBtns.forEach(function (btn) {
90
+ btn.addEventListener('click', function () {
91
+ var f = btn.dataset.filter;
92
+ filterBtns.forEach(function (b) { b.classList.toggle('active', b === btn); });
93
+ document.querySelectorAll('.cards .card').forEach(function (c) {
94
+ var fc = c.dataset.fclass || 'unclassified';
95
+ c.classList.toggle('hidden', f !== 'all' && fc !== f);
96
+ });
97
+ });
98
+ });
99
+ })();
100
+ `;