paseo-plugin-genui 0.0.0-stage → 0.1.1

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 (375) hide show
  1. package/LICENSE +202 -0
  2. package/OVERVIEW.md +21 -0
  3. package/README.md +77 -2
  4. package/assets/fonts/Excalifont-OFL.txt +109 -0
  5. package/assets/fonts/Excalifont-Regular-latin.woff2 +0 -0
  6. package/assets/icon.png +0 -0
  7. package/assets/icon.svg +1 -0
  8. package/assets/screenshot-chat.png +0 -0
  9. package/assets/screenshot-gallery.png +0 -0
  10. package/assets/screenshot-playground.png +0 -0
  11. package/client/components/content.tsx +468 -0
  12. package/client/components/data.tsx +967 -0
  13. package/client/components/dev.tsx +998 -0
  14. package/client/components/diagram.tsx +513 -0
  15. package/client/components/inputs.tsx +994 -0
  16. package/client/components/layout.tsx +239 -0
  17. package/client/components/media.tsx +428 -0
  18. package/client/components/project.tsx +641 -0
  19. package/client/components/widgets.tsx +836 -0
  20. package/client/gallery.tsx +449 -0
  21. package/client/live-ui.tsx +525 -0
  22. package/client/loaders.ts +342 -0
  23. package/client/markdown.tsx +172 -0
  24. package/client/node-renderer.tsx +149 -0
  25. package/client/render.tsx +186 -0
  26. package/client/runtime.ts +103 -0
  27. package/client/settings.tsx +57 -0
  28. package/client/web.ts +186 -0
  29. package/gallery/Acceptance-baseline.ui +1 -0
  30. package/gallery/Acceptance-checklist.ui +14 -0
  31. package/gallery/Acceptance-evidence.ui +9 -0
  32. package/gallery/Acceptance.ui +1 -0
  33. package/gallery/Badge.ui +3 -0
  34. package/gallery/Benchmark-context.ui +8 -0
  35. package/gallery/Benchmark-hyperfine.ui +1 -0
  36. package/gallery/Benchmark-runs-list.ui +9 -0
  37. package/gallery/Benchmark-runs.ui +1 -0
  38. package/gallery/Benchmark-vitest.ui +1 -0
  39. package/gallery/Benchmark.ui +9 -0
  40. package/gallery/Board-bb-tasks.ui +1 -0
  41. package/gallery/Board-gh-issues.ui +1 -0
  42. package/gallery/Board-gh-prs.ui +1 -0
  43. package/gallery/Board.ui +12 -0
  44. package/gallery/Button-destructive.ui +4 -0
  45. package/gallery/Button.ui +5 -0
  46. package/gallery/Calendar.ui +15 -0
  47. package/gallery/CalendarHeatmap-short.ui +11 -0
  48. package/gallery/CalendarHeatmap.ui +4 -0
  49. package/gallery/Callout.ui +4 -0
  50. package/gallery/Card.ui +4 -0
  51. package/gallery/Carousel.ui +8 -0
  52. package/gallery/Changes-paths.ui +1 -0
  53. package/gallery/Changes-uncommitted.ui +1 -0
  54. package/gallery/Changes.ui +1 -0
  55. package/gallery/Chart-area.ui +3 -0
  56. package/gallery/Chart-funnel.ui +3 -0
  57. package/gallery/Chart-heatmap.ui +11 -0
  58. package/gallery/Chart-pie.ui +3 -0
  59. package/gallery/Chart-radar.ui +3 -0
  60. package/gallery/Chart-scatter.ui +4 -0
  61. package/gallery/Chart-stacked.ui +5 -0
  62. package/gallery/Chart.ui +3 -0
  63. package/gallery/Checklist.ui +6 -0
  64. package/gallery/Choice.ui +8 -0
  65. package/gallery/Code.ui +3 -0
  66. package/gallery/Commits-limit.ui +1 -0
  67. package/gallery/Commits.ui +1 -0
  68. package/gallery/Compare-conditions.ui +4 -0
  69. package/gallery/Compare-three.ui +7 -0
  70. package/gallery/Compare.ui +4 -0
  71. package/gallery/Coverage-go.ui +1 -0
  72. package/gallery/Coverage-lcov.ui +1 -0
  73. package/gallery/Coverage-python.ui +1 -0
  74. package/gallery/Coverage.ui +1 -0
  75. package/gallery/DateInput.ui +5 -0
  76. package/gallery/Decision-percent.ui +10 -0
  77. package/gallery/Decision.ui +12 -0
  78. package/gallery/Dependencies-audit.ui +1 -0
  79. package/gallery/Dependencies-both.ui +1 -0
  80. package/gallery/Dependencies-list.ui +7 -0
  81. package/gallery/Dependencies-pip.ui +1 -0
  82. package/gallery/Dependencies-pnpm.ui +1 -0
  83. package/gallery/Dependencies.ui +1 -0
  84. package/gallery/Details.ui +4 -0
  85. package/gallery/Diagram-flow.ui +16 -0
  86. package/gallery/Diagram-sequence.ui +15 -0
  87. package/gallery/Diagram.ui +16 -0
  88. package/gallery/Diff.ui +3 -0
  89. package/gallery/Divider.ui +5 -0
  90. package/gallery/EditableTable.ui +9 -0
  91. package/gallery/Entity-list.ui +6 -0
  92. package/gallery/Entity.ui +8 -0
  93. package/gallery/Environments.ui +1 -0
  94. package/gallery/Estimate-points.ui +6 -0
  95. package/gallery/Estimate.ui +8 -0
  96. package/gallery/File.ui +4 -0
  97. package/gallery/Findings-biome.ui +1 -0
  98. package/gallery/Findings-eslint.ui +1 -0
  99. package/gallery/Findings-oxlint.ui +1 -0
  100. package/gallery/Findings-review.ui +1 -0
  101. package/gallery/Findings-ruff.ui +1 -0
  102. package/gallery/Findings-sarif.ui +1 -0
  103. package/gallery/Findings-ship.ui +11 -0
  104. package/gallery/Findings-tsc.ui +1 -0
  105. package/gallery/Findings.ui +7 -0
  106. package/gallery/Flashcards.ui +3 -0
  107. package/gallery/FollowUps.ui +4 -0
  108. package/gallery/Form.ui +8 -0
  109. package/gallery/Gallery-links.ui +8 -0
  110. package/gallery/Gallery-strip.ui +14 -0
  111. package/gallery/Gallery.ui +10 -0
  112. package/gallery/Gauge.ui +7 -0
  113. package/gallery/Grid.ui +8 -0
  114. package/gallery/Hotspots-photo.ui +8 -0
  115. package/gallery/Hotspots.ui +11 -0
  116. package/gallery/Icon.ui +5 -0
  117. package/gallery/Image.ui +4 -0
  118. package/gallery/ImageSearch-aspects.ui +6 -0
  119. package/gallery/ImageSearch.ui +4 -0
  120. package/gallery/Input.ui +5 -0
  121. package/gallery/Json-api.ui +1 -0
  122. package/gallery/Json.ui +8 -0
  123. package/gallery/KeyValue.ui +3 -0
  124. package/gallery/List-dots.ui +8 -0
  125. package/gallery/List.ui +8 -0
  126. package/gallery/ListEditor.ui +5 -0
  127. package/gallery/Logs-jsonl.ui +1 -0
  128. package/gallery/Logs-lines.ui +11 -0
  129. package/gallery/Logs-python.ui +1 -0
  130. package/gallery/Logs.ui +1 -0
  131. package/gallery/Map-city.ui +8 -0
  132. package/gallery/Map.ui +8 -0
  133. package/gallery/Metric.ui +3 -0
  134. package/gallery/OptionCards-multi.ui +10 -0
  135. package/gallery/OptionCards.ui +10 -0
  136. package/gallery/Pipeline-checks.ui +1 -0
  137. package/gallery/Pipeline-passed.ui +1 -0
  138. package/gallery/Pipeline-running.ui +1 -0
  139. package/gallery/Pipeline-stages.ui +13 -0
  140. package/gallery/Pipeline.ui +1 -0
  141. package/gallery/Plan.ui +9 -0
  142. package/gallery/Progress.ui +4 -0
  143. package/gallery/Provenance-deploy.ui +6 -0
  144. package/gallery/Provenance-no-receipt.ui +1 -0
  145. package/gallery/Provenance.ui +1 -0
  146. package/gallery/PullRequest.ui +1 -0
  147. package/gallery/Quiz.ui +6 -0
  148. package/gallery/Radio.ui +5 -0
  149. package/gallery/Rating.ui +5 -0
  150. package/gallery/Roadmap-sprint.ui +8 -0
  151. package/gallery/Roadmap-year.ui +9 -0
  152. package/gallery/Roadmap.ui +12 -0
  153. package/gallery/Row.ui +3 -0
  154. package/gallery/SegmentedBar-status.ui +8 -0
  155. package/gallery/SegmentedBar.ui +8 -0
  156. package/gallery/Select.ui +5 -0
  157. package/gallery/Slider.ui +7 -0
  158. package/gallery/Snippet.ui +3 -0
  159. package/gallery/Sources-chips.ui +4 -0
  160. package/gallery/Sources.ui +8 -0
  161. package/gallery/Sparkline.ui +6 -0
  162. package/gallery/Stack.ui +7 -0
  163. package/gallery/StackTrace-aggregate.ui +1 -0
  164. package/gallery/StackTrace-browser.ui +1 -0
  165. package/gallery/StackTrace-go.ui +1 -0
  166. package/gallery/StackTrace-java.ui +1 -0
  167. package/gallery/StackTrace-python.ui +1 -0
  168. package/gallery/StackTrace-rust.ui +1 -0
  169. package/gallery/StackTrace.ui +1 -0
  170. package/gallery/Status.ui +9 -0
  171. package/gallery/Stepper.ui +5 -0
  172. package/gallery/Steps.ui +8 -0
  173. package/gallery/Swatches-long-names.ui +4 -0
  174. package/gallery/Swatches-picker.ui +5 -0
  175. package/gallery/Swatches.ui +10 -0
  176. package/gallery/Table.ui +7 -0
  177. package/gallery/Tabs.ui +8 -0
  178. package/gallery/Tests-junit.ui +1 -0
  179. package/gallery/Tests-vitest-junit.ui +1 -0
  180. package/gallery/Tests-vitest.ui +1 -0
  181. package/gallery/Tests.ui +9 -0
  182. package/gallery/Text.ui +4 -0
  183. package/gallery/TextArea.ui +5 -0
  184. package/gallery/Threads-ids.ui +1 -0
  185. package/gallery/Threads.ui +1 -0
  186. package/gallery/Timeline.ui +8 -0
  187. package/gallery/Timer.ui +4 -0
  188. package/gallery/Toggle.ui +5 -0
  189. package/gallery/Transcript-text.ui +1 -0
  190. package/gallery/Transcript-untimed.ui +1 -0
  191. package/gallery/Transcript-whisper.ui +1 -0
  192. package/gallery/Transcript.ui +1 -0
  193. package/gallery/Tree.ui +3 -0
  194. package/gallery/Treemap-du.ui +1 -0
  195. package/gallery/Treemap-esbuild.ui +1 -0
  196. package/gallery/Treemap-tree.ui +7 -0
  197. package/gallery/Treemap.ui +18 -0
  198. package/gallery/Walkthrough.ui +9 -0
  199. package/gallery/Waterfall-har.ui +1 -0
  200. package/gallery/Waterfall-trace.ui +1 -0
  201. package/gallery/Waterfall.ui +11 -0
  202. package/gallery/data/acceptance/coil/viewport-lifecycle.json +25 -0
  203. package/gallery/data/acceptance/coil-gate.json +153 -0
  204. package/gallery/data/acceptance/orbit-baseline.json +163 -0
  205. package/gallery/data/acceptance/shots/gate-cell-opened.png +0 -0
  206. package/gallery/data/acceptance/shots/gate-light.png +0 -0
  207. package/gallery/data/acceptance/shots/gate-phone-dark.png +0 -0
  208. package/gallery/data/activity.json +1 -0
  209. package/gallery/data/bb-tasks-orb.json +420 -0
  210. package/gallery/data/biome.json +229 -0
  211. package/gallery/data/compare/status-after.png +0 -0
  212. package/gallery/data/compare/status-before.png +0 -0
  213. package/gallery/data/compare/status-dark-2x.png +0 -0
  214. package/gallery/data/compare/status-rerender.png +0 -0
  215. package/gallery/data/coverage-py.json +1 -0
  216. package/gallery/data/coverage-summary.json +1 -0
  217. package/gallery/data/coverage.out +23 -0
  218. package/gallery/data/deploys.json +1 -0
  219. package/gallery/data/device-runs.json +285 -0
  220. package/gallery/data/du-node-modules.txt +359 -0
  221. package/gallery/data/esbuild-meta.json +292 -0
  222. package/gallery/data/eslint.json +266 -0
  223. package/gallery/data/eslint.sarif +343 -0
  224. package/gallery/data/findings-review.json +76 -0
  225. package/gallery/data/gh-issues-nextjs.json +293 -0
  226. package/gallery/data/gh-pr-checks.json +272 -0
  227. package/gallery/data/gh-prs-nextjs.json +419 -0
  228. package/gallery/data/gh-run-failed.json +1 -0
  229. package/gallery/data/gh-run-passed.json +1 -0
  230. package/gallery/data/gh-run-running.json +1 -0
  231. package/gallery/data/github-release.json +57 -0
  232. package/gallery/data/hyperfine-startup.json +187 -0
  233. package/gallery/data/lcov.info +234 -0
  234. package/gallery/data/logs/media-sync.log +127 -0
  235. package/gallery/data/logs/thumbnailer.jsonl +17 -0
  236. package/gallery/data/logs/vitest-run.log +89 -0
  237. package/gallery/data/npm-audit.json +672 -0
  238. package/gallery/data/npm-outdated.json +179 -0
  239. package/gallery/data/oxlint.json +156 -0
  240. package/gallery/data/pip-outdated.json +68 -0
  241. package/gallery/data/pnpm-outdated.json +58 -0
  242. package/gallery/data/provenance/devicectl-install.json +35 -0
  243. package/gallery/data/provenance/install-timeout.log +4 -0
  244. package/gallery/data/provenance/iphone-install.json +72 -0
  245. package/gallery/data/provenance/iphone-no-receipt.json +57 -0
  246. package/gallery/data/provenance/live-home.jpg +0 -0
  247. package/gallery/data/provenance/live-probe.txt +10 -0
  248. package/gallery/data/provenance/xcodebuild-2811.log +19 -0
  249. package/gallery/data/provenance/xcodebuild-4712.log +19 -0
  250. package/gallery/data/pytest-junit.xml +64 -0
  251. package/gallery/data/python-org-trace.json +35 -0
  252. package/gallery/data/python-org.har +31 -0
  253. package/gallery/data/retry.ts +13 -0
  254. package/gallery/data/ruff.json +261 -0
  255. package/gallery/data/src/durations.py +63 -0
  256. package/gallery/data/src/json-logic.ts +156 -0
  257. package/gallery/data/src/mapMath.ts +135 -0
  258. package/gallery/data/src/semver/bump.go +73 -0
  259. package/gallery/data/traces/browser-error.json +5 -0
  260. package/gallery/data/traces/go/main.go +27 -0
  261. package/gallery/data/traces/go-panic.txt +16 -0
  262. package/gallery/data/traces/java-caused-by.txt +15 -0
  263. package/gallery/data/traces/node-aggregate.txt +30 -0
  264. package/gallery/data/traces/node-cause.txt +21 -0
  265. package/gallery/data/traces/py/deploy.json +5 -0
  266. package/gallery/data/traces/py/deploy.py +17 -0
  267. package/gallery/data/traces/py/settings.py +19 -0
  268. package/gallery/data/traces/python-chain.txt +30 -0
  269. package/gallery/data/traces/rust/main.rs +11 -0
  270. package/gallery/data/traces/rust-panic.txt +16 -0
  271. package/gallery/data/traces/shop/cart.mjs +19 -0
  272. package/gallery/data/traces/shop/pricing.mjs +12 -0
  273. package/gallery/data/traces/shop/status.mjs +9 -0
  274. package/gallery/data/traces/web/app.js +11 -0
  275. package/gallery/data/traces/web/index.html +1 -0
  276. package/gallery/data/traces/web/search.js +10 -0
  277. package/gallery/data/transcript/call-noisy.wav +0 -0
  278. package/gallery/data/transcript/call.json +362 -0
  279. package/gallery/data/transcript/call.wav +0 -0
  280. package/gallery/data/transcript/mlx-whisper-tiny.en-noisy.json +1 -0
  281. package/gallery/data/transcript/whisper-cpp-base.en-noisy.json +658 -0
  282. package/gallery/data/transcript/whisper-cpp-base.en.json +658 -0
  283. package/gallery/data/transcript/whisper-cpp-base.en.srt +16 -0
  284. package/gallery/data/transcript/whisper-cpp-tiny.en-noisy.json +619 -0
  285. package/gallery/data/tsc.txt +4 -0
  286. package/gallery/data/vitest-bench.json +119 -0
  287. package/gallery/data/vitest-junit.xml +133 -0
  288. package/gallery/data/vitest-report.json +612 -0
  289. package/gallery/images/bell.svg +10 -0
  290. package/gallery/images/bike.svg +47 -0
  291. package/gallery/images/footer-planet.webp +0 -0
  292. package/gallery/images/front-light.svg +13 -0
  293. package/gallery/images/golden-hour.webp +0 -0
  294. package/gallery/images/option-flowers.webp +0 -0
  295. package/gallery/images/option-hilltop.webp +0 -0
  296. package/gallery/images/option-meadow.webp +0 -0
  297. package/gallery/images/option-sky.webp +0 -0
  298. package/gallery/images/u-lock.svg +10 -0
  299. package/index.client.tsx +164 -0
  300. package/index.server.ts +216 -0
  301. package/package.json +50 -4
  302. package/paseo-plugin.json +14 -0
  303. package/server/data-source.ts +461 -0
  304. package/server/project.ts +981 -0
  305. package/server/web/address.ts +57 -0
  306. package/server/web/cache.ts +85 -0
  307. package/server/web/commons.ts +126 -0
  308. package/server/web/geocode.ts +40 -0
  309. package/server/web/image.ts +35 -0
  310. package/server/web/lookups.ts +248 -0
  311. package/server/web/net.ts +210 -0
  312. package/shared/core/acceptance.ts +744 -0
  313. package/shared/core/ast.ts +76 -0
  314. package/shared/core/benchmark.ts +743 -0
  315. package/shared/core/catalog.ts +733 -0
  316. package/shared/core/compare.ts +225 -0
  317. package/shared/core/coverage.ts +456 -0
  318. package/shared/core/data.ts +334 -0
  319. package/shared/core/decisions.ts +527 -0
  320. package/shared/core/dependencies.ts +604 -0
  321. package/shared/core/diagnostics.ts +24 -0
  322. package/shared/core/environments.ts +212 -0
  323. package/shared/core/evaluate.ts +737 -0
  324. package/shared/core/fields.ts +66 -0
  325. package/shared/core/findings.ts +1172 -0
  326. package/shared/core/form.ts +69 -0
  327. package/shared/core/index.ts +44 -0
  328. package/shared/core/junit.ts +252 -0
  329. package/shared/core/lexer.ts +275 -0
  330. package/shared/core/logs.ts +496 -0
  331. package/shared/core/markdown.ts +2374 -0
  332. package/shared/core/parser.ts +355 -0
  333. package/shared/core/perf-units.ts +228 -0
  334. package/shared/core/pipeline.ts +679 -0
  335. package/shared/core/project.ts +472 -0
  336. package/shared/core/provenance.ts +725 -0
  337. package/shared/core/stack-trace.ts +718 -0
  338. package/shared/core/streaming.ts +136 -0
  339. package/shared/core/test-results.ts +562 -0
  340. package/shared/core/thread-activity.ts +203 -0
  341. package/shared/core/tracking.ts +887 -0
  342. package/shared/core/transcript.ts +1364 -0
  343. package/shared/core/treemap.ts +446 -0
  344. package/shared/core/types.ts +70 -0
  345. package/shared/core/validate.ts +574 -0
  346. package/shared/core/waterfall.ts +536 -0
  347. package/shared/diagram/flowchart.ts +205 -0
  348. package/shared/diagram/layout.ts +440 -0
  349. package/shared/diagram/markdown.ts +135 -0
  350. package/shared/diagram/sequence.ts +122 -0
  351. package/shared/diagram/zoom.ts +114 -0
  352. package/shared/fallback.ts +120 -0
  353. package/shared/genui.test.ts +182 -0
  354. package/shared/instructions.ts +144 -0
  355. package/shared/logic/calendarMath.ts +130 -0
  356. package/shared/logic/chartMath.ts +289 -0
  357. package/shared/logic/choose-logic.ts +196 -0
  358. package/shared/logic/compare-logic.ts +270 -0
  359. package/shared/logic/content-model.ts +227 -0
  360. package/shared/logic/environments-logic.ts +53 -0
  361. package/shared/logic/explain-logic.ts +315 -0
  362. package/shared/logic/fieldLogic.ts +138 -0
  363. package/shared/logic/gallery-search.ts +71 -0
  364. package/shared/logic/icons.ts +322 -0
  365. package/shared/logic/json-logic.ts +153 -0
  366. package/shared/logic/mapMath.ts +133 -0
  367. package/shared/logic/project-logic.ts +39 -0
  368. package/shared/logic/references-model.ts +201 -0
  369. package/shared/logic/sketch.ts +165 -0
  370. package/shared/logic/supersession.ts +77 -0
  371. package/shared/logic/values.ts +133 -0
  372. package/shared/logic/widget-logic.ts +163 -0
  373. package/shared/rpc.ts +252 -0
  374. package/shared/segment.ts +81 -0
  375. package/shared/settings.ts +12 -0
@@ -0,0 +1,744 @@
1
+ // Acceptance: is it really done? Reads acceptance criteria as agents write
2
+ // them (a list with one status each, or a matrix of conditions per
3
+ // criterion), a gate or owner result through `Load`, or a flat CSV, into one
4
+ // report, and says what is proven, by whom, and what is still open. Pure: the
5
+ // UI (ui/components/Acceptance.tsx) and the markdown fallback both read it.
6
+ //
7
+ // Honesty rules: an unknown or missing result is "not tested", never passed;
8
+ // what an owner reported is "claimed", never passed; "done" is a claim.
9
+ import { isPlainObject } from "./evaluate";
10
+ import type { Value } from "./types";
11
+
12
+ export type AcceptanceRead = { readonly ok: true; readonly report: AcceptanceReport } | { readonly ok: false; readonly error: string };
13
+
14
+ export type AcceptanceStatus = "passed" | "failed" | "blocked" | "untested" | "claimed" | "skipped";
15
+
16
+ /** Worst first: how a criterion reads when its conditions disagree, and the order counts are listed in. */
17
+ export const STATUS_ORDER: readonly AcceptanceStatus[] = ["failed", "blocked", "untested", "skipped", "claimed", "passed"];
18
+
19
+ /** Evidence a check points at: a file (at a line), a web page, an image, or what a tool printed. */
20
+ export type Evidence =
21
+ | { readonly kind: "file"; readonly path: string; readonly line: number | null; readonly endLine: number | null; readonly label: string | null }
22
+ | { readonly kind: "url"; readonly url: string; readonly label: string | null }
23
+ | { readonly kind: "image"; readonly src: string; readonly label: string | null }
24
+ | { readonly kind: "text"; readonly text: string; readonly label: string | null };
25
+
26
+ /** One result: a matrix cell, or a single-status criterion's own result. */
27
+ export interface Check {
28
+ readonly status: AcceptanceStatus;
29
+ /** The status as written when it says more than the canonical word ("not met", "pending", "no match"), or text that is no known status; null otherwise. */
30
+ readonly given: string | null;
31
+ /** False when `given` is no status this knows (read as not tested). */
32
+ readonly known: boolean;
33
+ /** Written as n/a: not counted as a gap, said as "not applicable". */
34
+ readonly notApplicable: boolean;
35
+ /** False when nothing was written for it (a matrix cell with no entry): "no result recorded". */
36
+ readonly recorded: boolean;
37
+ /** What it waits on from the user ("plug in the iPhone"); "" when the status only said so; null when it doesn't. */
38
+ readonly needs: string | null;
39
+ readonly note: string | null;
40
+ readonly by: string | null;
41
+ readonly method: string | null;
42
+ readonly run: string | null;
43
+ readonly at: string | null;
44
+ readonly evidence: readonly Evidence[];
45
+ }
46
+
47
+ export interface Criterion {
48
+ /** Position in the report, for keys. */
49
+ readonly index: number;
50
+ readonly name: string;
51
+ readonly detail: string | null;
52
+ readonly required: boolean;
53
+ /**
54
+ * The criterion's own result. In the matrix form its status is the worst of
55
+ * its cells (n/a cells aside) and its note, evidence and `by` are the
56
+ * row-level ones written on the criterion.
57
+ */
58
+ readonly own: Check;
59
+ /** One per shown column in the matrix form; null in the single-status form. */
60
+ readonly cells: readonly Check[] | null;
61
+ /** A status written on a matrix row that its cells contradict ("passed" with a failed cell); null otherwise. */
62
+ readonly stated: AcceptanceStatus | null;
63
+ }
64
+
65
+ export interface AcceptanceReport {
66
+ readonly title: string | null;
67
+ /** The condition columns shown, in order; empty in the single-status form. */
68
+ readonly columns: readonly string[];
69
+ /** Columns the data has that `columns` left out. */
70
+ readonly hiddenColumns: number;
71
+ readonly criteria: readonly Criterion[];
72
+ /** Items left out because they have no name (a half-written item while the reply streams). */
73
+ readonly unnamed: number;
74
+ /** What the owner's result said about itself: `complete: true|false`, a reviewer's `verdict`. */
75
+ readonly ownerComplete: boolean | null;
76
+ readonly statedVerdict: string | null;
77
+ /** Limitations an owner result lists. */
78
+ readonly limitations: readonly string[];
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Reading values
83
+
84
+ type Field = Value | undefined;
85
+ type Fields = { readonly [key: string]: Value };
86
+
87
+ function record(value: Field): Fields | null {
88
+ // Narrowing keeps structurally compatible Action shapes; widen to read by key.
89
+ return value !== undefined && isPlainObject(value) ? value : null;
90
+ }
91
+
92
+ function str(value: Field): string | null {
93
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
94
+ if (typeof value !== "string") return null;
95
+ const trimmed = value.trim();
96
+ return trimmed === "" ? null : trimmed;
97
+ }
98
+
99
+ /** The first of `keys` that holds a usable string. */
100
+ function pick(item: Fields, keys: readonly string[]): string | null {
101
+ for (const key of keys) {
102
+ const value = str(item[key]);
103
+ if (value !== null) return value;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ function strings(value: Field): string[] {
109
+ const items: readonly Value[] = Array.isArray(value) ? value : value === undefined ? [] : [value];
110
+ return items.flatMap((item) => {
111
+ const text = str(item) ?? pick(record(item) ?? {}, ["text", "title", "name", "issue", "detail", "message"]);
112
+ return text === null ? [] : [text];
113
+ });
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Statuses
118
+
119
+ type StatusWord = {
120
+ readonly status: AcceptanceStatus;
121
+ /** A spelling that says nothing beyond the status ("pass", "FAILED"); others ("not met", "pending") are kept to show. */
122
+ readonly plain?: boolean;
123
+ readonly notApplicable?: boolean;
124
+ readonly needsYou?: boolean;
125
+ readonly owner?: boolean;
126
+ };
127
+
128
+ /** Every spelling read, by its key (lowercase, runs of anything but letters and digits as one space; symbols as written). */
129
+ const STATUS_WORDS: Readonly<Record<string, StatusWord>> = (() => {
130
+ const table: Record<string, StatusWord> = {};
131
+ const add = (word: StatusWord, ...keys: string[]) => keys.forEach((key) => (table[key] = word));
132
+ add({ status: "passed", plain: true }, "pass", "passed", "passes", "passing", "ok", "success", "succeeded", "successful", "green", "yes", "y", "✓", "✔", "✔️", "✅", "true");
133
+ add({ status: "passed", plain: true }, "met", "verified", "accepted", "proven", "confirmed");
134
+ add({ status: "failed", plain: true }, "fail", "failed", "fails", "failing", "failure", "red", "no", "n", "✗", "✘", "❌", "false");
135
+ add({ status: "failed" }, "not met", "unmet", "error", "errored", "broken", "regressed", "rejected");
136
+ add({ status: "blocked", plain: true }, "blocked");
137
+ add({ status: "blocked" }, "blocker", "capture blocked", "captureblocked", "on hold", "waiting", "stuck");
138
+ add({ status: "blocked", needsYou: true, plain: true }, "needs you", "needs user", "needs input", "needs decision", "needs approval", "waiting on you", "waiting on user", "waiting for you", "waiting for user", "awaiting user", "awaiting input", "awaiting you");
139
+ add({ status: "untested", plain: true }, "untested", "not tested");
140
+ add({ status: "untested" }, "not exercised", "unexercised", "not run", "pending", "unknown", "todo", "to do", "not started", "open", "missing", "no result", "unmeasured", "not measured", "?");
141
+ add({ status: "untested", notApplicable: true, plain: true }, "n a", "na", "not applicable", "inapplicable", "—", "-");
142
+ add({ status: "claimed", plain: true }, "claimed");
143
+ add({ status: "claimed" }, "reported", "unverified", "not verified", "self reported", "agent reported", "done", "complete", "completed", "implemented", "fixed", "ready", "says done");
144
+ add({ status: "claimed", owner: true, plain: true }, "owner reported", "owner says", "owner says done", "owner claimed", "owner verified");
145
+ add({ status: "skipped", plain: true }, "skipped", "skip", "skips");
146
+ add({ status: "skipped" }, "no match", "no matches", "zero tests", "0 tests", "no tests", "ignored", "excluded");
147
+ return table;
148
+ })();
149
+
150
+ /** A status as written: the canonical word, or null for text this doesn't know. */
151
+ export function statusWord(value: Field): StatusWord | null {
152
+ if (value === true || value === false) return { status: value ? "passed" : "failed", plain: true };
153
+ const raw = str(value);
154
+ if (raw === null) return null;
155
+ const key = raw
156
+ .toLowerCase()
157
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
158
+ .trim();
159
+ if (Object.hasOwn(STATUS_WORDS, key)) return STATUS_WORDS[key]!;
160
+ return Object.hasOwn(STATUS_WORDS, raw) ? STATUS_WORDS[raw]! : null;
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Evidence
165
+
166
+ const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|svg|avif)$/i;
167
+
168
+ /** `path:12`, `path:12:3`, `path:12-20`, `path#L12`, `path#L12-L20` split into the path and its lines. */
169
+ function fileLocation(raw: string): { path: string; line: number | null; endLine: number | null } {
170
+ const hash = /^(.*?)#L(\d+)(?:-L?(\d+))?$/.exec(raw);
171
+ if (hash !== null) return { path: hash[1]!, line: Number(hash[2]), endLine: hash[3] === undefined ? null : Number(hash[3]) };
172
+ const colon = /^(.*?\.[A-Za-z][\w]{0,9}):(\d+)(?:(?::\d+)|(?:-(\d+)))?$/.exec(raw);
173
+ if (colon !== null) return { path: colon[1]!, line: Number(colon[2]), endLine: colon[3] === undefined ? null : Number(colon[3]) };
174
+ return { path: raw, line: null, endLine: null };
175
+ }
176
+
177
+ /** Whether a written string names a file: one token with a path prefix or a file extension. */
178
+ function looksLikePath(raw: string): boolean {
179
+ if (/\s/.test(raw.replace(/\\ /g, ""))) return false;
180
+ if (/^(\/|\.{1,2}\/|~\/|\$[A-Z_]+|[a-z]:[\\/])/i.test(raw)) return true;
181
+ const location = fileLocation(raw).path;
182
+ return /\.[A-Za-z][\w]{0,9}$/.test(location) && /[A-Za-z]/.test(location.replace(/\.[^.]*$/, ""));
183
+ }
184
+
185
+ /** One piece of evidence from a string: an http(s) link, an image or a file by its shape, else text. */
186
+ function evidenceFromString(raw: string, label: string | null): Evidence {
187
+ if (/^https?:\/\//i.test(raw)) {
188
+ let path = raw;
189
+ try {
190
+ path = new URL(raw).pathname;
191
+ } catch {
192
+ return { kind: "text", text: raw, label };
193
+ }
194
+ return /^https:/i.test(raw) && IMAGE_EXTENSION.test(path) ? { kind: "image", src: raw, label } : { kind: "url", url: raw, label };
195
+ }
196
+ if (looksLikePath(raw)) {
197
+ const location = fileLocation(raw);
198
+ if (location.line === null && IMAGE_EXTENSION.test(location.path)) return { kind: "image", src: location.path, label };
199
+ return { kind: "file", path: location.path, line: location.line, endLine: location.endLine !== null && location.line !== null && location.endLine >= location.line ? location.endLine : null, label };
200
+ }
201
+ return { kind: "text", text: raw, label };
202
+ }
203
+
204
+ /** Evidence as written: a string, `{path, line?}`, `{url}`, `{image | src}`, `{text}` (each with an optional label), or a list of them. */
205
+ export function readEvidence(value: Field): Evidence[] {
206
+ if (value === undefined || value === null) return [];
207
+ if (Array.isArray(value)) return value.flatMap((item: Value) => readEvidence(item));
208
+ const one = str(value);
209
+ if (one !== null) return [evidenceFromString(one, null)];
210
+ const item = record(value);
211
+ if (item === null) return [];
212
+ const label = pick(item, ["label", "title", "name", "caption", "kind"]);
213
+ const url = pick(item, ["url", "href", "link"]);
214
+ if (url !== null) return [evidenceFromString(url, label)];
215
+ const image = pick(item, ["image", "src", "screenshot"]);
216
+ if (image !== null) return [{ kind: "image", src: image, label }];
217
+ const path = pick(item, ["path", "file", "artifact"]);
218
+ if (path !== null) {
219
+ const location = fileLocation(path);
220
+ const line = typeof item.line === "number" && Number.isSafeInteger(item.line) && item.line > 0 ? item.line : location.line;
221
+ const end = typeof item.endLine === "number" && Number.isSafeInteger(item.endLine) ? item.endLine : location.endLine;
222
+ if (line === null && IMAGE_EXTENSION.test(location.path)) return [{ kind: "image", src: location.path, label }];
223
+ return [{ kind: "file", path: location.path, line, endLine: end !== null && line !== null && end >= line ? end : null, label }];
224
+ }
225
+ const text = pick(item, ["text", "output", "excerpt", "value", "summary"]);
226
+ return text === null ? [] : [{ kind: "text", text, label }];
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Checks
231
+
232
+ const NAME_KEYS = ["name", "criterion", "title", "requirement", "check", "label", "item"];
233
+ const STATUS_KEYS = ["status", "state", "result", "outcome"];
234
+ const NOTE_KEYS = ["note", "reason", "why", "notes", "comment", "message"];
235
+ const BY_KEYS = ["by", "checkedBy", "verifiedBy", "checker", "reviewer", "verifier"];
236
+ const RUN_KEYS = ["run", "revision", "rev", "sha", "commit", "build", "job"];
237
+ const AT_KEYS = ["at", "time", "checkedAt", "date", "timestamp"];
238
+ const EVIDENCE_KEYS = ["evidence", "proof", "artifact", "artifacts", "receipt", "receipts", "screenshot"];
239
+ const CELL_COLUMN_KEYS = ["column", "condition", "platform", "device", "viewport", "env", "environment", "name"];
240
+
241
+ /** Fields of a criterion that aren't conditions when a flat row's other keys are read as columns. */
242
+ const ROW_KEYS: Readonly<Record<string, true>> = Object.fromEntries(
243
+ [
244
+ ...NAME_KEYS,
245
+ ...STATUS_KEYS,
246
+ ...NOTE_KEYS,
247
+ ...BY_KEYS,
248
+ ...RUN_KEYS,
249
+ ...AT_KEYS,
250
+ ...EVIDENCE_KEYS,
251
+ "detail",
252
+ "description",
253
+ "required",
254
+ "optional",
255
+ "needs",
256
+ "method",
257
+ "id",
258
+ "key",
259
+ "cells",
260
+ "owner",
261
+ "area",
262
+ ].map((key) => [key, true]),
263
+ );
264
+
265
+ const NO_RESULT: Check = {
266
+ status: "untested",
267
+ given: null,
268
+ known: true,
269
+ notApplicable: false,
270
+ recorded: false,
271
+ needs: null,
272
+ note: null,
273
+ by: null,
274
+ method: null,
275
+ run: null,
276
+ at: null,
277
+ evidence: [],
278
+ };
279
+
280
+ /** A check from a status word or `{status, note?, evidence?, by?, method?, run?, at?, needs?}`; `fallback` when no status is written. */
281
+ function readCheck(value: Field, fallback: AcceptanceStatus | null, owner: string | null = null): Check {
282
+ if (value === undefined || value === null) return NO_RESULT;
283
+ const item = record(value);
284
+ const statusValue = item === null ? value : STATUS_KEYS.map((key) => item[key]).find((field) => field !== undefined && field !== null);
285
+ const word = statusWord(statusValue);
286
+ const written = typeof statusValue === "boolean" ? null : str(statusValue);
287
+ const needsField = item === null ? null : str(item.needs);
288
+ let status: AcceptanceStatus;
289
+ let notApplicable = false;
290
+ let needs: string | null = null;
291
+ let given: string | null = null;
292
+ let known = true;
293
+ if (word !== null) {
294
+ status = word.status;
295
+ notApplicable = word.notApplicable === true;
296
+ if (word.needsYou === true) needs = "";
297
+ if (written !== null && word.plain !== true) given = written;
298
+ } else if (written !== null) {
299
+ status = "untested";
300
+ given = written;
301
+ known = false;
302
+ } else if (needsField !== null) {
303
+ status = "blocked";
304
+ } else if (fallback !== null) {
305
+ status = fallback;
306
+ } else {
307
+ status = "untested";
308
+ }
309
+ if (needsField !== null && status !== "passed" && !notApplicable) needs = needsField;
310
+ const fields = item ?? {};
311
+ const by = pick(fields, BY_KEYS) ?? (status === "claimed" ? (owner ?? (word?.owner === true ? "owner" : null)) : null);
312
+ const note = pick(fields, NOTE_KEYS);
313
+ const method = pick(fields, ["method", "how", "tool", "via"]);
314
+ const run = pick(fields, RUN_KEYS);
315
+ const at = pick(fields, AT_KEYS);
316
+ const evidence = EVIDENCE_KEYS.flatMap((key) => readEvidence(fields[key]));
317
+ return {
318
+ status,
319
+ given,
320
+ known,
321
+ notApplicable,
322
+ // A bare name, or a row with nothing but its name, records no result.
323
+ recorded: word !== null || written !== null || needsField !== null || fallback !== null || [note, by, method, run, at].some((part) => part !== null) || evidence.length > 0,
324
+ needs,
325
+ note,
326
+ by,
327
+ method,
328
+ run,
329
+ at,
330
+ evidence,
331
+ };
332
+ }
333
+
334
+ /** The worst status of `checks` (n/a ones aside); null when every check is n/a or there are none. */
335
+ export function worstStatus(checks: readonly Check[]): AcceptanceStatus | null {
336
+ let worst: number | null = null;
337
+ for (const check of checks) {
338
+ if (check.notApplicable) continue;
339
+ const rank = STATUS_ORDER.indexOf(check.status);
340
+ if (worst === null || rank < worst) worst = rank;
341
+ }
342
+ return worst === null ? null : STATUS_ORDER[worst]!;
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // The report
347
+
348
+ interface Source {
349
+ readonly items: readonly Value[];
350
+ readonly columns: Field;
351
+ readonly title: string | null;
352
+ readonly ownerComplete: boolean | null;
353
+ readonly statedVerdict: string | null;
354
+ readonly limitations: readonly string[];
355
+ /** Status for items that write none: an owner's contract is what the owner claims. */
356
+ readonly fallback: AcceptanceStatus | null;
357
+ readonly owner: string | null;
358
+ }
359
+
360
+ const LIST_KEYS = ["criteria", "rows", "items", "checks", "acceptance", "requirements", "results", "gates"];
361
+
362
+ /** A `{name: status | cell}` map as criteria; null when any value is neither. */
363
+ function mapItems(value: Fields): Value[] | null {
364
+ const entries = Object.entries(value);
365
+ if (entries.length === 0) return null;
366
+ const items: Value[] = [];
367
+ for (const [name, entry] of entries) {
368
+ const item = record(entry);
369
+ if (item !== null) {
370
+ const isCheck = STATUS_KEYS.some((key) => item[key] !== undefined) || item.cells !== undefined || item.needs !== undefined;
371
+ if (isCheck) {
372
+ items.push({ ...item, name });
373
+ continue;
374
+ }
375
+ // A matrix row, `{column: status | {status}}`, when every value is one.
376
+ const isRow = Object.values(item).every((cell) => statusWord(cell) !== null || STATUS_KEYS.some((key) => record(cell)?.[key] !== undefined));
377
+ if (!isRow) return null;
378
+ items.push({ name, cells: item });
379
+ } else if (typeof entry === "string" || typeof entry === "boolean") {
380
+ if (statusWord(entry) === null) return null;
381
+ items.push({ name, status: entry });
382
+ } else return null;
383
+ }
384
+ return items;
385
+ }
386
+
387
+ /** Where the criteria are in what was given: a list, a wrapper object (`{criteria}`, `{rows}`, a gate's `{matrix}`, an owner's `{contract, blockers}`), or a `{name: status}` map. */
388
+ function findSource(value: Field): Source | string {
389
+ const base = { columns: undefined, title: null, ownerComplete: null, statedVerdict: null, limitations: [], fallback: null, owner: null };
390
+ if (Array.isArray(value)) return { ...base, items: value };
391
+ const object = record(value);
392
+ if (object === null) return "Acceptance needs a list of criteria [{name, status}] or [{name, cells: {column: status}}]";
393
+ const title = pick(object, ["title", "subject", "feature", "game", "name", "goal"]);
394
+ const ownerComplete = typeof object.complete === "boolean" ? object.complete : null;
395
+ const statedVerdict = pick(object, ["verdict", "decision"]);
396
+ const limitations = strings(object.limitations ?? object.caveats);
397
+ const wrapper = { ...base, columns: object.columns, title, ownerComplete, statedVerdict, limitations };
398
+ const blockers = strings(object.blockers).map((text): Value => ({ name: text, status: "blocked" }));
399
+ for (const key of LIST_KEYS) {
400
+ const items = object[key];
401
+ if (Array.isArray(items)) return { ...wrapper, items: [...items, ...blockers] };
402
+ const map = record(items);
403
+ const mapped = map === null ? null : mapItems(map);
404
+ if (mapped !== null) return { ...wrapper, items: [...mapped, ...blockers] };
405
+ }
406
+ const matrix = record(object.matrix);
407
+ if (matrix !== null) {
408
+ const mapped = mapItems(matrix);
409
+ if (mapped !== null) return { ...wrapper, items: [...mapped, ...blockers] };
410
+ }
411
+ if (Array.isArray(object.matrix)) return { ...wrapper, items: [...object.matrix, ...blockers] };
412
+ if (Array.isArray(object.contract)) {
413
+ const owner = pick(object, ["owner", "by", "author"]) ?? "owner";
414
+ return { ...wrapper, items: [...object.contract, ...blockers], fallback: "claimed", owner };
415
+ }
416
+ if (blockers.length > 0) return { ...wrapper, items: blockers };
417
+ const mapped = mapItems(object);
418
+ if (mapped !== null) return { ...base, items: mapped };
419
+ return "Acceptance needs a list of criteria [{name, status}], {criteria: [...]}, a gate {matrix} or an owner result {contract}";
420
+ }
421
+
422
+ function readColumns(value: Field): string[] | null {
423
+ if (!Array.isArray(value)) return null;
424
+ const columns: string[] = [];
425
+ for (const item of value as readonly Value[]) {
426
+ const name = str(item) ?? pick(record(item) ?? {}, ["name", "key", "label", "title"]);
427
+ if (name !== null && !columns.includes(name)) columns.push(name);
428
+ }
429
+ return columns;
430
+ }
431
+
432
+ /** A matrix row's cells by column: `{column: cell}`, or a list of cells named by a `column`-like key or by position. */
433
+ function rowCells(item: Fields, given: readonly string[] | null): Map<string, Value> | null {
434
+ const cells = item.cells ?? item.conditions;
435
+ const object = record(cells);
436
+ if (object !== null) return new Map(Object.entries(object).map(([key, cell]) => [key.trim(), cell]));
437
+ if (Array.isArray(cells)) {
438
+ const map = new Map<string, Value>();
439
+ (cells as readonly Value[]).forEach((cell, position) => {
440
+ const named = pick(record(cell) ?? {}, CELL_COLUMN_KEYS);
441
+ map.set(named ?? given?.[position] ?? `#${position + 1}`, cell);
442
+ });
443
+ return map;
444
+ }
445
+ return null;
446
+ }
447
+
448
+ /** Keys of flat rows (a CSV, `{name, iPhone: "pass", Mac: "fail"}`) that hold statuses: the columns when nothing else names them. */
449
+ function flatColumns(items: readonly Fields[]): string[] {
450
+ const columns: string[] = [];
451
+ for (const item of items) {
452
+ for (const [key, value] of Object.entries(item)) {
453
+ if (Object.hasOwn(ROW_KEYS, key) || columns.includes(key)) continue;
454
+ const cell = record(value);
455
+ if (statusWord(value) !== null || (cell !== null && STATUS_KEYS.some((status) => cell[status] !== undefined))) columns.push(key);
456
+ }
457
+ }
458
+ return columns;
459
+ }
460
+
461
+ /**
462
+ * Criteria from what an agent wrote or a tool saved, with `columns` (the
463
+ * component's param) ordering and limiting the condition columns. Without
464
+ * columns and without cells each criterion has one status.
465
+ */
466
+ export function readAcceptance(value: Field, columnsParam?: Field): AcceptanceRead {
467
+ const source = findSource(value);
468
+ if (typeof source === "string") return { ok: false, error: source };
469
+ const rows: Fields[] = [];
470
+ let unnamed = 0;
471
+ for (const item of source.items) {
472
+ const fields = record(item) ?? (str(item) !== null ? { name: str(item)! } : null);
473
+ if (fields === null || pick(fields, NAME_KEYS) === null) {
474
+ unnamed++;
475
+ continue;
476
+ }
477
+ rows.push(fields);
478
+ }
479
+ const given = readColumns(columnsParam) ?? readColumns(source.columns);
480
+ const cellMaps = rows.map((row) => rowCells(row, given));
481
+ const hasCells = cellMaps.some((cells) => cells !== null);
482
+ const hasStatus = rows.some((row) => STATUS_KEYS.some((key) => row[key] !== undefined) || row.needs !== undefined);
483
+ // Columns the data itself names, in first-seen order.
484
+ let dataColumns: string[] = [];
485
+ if (hasCells) {
486
+ for (const cells of cellMaps) for (const key of cells?.keys() ?? []) if (!dataColumns.includes(key)) dataColumns.push(key);
487
+ } else if (given !== null || !hasStatus) {
488
+ dataColumns = flatColumns(rows);
489
+ }
490
+ const flat = !hasCells && (given !== null ? given.length > 0 : dataColumns.length > 0);
491
+ const columns = hasCells || flat ? (given ?? dataColumns) : [];
492
+ const hiddenColumns = given === null ? 0 : dataColumns.filter((column) => !given.includes(column)).length;
493
+ const matrix = columns.length > 0;
494
+
495
+ const criteria = rows.map((row, index): Criterion => {
496
+ const name = pick(row, NAME_KEYS)!;
497
+ const optional = row.optional === true || row.required === false || str(row.required)?.toLowerCase() === "optional";
498
+ const detail = pick(row, ["detail", "description"]);
499
+ if (!matrix) {
500
+ return { index, name, detail, required: !optional, own: readCheck(row, source.fallback, source.owner), cells: null, stated: null };
501
+ }
502
+ const cells = cellMaps[index];
503
+ const checks = columns.map((column) => readCheck(cells === null || cells === undefined ? (flat ? row[column] : undefined) : cells.get(column), source.fallback, source.owner));
504
+ const ownCheck = readCheck({ ...row, status: null }, null, source.owner);
505
+ const worst = worstStatus(checks);
506
+ const statedWord = STATUS_KEYS.map((key) => statusWord(row[key])).find((word) => word !== null) ?? null;
507
+ const status = worst ?? "untested";
508
+ const stated = statedWord !== null && !statedWord.notApplicable && worst !== null && statedWord.status !== worst ? statedWord.status : null;
509
+ const own: Check = {
510
+ ...ownCheck,
511
+ status,
512
+ given: null,
513
+ known: true,
514
+ notApplicable: worst === null && checks.length > 0,
515
+ recorded: checks.some((check) => check.recorded),
516
+ needs: ownCheck.needs,
517
+ };
518
+ return { index, name, detail, required: !optional, own, cells: checks, stated };
519
+ });
520
+
521
+ return {
522
+ ok: true,
523
+ report: {
524
+ title: source.title,
525
+ columns,
526
+ hiddenColumns,
527
+ criteria,
528
+ unnamed,
529
+ ownerComplete: source.ownerComplete,
530
+ statedVerdict: source.statedVerdict,
531
+ limitations: source.limitations,
532
+ },
533
+ };
534
+ }
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Saying it
538
+
539
+ /** A criterion is a gap when it isn't passed (n/a ones aren't gaps). */
540
+ export function isGap(criterion: Criterion): boolean {
541
+ return !criterion.own.notApplicable && criterion.own.status !== "passed";
542
+ }
543
+
544
+ export function isCellGap(check: Check): boolean {
545
+ return !check.notApplicable && check.status !== "passed";
546
+ }
547
+
548
+ export const STATUS_LABEL: Readonly<Record<AcceptanceStatus, string>> = {
549
+ passed: "Passed",
550
+ failed: "Failed",
551
+ blocked: "Blocked",
552
+ untested: "Not tested",
553
+ claimed: "Claimed",
554
+ skipped: "Skipped",
555
+ };
556
+
557
+ /** How a count of criteria (or cells) in a status reads in the verdict: "2 failing", "2 only claimed". */
558
+ export const COUNT_WORD: Readonly<Record<AcceptanceStatus, string>> = {
559
+ passed: "proven",
560
+ failed: "failing",
561
+ blocked: "blocked",
562
+ untested: "not tested",
563
+ claimed: "only claimed",
564
+ skipped: "skipped",
565
+ };
566
+
567
+ /** What a check says, in words: "Verified by reviewer", "Claimed by owner, not independently checked", "Not applicable". */
568
+ export function checkLabel(check: Check): string {
569
+ if (check.notApplicable) return "Not applicable";
570
+ if (!check.recorded) return "No result recorded";
571
+ const given = check.given === null ? "" : ` (${check.given})`;
572
+ switch (check.status) {
573
+ case "passed":
574
+ return check.by === null ? `Passed${given}` : `Verified by ${check.by}${given}`;
575
+ case "claimed":
576
+ return `Claimed${check.by === null ? "" : ` by ${check.by}`}${given}, not independently checked`;
577
+ case "untested":
578
+ return check.known ? `Not tested${given}` : `Not tested: “${check.given}” is not a known status`;
579
+ case "blocked":
580
+ return check.needs !== null ? `Blocked, needs you${check.needs === "" ? "" : `: ${check.needs}`}` : `Blocked${given}`;
581
+ default:
582
+ return `${STATUS_LABEL[check.status]}${given}`;
583
+ }
584
+ }
585
+
586
+ export type StatusCounts = Readonly<Record<AcceptanceStatus, number>>;
587
+
588
+ function emptyCounts(): Record<AcceptanceStatus, number> {
589
+ return { passed: 0, failed: 0, blocked: 0, untested: 0, claimed: 0, skipped: 0 };
590
+ }
591
+
592
+ /** One thing the user has to do, and every criterion or cell waiting on it. */
593
+ export interface NeedsYou {
594
+ /** What is needed ("Connect and unlock the iPhone"): the `needs` text, else the note; "" when only the status said so. */
595
+ readonly what: string;
596
+ /** The criteria waiting on it, in order, each with the columns (cells) that wait; no columns for a criterion's own result. */
597
+ readonly where: readonly { readonly criterion: string; readonly columns: readonly string[] }[];
598
+ }
599
+
600
+ /**
601
+ * Where a need applies, in a few words: "Plays a live Rook match (Source
602
+ * server, Published site)", or for a block of cells "iPhone · Whisper and
603
+ * iPhone · LiteRT in Tier 1, Tier 2 and 1 more". `limit` names at most that
604
+ * many criteria.
605
+ */
606
+ export function needsPlaces(item: NeedsYou, limit = Number.POSITIVE_INFINITY): string {
607
+ const names = (list: readonly string[]) => {
608
+ const shown = list.length > limit + 1 ? list.slice(0, limit) : list;
609
+ const more = list.length - shown.length;
610
+ const all = more > 0 ? [...shown, `${more} more`] : shown;
611
+ return all.length <= 1 ? all.join("") : `${all.slice(0, -1).join(", ")} and ${all[all.length - 1]}`;
612
+ };
613
+ const columns = [...new Set(item.where.flatMap((place) => place.columns))];
614
+ const sameColumns = item.where.every((place) => place.columns.length === columns.length);
615
+ if (columns.length > 0 && item.where.length > 1 && sameColumns) return `${names(columns)} in ${names(item.where.map((place) => place.criterion))}`;
616
+ return names(item.where.map((place) => (place.columns.length === 0 ? place.criterion : `${place.criterion} (${place.columns.join(", ")})`)));
617
+ }
618
+
619
+ export interface Verdict {
620
+ /** "Done", "Not done", "Not proven", "No criteria". */
621
+ readonly headline: string;
622
+ readonly tone: "good" | "warn" | "bad" | "neutral";
623
+ /** "3 of 8 criteria proven". */
624
+ readonly proven: string;
625
+ /** Required criteria per status (n/a ones aside). */
626
+ readonly counts: StatusCounts;
627
+ /** "2 failing", "1 blocked", "2 only claimed", worst first, non-zero only. */
628
+ readonly parts: readonly string[];
629
+ /** The matrix's cells per status (n/a cells aside), with how many there are; null in the single-status form. */
630
+ readonly cells: { readonly total: number; readonly counts: StatusCounts; readonly notApplicable: number } | null;
631
+ readonly required: number;
632
+ readonly optional: { readonly total: number; readonly proven: number };
633
+ readonly notApplicable: number;
634
+ readonly needsYou: readonly NeedsYou[];
635
+ }
636
+
637
+ function plural(count: number, one: string, many: string): string {
638
+ return `${count} ${count === 1 ? one : many}`;
639
+ }
640
+
641
+ /** The verdict in plain words, from required criteria; optional ones are counted apart. */
642
+ export function acceptanceVerdict(report: AcceptanceReport): Verdict {
643
+ const counts = emptyCounts();
644
+ let required = 0;
645
+ let notApplicable = 0;
646
+ let optionalTotal = 0;
647
+ let optionalProven = 0;
648
+ const needsYou = new Map<string, { what: string; where: { criterion: string; columns: string[] }[] }>();
649
+ const addNeed = (check: Check, criterion: string, column: string | null) => {
650
+ const what = check.needs || (check.note ?? "");
651
+ const key = what.toLowerCase();
652
+ let group = needsYou.get(key);
653
+ if (group === undefined) needsYou.set(key, (group = { what, where: [] }));
654
+ let place = group.where.find((entry) => entry.criterion === criterion);
655
+ if (place === undefined) group.where.push((place = { criterion, columns: [] }));
656
+ if (column !== null) place.columns.push(column);
657
+ };
658
+ let cells: { total: number; counts: Record<AcceptanceStatus, number>; notApplicable: number } | null = null;
659
+ if (report.columns.length > 0) cells = { total: 0, counts: emptyCounts(), notApplicable: 0 };
660
+ for (const criterion of report.criteria) {
661
+ if (criterion.own.needs !== null && criterion.own.status !== "passed") addNeed(criterion.own, criterion.name, null);
662
+ criterion.cells?.forEach((check, position) => {
663
+ if (check.needs !== null) addNeed(check, criterion.name, report.columns[position] ?? "");
664
+ if (cells === null || !criterion.required) return;
665
+ if (check.notApplicable) cells.notApplicable++;
666
+ else {
667
+ cells.total++;
668
+ cells.counts[check.status]++;
669
+ }
670
+ });
671
+ if (criterion.own.notApplicable) {
672
+ if (criterion.required) notApplicable++;
673
+ continue;
674
+ }
675
+ if (!criterion.required) {
676
+ optionalTotal++;
677
+ if (criterion.own.status === "passed") optionalProven++;
678
+ continue;
679
+ }
680
+ required++;
681
+ counts[criterion.own.status]++;
682
+ }
683
+ const noun = optionalTotal > 0 ? ["required criterion", "required criteria"] : ["criterion", "criteria"];
684
+ const parts = STATUS_ORDER.filter((status) => status !== "passed" && counts[status] > 0).map((status) => `${counts[status]} ${COUNT_WORD[status]}`);
685
+ const proven = required === 0 ? plural(0, noun[0]!, noun[1]!) : `${counts.passed} of ${plural(required, noun[0]!, noun[1]!)} proven`;
686
+ let headline: string;
687
+ let tone: Verdict["tone"];
688
+ if (report.criteria.length === 0) {
689
+ headline = "No criteria";
690
+ tone = "neutral";
691
+ } else if (required === 0) {
692
+ headline = optionalTotal > 0 ? "Nothing required" : "Not applicable";
693
+ tone = "neutral";
694
+ } else if (counts.passed === required) {
695
+ headline = "Done";
696
+ tone = "good";
697
+ } else if (counts.failed + counts.blocked + counts.untested > 0) {
698
+ headline = "Not done";
699
+ tone = counts.failed > 0 ? "bad" : "warn";
700
+ } else {
701
+ headline = "Not proven";
702
+ tone = "warn";
703
+ }
704
+ return {
705
+ headline,
706
+ tone,
707
+ proven: headline === "Done" ? `all ${plural(required, noun[0]!, noun[1]!)} proven` : proven,
708
+ counts,
709
+ parts,
710
+ cells,
711
+ required,
712
+ optional: { total: optionalTotal, proven: optionalProven },
713
+ notApplicable,
714
+ needsYou: [...needsYou.values()],
715
+ };
716
+ }
717
+
718
+ /** "Not done: 3 of 8 criteria proven · 2 failing · 1 blocked · 2 only claimed". */
719
+ export function verdictLine(verdict: Verdict): string {
720
+ if (verdict.headline === "No criteria") return "No criteria yet";
721
+ return [`${verdict.headline}: ${verdict.proven}`, ...verdict.parts].join(" · ");
722
+ }
723
+
724
+ /** The matrix's cells in words: "24 cells: 24 blocked", "72 cells: 2 proven · 70 untested". */
725
+ export function cellsLine(verdict: Verdict): string | null {
726
+ const cells = verdict.cells;
727
+ if (cells === null || cells.total === 0) return null;
728
+ const parts = [...STATUS_ORDER].reverse().filter((status) => cells.counts[status] > 0).map((status) => `${cells.counts[status]} ${COUNT_WORD[status]}`);
729
+ const na = cells.notApplicable > 0 ? ` (${cells.notApplicable} not applicable)` : "";
730
+ return `${plural(cells.total, "cell", "cells")}${na}: ${parts.join(" · ")}`;
731
+ }
732
+
733
+ /** What the owner said against what is proven: "Owner reports complete; 5 criteria are not proven". Null when it said nothing or agrees. */
734
+ export function ownerContradiction(report: AcceptanceReport, verdict: Verdict): string | null {
735
+ if (report.ownerComplete !== true || verdict.headline === "Done" || verdict.required === 0) return null;
736
+ const open = verdict.required - verdict.counts.passed;
737
+ return `Owner reports complete, but ${plural(open, "required criterion is", "required criteria are")} not proven`;
738
+ }
739
+
740
+ /** The text a criterion's or cell's copy button copies: `Criterion · column: label — note`. */
741
+ export function checkSummary(criterion: string, column: string | null, check: Check): string {
742
+ const note = check.note === null ? "" : ` — ${check.note}`;
743
+ return `${criterion}${column === null ? "" : ` · ${column}`}: ${checkLabel(check)}${note}`;
744
+ }