rcf-lite 0.0.1 → 0.8.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 (349) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/LICENSE +202 -0
  3. package/README.md +92 -4
  4. package/bin/rcf-verify.js +122 -0
  5. package/bin/rcf.js +174 -0
  6. package/bin/view-supervisor-child.mjs +14 -0
  7. package/fixtures/canary-manifest.json +103 -0
  8. package/guidance/README.md +25 -0
  9. package/guidance/build-cycle-playbook.md +492 -0
  10. package/guidance/build-cycle.md +40 -0
  11. package/guidance/document-model.md +45 -0
  12. package/guidance/elicitation-playbook.md +442 -0
  13. package/guidance/harness-template.md +246 -0
  14. package/guidance/managed/README.md +63 -0
  15. package/guidance/managed/agent-instructions-block.hash +1 -0
  16. package/guidance/managed/agent-instructions-block.md +220 -0
  17. package/guidance/managed/legacy-fragment-hashes.json +22 -0
  18. package/guidance/manifest.json +21 -0
  19. package/guidance/overview.md +48 -0
  20. package/guidance/persona-programme.md +44 -0
  21. package/package.json +71 -8
  22. package/rcf/adrs/adr-001.json +25 -0
  23. package/rcf/adrs/adr-002.json +25 -0
  24. package/rcf/adrs/adr-003.json +25 -0
  25. package/rcf/adrs/adr-004.json +20 -0
  26. package/rcf/adrs/adr-005.json +20 -0
  27. package/rcf/adrs/adr-006.json +25 -0
  28. package/rcf/adrs/adr-007.json +25 -0
  29. package/rcf/adrs/adr-008.json +25 -0
  30. package/rcf/adrs/adr-009.json +25 -0
  31. package/rcf/build-sequence.json +11 -0
  32. package/rcf/code-nodes/cn-001.json +14 -0
  33. package/rcf/code-nodes/cn-002.json +14 -0
  34. package/rcf/code-nodes/cn-003.json +15 -0
  35. package/rcf/code-nodes/cn-004.json +17 -0
  36. package/rcf/code-nodes/cn-005.json +19 -0
  37. package/rcf/code-nodes/cn-006.json +17 -0
  38. package/rcf/code-nodes/cn-007.json +16 -0
  39. package/rcf/code-nodes/cn-008.json +19 -0
  40. package/rcf/code-nodes/cn-009.json +16 -0
  41. package/rcf/code-nodes/cn-010.json +18 -0
  42. package/rcf/code-nodes/cn-011.json +18 -0
  43. package/rcf/code-nodes/cn-012.json +18 -0
  44. package/rcf/code-nodes/cn-013.json +19 -0
  45. package/rcf/code-nodes/cn-014.json +16 -0
  46. package/rcf/code-nodes/cn-015.json +16 -0
  47. package/rcf/code-nodes/cn-016.json +16 -0
  48. package/rcf/code-nodes/cn-017.json +16 -0
  49. package/rcf/code-nodes/cn-018.json +15 -0
  50. package/rcf/code-nodes/cn-019.json +16 -0
  51. package/rcf/code-nodes/cn-020.json +16 -0
  52. package/rcf/code-nodes/cn-021.json +16 -0
  53. package/rcf/code-nodes/cn-022.json +16 -0
  54. package/rcf/code-nodes/cn-023.json +16 -0
  55. package/rcf/code-nodes/cn-024.json +16 -0
  56. package/rcf/code-nodes/cn-025.json +16 -0
  57. package/rcf/code-nodes/cn-026.json +16 -0
  58. package/rcf/code-nodes/cn-027.json +16 -0
  59. package/rcf/code-nodes/cn-028.json +16 -0
  60. package/rcf/code-nodes/cn-029.json +19 -0
  61. package/rcf/code-nodes/cn-030.json +14 -0
  62. package/rcf/code-nodes/cn-031.json +14 -0
  63. package/rcf/code-nodes/cn-032.json +14 -0
  64. package/rcf/code-nodes/cn-033.json +14 -0
  65. package/rcf/code-nodes/cn-034.json +14 -0
  66. package/rcf/code-nodes/cn-035.json +14 -0
  67. package/rcf/code-nodes/cn-036.json +14 -0
  68. package/rcf/code-nodes/cn-037.json +14 -0
  69. package/rcf/code-nodes/cn-038.json +14 -0
  70. package/rcf/code-nodes/cn-039.json +14 -0
  71. package/rcf/code-nodes/cn-040.json +14 -0
  72. package/rcf/code-nodes/cn-041.json +14 -0
  73. package/rcf/code-nodes/cn-042.json +14 -0
  74. package/rcf/code-nodes/cn-043.json +14 -0
  75. package/rcf/code-nodes/cn-044.json +14 -0
  76. package/rcf/code-nodes/cn-045.json +14 -0
  77. package/rcf/code-nodes/cn-046.json +14 -0
  78. package/rcf/code-nodes/cn-047.json +14 -0
  79. package/rcf/code-nodes/cn-048.json +14 -0
  80. package/rcf/code-nodes/cn-049.json +14 -0
  81. package/rcf/code-nodes/cn-050.json +14 -0
  82. package/rcf/code-nodes/cn-051.json +14 -0
  83. package/rcf/code-nodes/cn-052.json +14 -0
  84. package/rcf/code-nodes/cn-053.json +14 -0
  85. package/rcf/code-nodes/cn-054.json +14 -0
  86. package/rcf/code-nodes/cn-055.json +14 -0
  87. package/rcf/code-nodes/cn-056.json +14 -0
  88. package/rcf/code-nodes/cn-057.json +14 -0
  89. package/rcf/fbs/fbs-001.json +49 -0
  90. package/rcf/fbs/fbs-002.json +42 -0
  91. package/rcf/fbs/fbs-003.json +37 -0
  92. package/rcf/fbs/fbs-004.json +39 -0
  93. package/rcf/fbs/fbs-005.json +38 -0
  94. package/rcf/fbs/fbs-006.json +48 -0
  95. package/rcf/fbs/fbs-007.json +39 -0
  96. package/rcf/fbs/fbs-008.json +40 -0
  97. package/rcf/fbs/fbs-009.json +36 -0
  98. package/rcf/fbs/fbs-010.json +41 -0
  99. package/rcf/fbs/fbs-011.json +36 -0
  100. package/rcf/fbs/fbs-012.json +46 -0
  101. package/rcf/fbs/fbs-013.json +42 -0
  102. package/rcf/fbs/fbs-014.json +49 -0
  103. package/rcf/fbs/fbs-015.json +32 -0
  104. package/rcf/manifest.json +17 -0
  105. package/rcf/prd.json +47 -0
  106. package/rcf/requirements/req-001.json +19 -0
  107. package/rcf/requirements/req-002.json +19 -0
  108. package/rcf/requirements/req-003.json +19 -0
  109. package/rcf/requirements/req-004.json +19 -0
  110. package/rcf/requirements/req-005.json +19 -0
  111. package/rcf/requirements/req-006.json +19 -0
  112. package/rcf/requirements/req-007.json +19 -0
  113. package/rcf/requirements/req-008.json +19 -0
  114. package/rcf/requirements/req-009.json +19 -0
  115. package/rcf/tacs/tac-001.json +45 -0
  116. package/rcf/tacs/tac-002.json +109 -0
  117. package/rcf/tacs/tac-003.json +40 -0
  118. package/rcf/tacs/tac-004.json +51 -0
  119. package/rcf/tacs/tac-005.json +52 -0
  120. package/rcf/tacs/tac-006.json +104 -0
  121. package/rcf/tacs/tac-007.json +38 -0
  122. package/rcf/tacs/tac-008.json +51 -0
  123. package/rcf/tad.json +51 -0
  124. package/rcf/test-suites/PENDING.md +23 -0
  125. package/rcf/test-suites/ts-001.json +38 -0
  126. package/rcf/test-suites/ts-002.json +38 -0
  127. package/rcf/test-suites/ts-003.json +43 -0
  128. package/rcf/test-suites/ts-004.json +44 -0
  129. package/rcf/test-suites/ts-005.json +30 -0
  130. package/rcf/test-suites/ts-006.json +36 -0
  131. package/rcf/test-suites/ts-007.json +43 -0
  132. package/rcf/test-suites/ts-008.json +37 -0
  133. package/rcf/test-suites/ts-009.json +38 -0
  134. package/rcf/test-suites/ts-010.json +38 -0
  135. package/rcf/test-suites/ts-011.json +44 -0
  136. package/rcf/test-suites/ts-012.json +36 -0
  137. package/rcf/test-suites/ts-013.json +38 -0
  138. package/rcf/test-suites/ts-014.json +38 -0
  139. package/rcf/test-suites/ts-015.json +38 -0
  140. package/rcf/test-suites/ts-016.json +37 -0
  141. package/rcf/test-suites/ts-017.json +38 -0
  142. package/rcf/test-suites/ts-018.json +38 -0
  143. package/rcf/test-suites/ts-019.json +37 -0
  144. package/rcf/test-suites/ts-020.json +46 -0
  145. package/rcf/test-suites/ts-021.json +46 -0
  146. package/rcf/test-suites/ts-022.json +46 -0
  147. package/rcf/test-suites/ts-023.json +46 -0
  148. package/rcf/test-suites/ts-024.json +46 -0
  149. package/rcf/test-suites/ts-025.json +52 -0
  150. package/rcf/user-stories/us-101.json +40 -0
  151. package/rcf/user-stories/us-102.json +40 -0
  152. package/rcf/user-stories/us-201.json +40 -0
  153. package/rcf/user-stories/us-202.json +40 -0
  154. package/rcf/user-stories/us-203.json +32 -0
  155. package/rcf/user-stories/us-301.json +40 -0
  156. package/rcf/user-stories/us-302.json +40 -0
  157. package/rcf/user-stories/us-303.json +40 -0
  158. package/rcf/user-stories/us-304.json +40 -0
  159. package/rcf/user-stories/us-401.json +40 -0
  160. package/rcf/user-stories/us-402.json +40 -0
  161. package/rcf/user-stories/us-403.json +40 -0
  162. package/rcf/user-stories/us-501.json +40 -0
  163. package/rcf/user-stories/us-502.json +40 -0
  164. package/rcf/user-stories/us-503.json +40 -0
  165. package/rcf/user-stories/us-601.json +40 -0
  166. package/rcf/user-stories/us-602.json +40 -0
  167. package/rcf/user-stories/us-701.json +40 -0
  168. package/rcf/user-stories/us-702.json +40 -0
  169. package/rcf/user-stories/us-801.json +49 -0
  170. package/rcf/user-stories/us-802.json +49 -0
  171. package/rcf/user-stories/us-803.json +49 -0
  172. package/rcf/user-stories/us-804.json +49 -0
  173. package/rcf/user-stories/us-805.json +49 -0
  174. package/rcf/user-stories/us-901.json +40 -0
  175. package/src/.gitkeep +0 -0
  176. package/src/admissibility/enforce.js +142 -0
  177. package/src/admissibility/index.js +8 -0
  178. package/src/admissibility/markers.js +104 -0
  179. package/src/admissibility/scope-lint.js +163 -0
  180. package/src/browser-verify/auth-smoke.js +109 -0
  181. package/src/browser-verify/index.js +29 -0
  182. package/src/browser-verify/invariants.js +336 -0
  183. package/src/browser-verify/manifest-writer.js +189 -0
  184. package/src/browser-verify/runner.js +171 -0
  185. package/src/build/bundle.js +198 -0
  186. package/src/build/formatters/json.js +18 -0
  187. package/src/build/formatters/markdown.js +406 -0
  188. package/src/build/index.js +14 -0
  189. package/src/build/mark.js +177 -0
  190. package/src/build/queue.js +285 -0
  191. package/src/cli/browser-verify.js +231 -0
  192. package/src/cli/build.js +584 -0
  193. package/src/cli/coverage.js +219 -0
  194. package/src/cli/create.js +358 -0
  195. package/src/cli/delete.js +127 -0
  196. package/src/cli/design.js +353 -0
  197. package/src/cli/doctor.js +548 -0
  198. package/src/cli/fbs.js +171 -0
  199. package/src/cli/finalise.js +377 -0
  200. package/src/cli/guidance.js +206 -0
  201. package/src/cli/help.js +156 -0
  202. package/src/cli/impact.js +119 -0
  203. package/src/cli/init.js +282 -0
  204. package/src/cli/intake.js +153 -0
  205. package/src/cli/link.js +128 -0
  206. package/src/cli/mcp.js +160 -0
  207. package/src/cli/preflight.js +220 -0
  208. package/src/cli/read.js +168 -0
  209. package/src/cli/req-baseline.js +269 -0
  210. package/src/cli/req-classify.js +135 -0
  211. package/src/cli/review.js +295 -0
  212. package/src/cli/test-suite.js +221 -0
  213. package/src/cli/trace.js +172 -0
  214. package/src/cli/ui-baseline.js +292 -0
  215. package/src/cli/ui-classify.js +108 -0
  216. package/src/cli/update.js +197 -0
  217. package/src/cli/validate.js +168 -0
  218. package/src/cli/view.js +452 -0
  219. package/src/core/baseline-catalog/data/auth.json +42 -0
  220. package/src/core/baseline-catalog/data/http-api.json +42 -0
  221. package/src/core/baseline-catalog/data/notifications.json +33 -0
  222. package/src/core/baseline-catalog/data/persistence.json +33 -0
  223. package/src/core/baseline-catalog/data/web-ui.json +60 -0
  224. package/src/core/baseline-catalog/index.js +121 -0
  225. package/src/core/errors/index.js +167 -0
  226. package/src/core/fixtures/register-canary/canary-prompt-01.json +18 -0
  227. package/src/core/fixtures/register-canary/canary-prompt-02.json +21 -0
  228. package/src/core/fixtures/register-canary/canary-prompt-03.json +17 -0
  229. package/src/core/isolation/index.js +60 -0
  230. package/src/core/mcp/framing.js +103 -0
  231. package/src/core/mcp/index.js +8 -0
  232. package/src/core/mcp/server.js +228 -0
  233. package/src/core/patterns/register-canary.js +209 -0
  234. package/src/core/patterns/req-shapes.js +158 -0
  235. package/src/core/patterns/services.js +358 -0
  236. package/src/core/patterns/ui-shapes.js +166 -0
  237. package/src/core/store/cn-resolve.js +134 -0
  238. package/src/core/store/derive-deps.js +93 -0
  239. package/src/core/store/ids.js +78 -0
  240. package/src/core/store/index.js +20 -0
  241. package/src/core/store/init.js +255 -0
  242. package/src/core/store/loader.js +214 -0
  243. package/src/core/store/tp-resolve.js +176 -0
  244. package/src/core/store/validator.js +191 -0
  245. package/src/core/store/walker.js +944 -0
  246. package/src/core/store/writer.js +1879 -0
  247. package/src/design/index.js +11 -0
  248. package/src/design/writer.js +271 -0
  249. package/src/finalise/detect.js +151 -0
  250. package/src/finalise/index.js +31 -0
  251. package/src/finalise/ingest.js +160 -0
  252. package/src/finalise/install.js +119 -0
  253. package/src/finalise/ship-without-verified.js +131 -0
  254. package/src/finalise/spawn.js +84 -0
  255. package/src/intake/fidelity.js +105 -0
  256. package/src/intake/index.js +6 -0
  257. package/src/intake/manifest-writer.js +100 -0
  258. package/src/intake/orchestrator.js +138 -0
  259. package/src/intake/validate.js +80 -0
  260. package/src/mcp/map-errors.js +131 -0
  261. package/src/mcp/prompts.js +49 -0
  262. package/src/mcp/resources.js +244 -0
  263. package/src/mcp/tools.js +1212 -0
  264. package/src/preflight/design-shapes.js +185 -0
  265. package/src/preflight/index.js +90 -0
  266. package/src/preflight/manifest-writer.js +264 -0
  267. package/src/preflight/scanner.js +206 -0
  268. package/src/preflight/secrets.js +134 -0
  269. package/src/preflight/session.js +246 -0
  270. package/src/query/attestation.js +285 -0
  271. package/src/query/coverage.js +308 -0
  272. package/src/query/formatters/json.js +21 -0
  273. package/src/query/formatters/mermaid.js +209 -0
  274. package/src/query/formatters/table.js +203 -0
  275. package/src/query/impact.js +173 -0
  276. package/src/query/index.js +13 -0
  277. package/src/query/refuse-on-admissibility.js +73 -0
  278. package/src/query/trace.js +345 -0
  279. package/src/register-canary/fixture-loader.js +87 -0
  280. package/src/register-canary/index.js +10 -0
  281. package/src/register-canary/record-writer.js +132 -0
  282. package/src/register-canary/runner.js +156 -0
  283. package/src/req-baseline/gate.js +86 -0
  284. package/src/req-baseline/index.js +27 -0
  285. package/src/req-baseline/open-candidates.js +143 -0
  286. package/src/req-baseline/opt-out.js +195 -0
  287. package/src/req-baseline/sweep.js +230 -0
  288. package/src/req-detection/classifier.js +181 -0
  289. package/src/req-detection/index.js +9 -0
  290. package/src/req-detection/persist.js +55 -0
  291. package/src/review/index.js +325 -0
  292. package/src/review/mutation.js +117 -0
  293. package/src/review/ui-baseline-drift.js +138 -0
  294. package/src/ruleset/index.js +140 -0
  295. package/src/ruleset/ruleset.json +146 -0
  296. package/src/server/index.js +178 -0
  297. package/src/server/routes.js +110 -0
  298. package/src/server/sse.js +118 -0
  299. package/src/setup/agent-setup.js +362 -0
  300. package/src/setup/identity-seed.js +104 -0
  301. package/src/setup/knowledge-seed.js +123 -0
  302. package/src/setup/managed-block.js +193 -0
  303. package/src/setup/managed-gitignore.js +166 -0
  304. package/src/setup/managed-markers.js +49 -0
  305. package/src/ui-baseline/defaults.js +119 -0
  306. package/src/ui-baseline/index.js +25 -0
  307. package/src/ui-baseline/manifest-writer.js +282 -0
  308. package/src/ui-baseline/session.js +178 -0
  309. package/src/ui-detection/classifier.js +192 -0
  310. package/src/verify/chain/index.js +221 -0
  311. package/src/verify/cli/cleanup.js +61 -0
  312. package/src/verify/cli/help.js +56 -0
  313. package/src/verify/cli/mcp.js +98 -0
  314. package/src/verify/cli/provision.js +71 -0
  315. package/src/verify/cli/report.js +71 -0
  316. package/src/verify/cli/run.js +155 -0
  317. package/src/verify/engine/brief.js +87 -0
  318. package/src/verify/engine/index.js +177 -0
  319. package/src/verify/engine/launcher.js +307 -0
  320. package/src/verify/mcp/tools.js +107 -0
  321. package/src/verify/profile/index.js +146 -0
  322. package/src/verify/provision/index.js +256 -0
  323. package/src/verify/report/index.js +139 -0
  324. package/src/verify/report/renderer.js +118 -0
  325. package/src/verify/verdict/index.js +313 -0
  326. package/src/view/doc-renderers/adr.js +44 -0
  327. package/src/view/doc-renderers/build-sequence.js +40 -0
  328. package/src/view/doc-renderers/fbs.js +128 -0
  329. package/src/view/doc-renderers/helpers.js +159 -0
  330. package/src/view/doc-renderers/index.js +12 -0
  331. package/src/view/doc-renderers/prd.js +45 -0
  332. package/src/view/doc-renderers/req.js +43 -0
  333. package/src/view/doc-renderers/tac.js +38 -0
  334. package/src/view/doc-renderers/tad.js +74 -0
  335. package/src/view/doc-renderers/test-suite.js +45 -0
  336. package/src/view/doc-renderers/user-story.js +63 -0
  337. package/src/view/html-page.js +462 -0
  338. package/src/view/index.js +63 -0
  339. package/src/view/live-client.js +338 -0
  340. package/src/view/mermaid-diagram.js +178 -0
  341. package/src/view/style.css +735 -0
  342. package/src/view/tree-model.js +152 -0
  343. package/src/view/vendored/mermaid.min.js +2607 -0
  344. package/src/view-supervisor/index.js +26 -0
  345. package/src/view-supervisor/logs.js +32 -0
  346. package/src/view-supervisor/manifest-writer.js +178 -0
  347. package/src/view-supervisor/persist-until.js +85 -0
  348. package/src/view-supervisor/supervisor.js +276 -0
  349. package/src/watch/index.js +152 -0
@@ -0,0 +1,336 @@
1
+ // Browser-verification invariants (ui-design-gate-0.7.0-spec §8.3).
2
+ //
3
+ // v1 invariant set is module-encoded (§12 O-6 Decided): the record
4
+ // carries free-string invariant names, and adding an invariant is a
5
+ // build minor bump with no schema-versioning ceremony. v2 can
6
+ // schema-encode once the set stabilises.
7
+ //
8
+ // Every invariant here is deterministic against a DOM string plus a
9
+ // FBS + baseline context. The `agentScreenshotCritique` mode calls
10
+ // these against markup the driven browser returned; the operator-
11
+ // session mode does not invoke them (the operator's ack is the
12
+ // evidence, spec §8.2). Severities are the runtime enforcement:
13
+ // `block` refuses at the finalise gate, `warn` requires operator ack,
14
+ // `advisory` records the finding without gating.
15
+
16
+ /**
17
+ * @typedef {'block'|'warn'|'advisory'} InvariantSeverity
18
+ * @typedef {'pass'|'warn'|'fail'} InvariantVerdict
19
+ */
20
+
21
+ /**
22
+ * @typedef {object} InvariantResult
23
+ * @property {string} invariant
24
+ * @property {InvariantVerdict} verdict
25
+ * @property {string} [detail]
26
+ * @property {InvariantSeverity} severity
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} InvariantContext
31
+ * @property {string} routePath path of the route being checked
32
+ * @property {'light'|'dark'} themeApplied theme active during capture
33
+ * @property {object} fbs the FBS being verified
34
+ * @property {object} [uiBaseline] the manifest uiBaseline record (defaults + opt-outs)
35
+ * @property {string} dom the response HTML for the route
36
+ * @property {boolean} [authenticated] whether the request carried the auth cookie
37
+ */
38
+
39
+ const OPT_OUT_FIELDS = new Set([
40
+ 'themeMode',
41
+ 'sharedLayoutModule',
42
+ 'authFlow.htmlLoginPageRequired',
43
+ ]);
44
+
45
+ const V1_INVARIANTS = [
46
+ {
47
+ name: 'sharedNavPresent',
48
+ severity: 'block',
49
+ perThemeOnly: false,
50
+ run: checkSharedNavPresent,
51
+ },
52
+ {
53
+ name: 'activeNavMarked',
54
+ severity: 'block',
55
+ perThemeOnly: false,
56
+ run: checkActiveNavMarked,
57
+ },
58
+ {
59
+ name: 'signedInAsAffordance',
60
+ severity: 'block',
61
+ perThemeOnly: false,
62
+ run: checkSignedInAsAffordance,
63
+ },
64
+ {
65
+ name: 'themeToggleVisible',
66
+ severity: 'block',
67
+ perThemeOnly: false,
68
+ run: checkThemeToggleVisible,
69
+ },
70
+ {
71
+ name: 'themeDefaultsToLight',
72
+ severity: 'warn',
73
+ perThemeOnly: true, // only fires on the light-capture without a cookie
74
+ run: checkThemeDefaultsToLight,
75
+ },
76
+ {
77
+ name: 'focusRingsVisible',
78
+ severity: 'warn',
79
+ perThemeOnly: false,
80
+ run: checkFocusRingsVisible,
81
+ },
82
+ {
83
+ name: 'sharedLayoutModule',
84
+ severity: 'block',
85
+ perThemeOnly: false,
86
+ run: checkSharedLayoutStructural,
87
+ },
88
+ ];
89
+
90
+ /** Versioned constant, module-encoded per §12 O-6. */
91
+ export const UI_INVARIANTS_V1 = Object.freeze(V1_INVARIANTS.map((i) => Object.freeze({ name: i.name, severity: i.severity })));
92
+
93
+ /**
94
+ * Run the v1 invariant set against a single (route × theme × DOM)
95
+ * capture. Returns one result per invariant.
96
+ *
97
+ * @param {InvariantContext} ctx
98
+ * @returns {InvariantResult[]}
99
+ */
100
+ export function runInvariantsForCapture(ctx) {
101
+ /** @type {InvariantResult[]} */
102
+ const results = [];
103
+ for (const inv of V1_INVARIANTS) {
104
+ // `themeDefaultsToLight` only makes sense on the "no cookie, first
105
+ // load" light capture; other invariants fire per capture.
106
+ if (inv.perThemeOnly && ctx.themeApplied !== 'light') continue;
107
+ const raw = inv.run(ctx);
108
+ const detail = raw?.detail;
109
+ /** @type {InvariantVerdict} */
110
+ const verdict = raw?.verdict ?? 'pass';
111
+ const result = { invariant: inv.name, verdict, severity: inv.severity };
112
+ if (detail) result.detail = detail;
113
+ // Baseline opt-outs demote block -> advisory (per spec §6.3: the
114
+ // operator ruling wins). Only opt-outs registered for fields that
115
+ // map to invariant enforcement.
116
+ if (verdict !== 'pass' && isOptedOut(inv.name, ctx.uiBaseline)) {
117
+ result.severity = 'advisory';
118
+ result.detail = (detail ? detail + ' ' : '') + '(opt-out recorded on uiBaseline)';
119
+ }
120
+ results.push(result);
121
+ }
122
+ return results;
123
+ }
124
+
125
+ /**
126
+ * Aggregate one route's results into a `browserVerificationInvariantCheck[]`
127
+ * array as it lands on the record (spec §3.3). One entry per invariant,
128
+ * with the per-route + per-theme details flattened into the detail
129
+ * string when the invariant ran per capture.
130
+ *
131
+ * @param {Array<{ routePath: string, themeApplied: 'light'|'dark', results: InvariantResult[] }>} perCapture
132
+ * @returns {Array<{ invariant: string, verdict: InvariantVerdict, detail?: string, severity: InvariantSeverity }>}
133
+ */
134
+ export function foldInvariantsForRecord(perCapture) {
135
+ /** @type {Map<string, { invariant: string, verdict: InvariantVerdict, severity: InvariantSeverity, details: string[] }>} */
136
+ const byName = new Map();
137
+ for (const cap of perCapture) {
138
+ for (const r of cap.results) {
139
+ const cursor = byName.get(r.invariant) ?? { invariant: r.invariant, verdict: 'pass', severity: r.severity, details: [] };
140
+ cursor.verdict = worstOf(cursor.verdict, r.verdict);
141
+ cursor.severity = mostSevere(cursor.severity, r.severity);
142
+ if (r.verdict !== 'pass' && r.detail) {
143
+ cursor.details.push(`${cap.routePath}[${cap.themeApplied}]: ${r.detail}`);
144
+ }
145
+ byName.set(r.invariant, cursor);
146
+ }
147
+ }
148
+ const out = [];
149
+ for (const cursor of byName.values()) {
150
+ const entry = { invariant: cursor.invariant, verdict: cursor.verdict, severity: cursor.severity };
151
+ if (cursor.details.length > 0) entry.detail = cursor.details.join(' | ');
152
+ out.push(entry);
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function worstOf(a, b) {
158
+ const order = { pass: 0, warn: 1, fail: 2 };
159
+ return (order[b] > order[a]) ? b : a;
160
+ }
161
+ function mostSevere(a, b) {
162
+ const order = { advisory: 0, warn: 1, block: 2 };
163
+ return (order[b] > order[a]) ? b : a;
164
+ }
165
+
166
+ function isOptedOut(invariantName, baseline) {
167
+ const optOuts = Array.isArray(baseline?.operatorOptOuts) ? baseline.operatorOptOuts : [];
168
+ if (invariantName === 'themeDefaultsToLight' && optOuts.some((o) => OPT_OUT_FIELDS.has(o.field) && o.field === 'themeMode')) return true;
169
+ if (invariantName === 'sharedNavPresent' && optOuts.some((o) => o.field === 'sharedLayoutModule')) return true;
170
+ if (invariantName === 'sharedLayoutModule' && optOuts.some((o) => o.field === 'sharedLayoutModule')) return true;
171
+ return false;
172
+ }
173
+
174
+ // ---------- individual checks (each returns { verdict, detail? }) ----------
175
+
176
+ function checkSharedNavPresent(ctx) {
177
+ const routes = ctx.fbs?.designStage?.navModel?.routes ?? [];
178
+ const authRoutes = routes.filter((r) => r.authRequired);
179
+ if (authRoutes.length === 0) return { verdict: 'pass' };
180
+ if (!/<nav\b/i.test(ctx.dom)) {
181
+ return { verdict: 'fail', detail: 'no <nav> element in the response HTML for an authenticated route' };
182
+ }
183
+ // Every enumerated route must appear as <a href="..."> OR (for the
184
+ // current route) as a non-anchor stand-in - either the same path in
185
+ // a <span>/<a> without href, or a labelled non-anchor. v1 heuristic:
186
+ // pass when path shows up somewhere inside the <nav>.
187
+ const navMatch = /<nav\b[\s\S]*?<\/nav>/i.exec(ctx.dom);
188
+ const navMarkup = navMatch ? navMatch[0] : '';
189
+ const missing = [];
190
+ for (const r of routes) {
191
+ const patternPath = escapeForRegExp(r.path);
192
+ const patternLabel = escapeForRegExp(r.label);
193
+ const found = new RegExp(`href=["']${patternPath}["']`, 'i').test(navMarkup)
194
+ || new RegExp(`>${patternLabel}<`, 'i').test(navMarkup);
195
+ if (!found) missing.push(r.path);
196
+ }
197
+ if (missing.length > 0) {
198
+ return { verdict: 'fail', detail: `<nav> missing enumerated route(s): ${missing.join(', ')}` };
199
+ }
200
+ return { verdict: 'pass' };
201
+ }
202
+
203
+ function checkActiveNavMarked(ctx) {
204
+ const routes = ctx.fbs?.designStage?.navModel?.routes ?? [];
205
+ const authRoutes = routes.filter((r) => r.authRequired);
206
+ if (authRoutes.length === 0) return { verdict: 'pass' };
207
+ if (!/aria-current=["']page["']/i.test(ctx.dom)) {
208
+ return { verdict: 'fail', detail: 'no element carries aria-current="page" (active-nav marker missing)' };
209
+ }
210
+ // We do NOT verify that the aria-current landed on the CURRENT
211
+ // route's link (that would require request-to-route mapping in the
212
+ // driver; v1 keeps this cheap and delegates that mapping to the
213
+ // agent-critique rubric).
214
+ return { verdict: 'pass' };
215
+ }
216
+
217
+ function checkSignedInAsAffordance(ctx) {
218
+ const affordance = ctx.fbs?.designStage?.navModel?.signedInAsAffordance;
219
+ if (affordance !== true) return { verdict: 'pass' };
220
+ if (ctx.authenticated === false) return { verdict: 'pass' };
221
+ const hasText = /signed[\s-]in as|logged[\s-]in as/i.test(ctx.dom);
222
+ const hasLogout = /href=["'][^"']*\/logout["']/i.test(ctx.dom);
223
+ if (hasText || hasLogout) return { verdict: 'pass' };
224
+ return { verdict: 'fail', detail: 'no signed-in-as text and no /logout link visible on an authenticated route' };
225
+ }
226
+
227
+ function checkThemeToggleVisible(ctx) {
228
+ // Widened recognition set per N7 (spec §8.3): any of (a)
229
+ // [data-theme-toggle], (b) id/class matching /theme-toggle|toggle-theme/i,
230
+ // (c) accessible name containing "theme" (aria-label / aria-labelledby /
231
+ // inner text).
232
+ if (/data-theme-toggle/i.test(ctx.dom)) return { verdict: 'pass' };
233
+ if (/(?:id|class)=["'][^"']*(?:theme[\s-]?toggle|toggle[\s-]?theme)[^"']*["']/i.test(ctx.dom)) return { verdict: 'pass' };
234
+ if (/aria-label=["'][^"']*theme[^"']*["']/i.test(ctx.dom)) return { verdict: 'pass' };
235
+ // Inner-text heuristic: an element whose direct inner text contains
236
+ // "theme" (case-insensitive). Cheap without a real DOM parser.
237
+ if (/>[^<]*theme[^<]*</i.test(ctx.dom)) return { verdict: 'pass' };
238
+ return { verdict: 'fail', detail: 'no theme toggle detectable in the response HTML' };
239
+ }
240
+
241
+ function checkThemeDefaultsToLight(ctx) {
242
+ // Only meaningful on the "no cookie" first load. The runner is
243
+ // expected to fire this against a light capture with no cookie; here
244
+ // we just check the served theme attribute matches.
245
+ const match = /<html\b[^>]*data-theme=["']([^"']+)["']/i.exec(ctx.dom);
246
+ const served = match?.[1] ?? null;
247
+ const expected = ctx.uiBaseline?.defaults?.themeMode === 'dark-default-with-toggle' ? 'dark'
248
+ : ctx.uiBaseline?.defaults?.themeMode === 'single-theme-declared' ? null
249
+ : 'light';
250
+ if (expected === null) return { verdict: 'pass' };
251
+ if (served === expected) return { verdict: 'pass' };
252
+ return { verdict: 'fail', detail: `<html data-theme> served '${served ?? '(unset)'}' but the baseline expects '${expected}' on a first-load without a theme cookie` };
253
+ }
254
+
255
+ function checkFocusRingsVisible(ctx) {
256
+ // Cheap chain-shaped heuristic: the response HTML mentions a focus
257
+ // style (either an inline focus-ring rule or a class that reads as
258
+ // one). The real check happens agent-side against a tab-focused
259
+ // screenshot; v1 records `advisory` when the heuristic is
260
+ // inconclusive so operators know to eyeball. spec §8.3 severity `warn`.
261
+ if (/focus-visible|focus-ring|outline\s*:/i.test(ctx.dom)) return { verdict: 'pass' };
262
+ return { verdict: 'warn', detail: 'no focus-ring style detected in the response HTML (visually confirm agent-side)' };
263
+ }
264
+
265
+ function checkSharedLayoutStructural(ctx) {
266
+ // The record-shape reduction: pass on the individual capture; the
267
+ // structural cross-route compare lives at the fold stage
268
+ // (`compareTopLevelStructure` below) and is called from the runner.
269
+ return { verdict: 'pass' };
270
+ }
271
+
272
+ /**
273
+ * Compare top-level element sequences across multiple captured DOMs.
274
+ * Returns a `sharedLayoutModule` invariant result to append to the
275
+ * record when the sequences differ meaningfully (§8.3).
276
+ *
277
+ * @param {Array<{ routePath: string, dom: string }>} captures
278
+ * @param {object} [baseline]
279
+ * @returns {InvariantResult}
280
+ */
281
+ export function compareTopLevelStructure(captures, baseline) {
282
+ const auth = captures.filter((c) => c.dom);
283
+ if (auth.length < 2) {
284
+ return { invariant: 'sharedLayoutModule', verdict: 'pass', severity: 'block' };
285
+ }
286
+ const structures = auth.map((c) => topLevelChildTagSequence(c.dom));
287
+ const [reference, ...rest] = structures;
288
+ const mismatched = [];
289
+ for (let i = 0; i < rest.length; i += 1) {
290
+ if (!arraysEqual(rest[i], reference)) mismatched.push(auth[i + 1].routePath);
291
+ }
292
+ const severity = isOptedOut('sharedLayoutModule', baseline) ? 'advisory' : 'block';
293
+ if (mismatched.length === 0) return { invariant: 'sharedLayoutModule', verdict: 'pass', severity };
294
+ return {
295
+ invariant: 'sharedLayoutModule',
296
+ verdict: 'fail',
297
+ detail: `top-level DOM structure diverges between ${auth[0].routePath} and ${mismatched.join(', ')} (spec §8.3 structural check)`,
298
+ severity,
299
+ };
300
+ }
301
+
302
+ function topLevelChildTagSequence(dom) {
303
+ // Extract the tag-name sequence of direct children of <body>. Cheap
304
+ // regex-based; deliberately conservative (misses nested body-siblings
305
+ // in unusual documents but catches the two-shells watchpost pattern).
306
+ const bodyMatch = /<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(dom);
307
+ const source = bodyMatch ? bodyMatch[1] : dom;
308
+ const tags = [];
309
+ const re = /<([a-zA-Z][a-zA-Z0-9-]*)\b/g;
310
+ let m;
311
+ let depth = 0;
312
+ let index = 0;
313
+ while ((m = re.exec(source)) !== null) {
314
+ if (depth === 0 && index === m.index) tags.push(m[1].toLowerCase());
315
+ // Depth tracking is imprecise for self-closing tags; the check
316
+ // exists as a coarse divergence signal, not a formal parser.
317
+ if (source[m.index + 1] !== '/') depth += 1;
318
+ // Advance index heuristically to next top-level opening.
319
+ const closeIdx = source.indexOf(`</${m[1]}>`, m.index);
320
+ if (closeIdx < 0) break;
321
+ depth = Math.max(0, depth - 1);
322
+ index = closeIdx + `</${m[1]}>`.length;
323
+ re.lastIndex = index;
324
+ }
325
+ return tags;
326
+ }
327
+
328
+ function arraysEqual(a, b) {
329
+ if (a.length !== b.length) return false;
330
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
331
+ return true;
332
+ }
333
+
334
+ function escapeForRegExp(s) {
335
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
336
+ }
@@ -0,0 +1,189 @@
1
+ // Browser-verification record composer + writer
2
+ // (ui-design-gate-0.7.0-spec §3.3, §8).
3
+
4
+ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ import { rcfError } from '#core/errors';
8
+ import { validateDocument } from '#core/store';
9
+
10
+ /**
11
+ * Compute the next `bv-<fbsId>-<n>` id: monotonic per FBS.
12
+ *
13
+ * @param {object|null} manifest
14
+ * @param {string} fbsId
15
+ * @returns {string}
16
+ */
17
+ export function nextBrowserVerificationId(manifest, fbsId) {
18
+ const prefix = `bv-${fbsId}-`;
19
+ const existing = Array.isArray(manifest?.browserVerification) ? manifest.browserVerification : [];
20
+ let maxN = 0;
21
+ for (const rec of existing) {
22
+ if (typeof rec?.id !== 'string' || !rec.id.startsWith(prefix)) continue;
23
+ const n = Number.parseInt(rec.id.slice(prefix.length), 10);
24
+ if (Number.isFinite(n) && n > maxN) maxN = n;
25
+ }
26
+ return `${prefix}${maxN + 1}`;
27
+ }
28
+
29
+ /**
30
+ * Aggregate the record verdict per spec §8.5:
31
+ * block if any invariant severity=block verdict=fail, OR any auth smoke fail;
32
+ * warn if any invariant severity=warn verdict=fail (and no block);
33
+ * pass otherwise.
34
+ *
35
+ * @param {Array<{ invariant: string, verdict: 'pass'|'warn'|'fail', severity: 'block'|'warn'|'advisory' }>} invariantChecks
36
+ * @param {Array<{ verdict: 'pass'|'warn'|'fail' }>} authSmokeChecks
37
+ * @returns {'pass'|'warn'|'block'}
38
+ */
39
+ export function aggregateVerdict(invariantChecks, authSmokeChecks) {
40
+ for (const check of invariantChecks) {
41
+ if (check.severity === 'block' && check.verdict === 'fail') return 'block';
42
+ }
43
+ for (const check of authSmokeChecks ?? []) {
44
+ if (check.verdict === 'fail') return 'block';
45
+ }
46
+ for (const check of invariantChecks) {
47
+ if (check.severity === 'warn' && (check.verdict === 'fail' || check.verdict === 'warn')) return 'warn';
48
+ }
49
+ return 'pass';
50
+ }
51
+
52
+ /**
53
+ * Compose a `browserVerification` record ready to append.
54
+ *
55
+ * @param {object} args
56
+ * @param {object|null} args.manifest
57
+ * @param {string} args.fbsId
58
+ * @param {'operatorSession'|'agentScreenshotCritique'} args.mode
59
+ * @param {'deployed'|'ci'|'local-dev'} args.runtimeProfile
60
+ * @param {string} args.runtimeUrl
61
+ * @param {Array<{ path: string, screenshotPath: string, themeApplied: 'light'|'dark' }>} args.routesChecked
62
+ * @param {Array<{ invariant: string, verdict: 'pass'|'warn'|'fail', detail?: string, severity: 'block'|'warn'|'advisory' }>} args.invariantChecks
63
+ * @param {Array<{ check: string, status?: number, contentType?: string, verdict: 'pass'|'warn'|'fail', detail?: string }>} [args.authSmokeChecks]
64
+ * @param {string} [args.notes]
65
+ * @param {Date} [args.now]
66
+ * @returns {object}
67
+ */
68
+ export function composeBrowserVerificationRecord({
69
+ manifest, fbsId, mode, runtimeProfile, runtimeUrl, routesChecked,
70
+ invariantChecks, authSmokeChecks = [], notes, now = new Date(),
71
+ }) {
72
+ const id = nextBrowserVerificationId(manifest, fbsId);
73
+ const verdict = aggregateVerdict(invariantChecks, authSmokeChecks);
74
+ const record = {
75
+ id,
76
+ fbsId,
77
+ createdAt: now.toISOString(),
78
+ mode,
79
+ runtimeProfile,
80
+ runtimeUrl,
81
+ routesChecked: routesChecked.map((r) => ({ path: r.path, screenshotPath: r.screenshotPath, themeApplied: r.themeApplied })),
82
+ invariantChecks: invariantChecks.map((c) => stripSeverity(c)),
83
+ verdict,
84
+ };
85
+ if (authSmokeChecks && authSmokeChecks.length > 0) record.authSmokeChecks = authSmokeChecks;
86
+ if (typeof notes === 'string' && notes.length > 0) record.notes = notes;
87
+ return record;
88
+ }
89
+
90
+ function stripSeverity(check) {
91
+ const out = { invariant: check.invariant, verdict: check.verdict };
92
+ if (typeof check.detail === 'string' && check.detail.length > 0) out.detail = check.detail;
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Persist a composed `browserVerification` record on the manifest.
98
+ * Atomic tmp-and-rename, schema-validated before write.
99
+ *
100
+ * @param {object} args
101
+ * @param {string} args.projectRoot
102
+ * @param {import('#core/store/walker.js').TreeModel} args.tree
103
+ * @param {object} args.record
104
+ * @param {object} [args.options]
105
+ * @returns {Promise<{ record: object } | import('#core/errors').RcfError>}
106
+ */
107
+ export async function writeBrowserVerificationRecord({ projectRoot, tree, record, options = {} }) {
108
+ const manifest = tree.manifest ?? {};
109
+ const nextManifest = { ...manifest };
110
+ const existing = Array.isArray(nextManifest.browserVerification) ? nextManifest.browserVerification : [];
111
+ nextManifest.browserVerification = [...existing, record];
112
+
113
+ const relPath = 'rcf/manifest.json';
114
+ const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: relPath });
115
+ if (validation) return validation;
116
+
117
+ if (options.dryRun) return { record, dryRun: true };
118
+
119
+ const absPath = join(projectRoot, 'rcf', 'manifest.json');
120
+ try {
121
+ await mkdir(dirname(absPath), { recursive: true });
122
+ const tmp = `${absPath}.tmp`;
123
+ await writeFile(tmp, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
124
+ try {
125
+ await rename(tmp, absPath);
126
+ } catch (err) {
127
+ try { await unlink(tmp); } catch { /* ignore */ }
128
+ throw err;
129
+ }
130
+ } catch (err) {
131
+ return rcfError({
132
+ kind: 'ioFailure',
133
+ message: `browser-verify: manifest write failed: ${err.message}`,
134
+ filePath: relPath,
135
+ stack: err.stack,
136
+ });
137
+ }
138
+ return { record };
139
+ }
140
+
141
+ /**
142
+ * Append `operatorAckAt` or `operatorShipDespiteBlockReason` to the
143
+ * latest `browserVerification` record for a FBS.
144
+ *
145
+ * @param {object} args
146
+ * @returns {Promise<{ record: object } | import('#core/errors').RcfError>}
147
+ */
148
+ export async function writeBrowserVerificationAck({
149
+ projectRoot, tree, fbsId, operatorAckAt, operatorShipDespiteBlockReason, now = new Date(),
150
+ }) {
151
+ const manifest = tree.manifest ?? {};
152
+ const records = Array.isArray(manifest.browserVerification) ? manifest.browserVerification : [];
153
+ const latestIdx = findLatestRecordIndex(records, fbsId);
154
+ if (latestIdx < 0) {
155
+ return rcfError({
156
+ kind: 'usage',
157
+ message: `browser-verify --ack: no browserVerification record exists for ${fbsId} yet.`,
158
+ });
159
+ }
160
+ const isoNow = now.toISOString();
161
+ const nextManifest = { ...manifest };
162
+ const next = [...records];
163
+ const target = { ...next[latestIdx] };
164
+ if (operatorAckAt) target.operatorAckAt = operatorAckAt === true ? isoNow : String(operatorAckAt);
165
+ if (operatorShipDespiteBlockReason) target.operatorShipDespiteBlockReason = operatorShipDespiteBlockReason;
166
+ next[latestIdx] = target;
167
+ nextManifest.browserVerification = next;
168
+ const relPath = 'rcf/manifest.json';
169
+ const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: relPath });
170
+ if (validation) return validation;
171
+ const absPath = join(projectRoot, 'rcf', 'manifest.json');
172
+ try {
173
+ await mkdir(dirname(absPath), { recursive: true });
174
+ const tmp = `${absPath}.tmp`;
175
+ await writeFile(tmp, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
176
+ try { await rename(tmp, absPath); } catch (err) {
177
+ try { await unlink(tmp); } catch { /* ignore */ }
178
+ throw err;
179
+ }
180
+ } catch (err) {
181
+ return rcfError({ kind: 'ioFailure', message: `browser-verify --ack: manifest write failed: ${err.message}`, filePath: relPath, stack: err.stack });
182
+ }
183
+ return { record: target };
184
+ }
185
+
186
+ function findLatestRecordIndex(records, fbsId) {
187
+ for (let i = records.length - 1; i >= 0; i -= 1) if (records[i]?.fbsId === fbsId) return i;
188
+ return -1;
189
+ }