rcf-lite 0.0.1 → 0.7.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 (342) hide show
  1. package/CHANGELOG.md +288 -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 +69 -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/browser-verify/auth-smoke.js +109 -0
  177. package/src/browser-verify/index.js +29 -0
  178. package/src/browser-verify/invariants.js +336 -0
  179. package/src/browser-verify/manifest-writer.js +189 -0
  180. package/src/browser-verify/runner.js +171 -0
  181. package/src/build/bundle.js +198 -0
  182. package/src/build/formatters/json.js +18 -0
  183. package/src/build/formatters/markdown.js +406 -0
  184. package/src/build/index.js +14 -0
  185. package/src/build/mark.js +177 -0
  186. package/src/build/queue.js +285 -0
  187. package/src/cli/browser-verify.js +231 -0
  188. package/src/cli/build.js +584 -0
  189. package/src/cli/coverage.js +219 -0
  190. package/src/cli/create.js +355 -0
  191. package/src/cli/delete.js +127 -0
  192. package/src/cli/design.js +353 -0
  193. package/src/cli/doctor.js +548 -0
  194. package/src/cli/fbs.js +171 -0
  195. package/src/cli/finalise.js +377 -0
  196. package/src/cli/guidance.js +206 -0
  197. package/src/cli/help.js +156 -0
  198. package/src/cli/impact.js +119 -0
  199. package/src/cli/init.js +282 -0
  200. package/src/cli/intake.js +153 -0
  201. package/src/cli/link.js +128 -0
  202. package/src/cli/mcp.js +160 -0
  203. package/src/cli/preflight.js +220 -0
  204. package/src/cli/read.js +162 -0
  205. package/src/cli/req-baseline.js +269 -0
  206. package/src/cli/req-classify.js +135 -0
  207. package/src/cli/review.js +295 -0
  208. package/src/cli/test-suite.js +216 -0
  209. package/src/cli/trace.js +172 -0
  210. package/src/cli/ui-baseline.js +292 -0
  211. package/src/cli/ui-classify.js +108 -0
  212. package/src/cli/update.js +197 -0
  213. package/src/cli/validate.js +168 -0
  214. package/src/cli/view.js +452 -0
  215. package/src/core/baseline-catalog/data/auth.json +42 -0
  216. package/src/core/baseline-catalog/data/http-api.json +42 -0
  217. package/src/core/baseline-catalog/data/notifications.json +33 -0
  218. package/src/core/baseline-catalog/data/persistence.json +33 -0
  219. package/src/core/baseline-catalog/data/web-ui.json +60 -0
  220. package/src/core/baseline-catalog/index.js +121 -0
  221. package/src/core/errors/index.js +167 -0
  222. package/src/core/fixtures/register-canary/canary-prompt-01.json +18 -0
  223. package/src/core/fixtures/register-canary/canary-prompt-02.json +21 -0
  224. package/src/core/fixtures/register-canary/canary-prompt-03.json +17 -0
  225. package/src/core/isolation/index.js +60 -0
  226. package/src/core/mcp/framing.js +103 -0
  227. package/src/core/mcp/index.js +8 -0
  228. package/src/core/mcp/server.js +228 -0
  229. package/src/core/patterns/register-canary.js +209 -0
  230. package/src/core/patterns/req-shapes.js +158 -0
  231. package/src/core/patterns/services.js +358 -0
  232. package/src/core/patterns/ui-shapes.js +166 -0
  233. package/src/core/store/cn-resolve.js +134 -0
  234. package/src/core/store/derive-deps.js +93 -0
  235. package/src/core/store/ids.js +78 -0
  236. package/src/core/store/index.js +20 -0
  237. package/src/core/store/init.js +255 -0
  238. package/src/core/store/loader.js +211 -0
  239. package/src/core/store/tp-resolve.js +176 -0
  240. package/src/core/store/validator.js +191 -0
  241. package/src/core/store/walker.js +898 -0
  242. package/src/core/store/writer.js +1849 -0
  243. package/src/design/index.js +11 -0
  244. package/src/design/writer.js +271 -0
  245. package/src/finalise/detect.js +129 -0
  246. package/src/finalise/index.js +17 -0
  247. package/src/finalise/ingest.js +119 -0
  248. package/src/finalise/install.js +119 -0
  249. package/src/finalise/ship-without-verified.js +131 -0
  250. package/src/finalise/spawn.js +84 -0
  251. package/src/intake/fidelity.js +105 -0
  252. package/src/intake/index.js +6 -0
  253. package/src/intake/manifest-writer.js +100 -0
  254. package/src/intake/orchestrator.js +138 -0
  255. package/src/intake/validate.js +80 -0
  256. package/src/mcp/map-errors.js +131 -0
  257. package/src/mcp/prompts.js +49 -0
  258. package/src/mcp/resources.js +244 -0
  259. package/src/mcp/tools.js +1204 -0
  260. package/src/preflight/design-shapes.js +185 -0
  261. package/src/preflight/index.js +90 -0
  262. package/src/preflight/manifest-writer.js +264 -0
  263. package/src/preflight/scanner.js +206 -0
  264. package/src/preflight/secrets.js +134 -0
  265. package/src/preflight/session.js +246 -0
  266. package/src/query/attestation.js +285 -0
  267. package/src/query/coverage.js +308 -0
  268. package/src/query/formatters/json.js +21 -0
  269. package/src/query/formatters/mermaid.js +209 -0
  270. package/src/query/formatters/table.js +203 -0
  271. package/src/query/impact.js +173 -0
  272. package/src/query/index.js +9 -0
  273. package/src/query/trace.js +345 -0
  274. package/src/register-canary/fixture-loader.js +87 -0
  275. package/src/register-canary/index.js +10 -0
  276. package/src/register-canary/record-writer.js +132 -0
  277. package/src/register-canary/runner.js +156 -0
  278. package/src/req-baseline/gate.js +86 -0
  279. package/src/req-baseline/index.js +27 -0
  280. package/src/req-baseline/open-candidates.js +143 -0
  281. package/src/req-baseline/opt-out.js +195 -0
  282. package/src/req-baseline/sweep.js +230 -0
  283. package/src/req-detection/classifier.js +181 -0
  284. package/src/req-detection/index.js +9 -0
  285. package/src/req-detection/persist.js +55 -0
  286. package/src/review/index.js +325 -0
  287. package/src/review/mutation.js +117 -0
  288. package/src/review/ui-baseline-drift.js +138 -0
  289. package/src/server/index.js +178 -0
  290. package/src/server/routes.js +110 -0
  291. package/src/server/sse.js +118 -0
  292. package/src/setup/agent-setup.js +362 -0
  293. package/src/setup/identity-seed.js +104 -0
  294. package/src/setup/knowledge-seed.js +123 -0
  295. package/src/setup/managed-block.js +193 -0
  296. package/src/setup/managed-gitignore.js +166 -0
  297. package/src/setup/managed-markers.js +49 -0
  298. package/src/ui-baseline/defaults.js +119 -0
  299. package/src/ui-baseline/index.js +25 -0
  300. package/src/ui-baseline/manifest-writer.js +282 -0
  301. package/src/ui-baseline/session.js +178 -0
  302. package/src/ui-detection/classifier.js +192 -0
  303. package/src/verify/chain/index.js +190 -0
  304. package/src/verify/cli/cleanup.js +61 -0
  305. package/src/verify/cli/help.js +56 -0
  306. package/src/verify/cli/mcp.js +98 -0
  307. package/src/verify/cli/provision.js +71 -0
  308. package/src/verify/cli/report.js +71 -0
  309. package/src/verify/cli/run.js +155 -0
  310. package/src/verify/engine/brief.js +87 -0
  311. package/src/verify/engine/index.js +177 -0
  312. package/src/verify/engine/launcher.js +307 -0
  313. package/src/verify/mcp/tools.js +107 -0
  314. package/src/verify/profile/index.js +146 -0
  315. package/src/verify/provision/index.js +256 -0
  316. package/src/verify/report/index.js +139 -0
  317. package/src/verify/report/renderer.js +118 -0
  318. package/src/verify/verdict/index.js +246 -0
  319. package/src/view/doc-renderers/adr.js +44 -0
  320. package/src/view/doc-renderers/build-sequence.js +40 -0
  321. package/src/view/doc-renderers/fbs.js +128 -0
  322. package/src/view/doc-renderers/helpers.js +159 -0
  323. package/src/view/doc-renderers/index.js +12 -0
  324. package/src/view/doc-renderers/prd.js +45 -0
  325. package/src/view/doc-renderers/req.js +43 -0
  326. package/src/view/doc-renderers/tac.js +38 -0
  327. package/src/view/doc-renderers/tad.js +74 -0
  328. package/src/view/doc-renderers/test-suite.js +45 -0
  329. package/src/view/doc-renderers/user-story.js +63 -0
  330. package/src/view/html-page.js +462 -0
  331. package/src/view/index.js +63 -0
  332. package/src/view/live-client.js +338 -0
  333. package/src/view/mermaid-diagram.js +178 -0
  334. package/src/view/style.css +735 -0
  335. package/src/view/tree-model.js +152 -0
  336. package/src/view/vendored/mermaid.min.js +2607 -0
  337. package/src/view-supervisor/index.js +26 -0
  338. package/src/view-supervisor/logs.js +32 -0
  339. package/src/view-supervisor/manifest-writer.js +178 -0
  340. package/src/view-supervisor/persist-until.js +85 -0
  341. package/src/view-supervisor/supervisor.js +276 -0
  342. package/src/watch/index.js +152 -0
@@ -0,0 +1,282 @@
1
+ // UI-baseline manifest writer (ui-design-gate-0.7.0-spec §3.2, §5.4).
2
+ //
3
+ // Owns id allocation (`uib-YYYY-MM-DD-NNN` monotonic per project),
4
+ // atomic tmp-and-rename write against `rcf/manifest.json`, and
5
+ // `uiBaselineHistory[]` folding on `--reset`. Every field is optional
6
+ // on the schema; the enforcement happens via the interactive session
7
+ // walking every field before writing `operatorAckAt`.
8
+ //
9
+ // The preflight seam (spec §3.2 + Track A preflight `skippedUiBaseline`
10
+ // note): when the operator ratifies a `uiBaseline`, we look up
11
+ // preflight-recorded design-shape answers whose `uiBaselineWritePath`
12
+ // targets `defaults.*` and apply the deferred writes automatically.
13
+ // Only APPLIED once (idempotent): the corresponding
14
+ // `baselineAcOptOuts[]` entries stay in place as the durable ledger;
15
+ // the seam itself is one-way from preflight into the baseline defaults.
16
+
17
+ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
18
+ import { dirname, join } from 'node:path';
19
+
20
+ import { rcfError } from '#core/errors';
21
+ import { validateDocument } from '#core/store';
22
+
23
+ import { composeDefaults, deepGet, deepSet } from './defaults.js';
24
+ import { CATALOGUE_V1 } from '../preflight/design-shapes.js';
25
+
26
+ /**
27
+ * Compute the next `uib-YYYY-MM-DD-NNN` id: monotonic per project.
28
+ *
29
+ * @param {object|null} manifest
30
+ * @param {Date} now
31
+ * @returns {string}
32
+ */
33
+ export function nextUiBaselineId(manifest, now) {
34
+ const y = now.getUTCFullYear().toString().padStart(4, '0');
35
+ const m = (now.getUTCMonth() + 1).toString().padStart(2, '0');
36
+ const d = now.getUTCDate().toString().padStart(2, '0');
37
+ const prefix = `uib-${y}-${m}-${d}-`;
38
+ const active = manifest?.uiBaseline?.id;
39
+ const history = Array.isArray(manifest?.uiBaselineHistory) ? manifest.uiBaselineHistory : [];
40
+ const ids = [active, ...history.map((h) => h?.id)].filter((id) => typeof id === 'string' && id.startsWith(prefix));
41
+ let maxN = 0;
42
+ for (const id of ids) {
43
+ const n = Number.parseInt(id.slice(prefix.length), 10);
44
+ if (Number.isFinite(n) && n > maxN) maxN = n;
45
+ }
46
+ return `${prefix}${(maxN + 1).toString().padStart(3, '0')}`;
47
+ }
48
+
49
+ /**
50
+ * Compose a fresh `uiBaseline` record. Applies preflight seam pickups
51
+ * (spec §3.2) that would otherwise sit fenced in preflight's
52
+ * `skippedUiBaseline` seam, plus any operator-supplied opt-outs.
53
+ *
54
+ * @param {object} args
55
+ * @param {object|null} args.manifest
56
+ * @param {string} args.prdId
57
+ * @param {Array<{ field: string, reason: string }>} [args.optOuts]
58
+ * @param {object} [args.overrides] dot-path -> value overrides (e.g. from preflight seam)
59
+ * @param {Date} [args.now]
60
+ * @returns {object}
61
+ */
62
+ export function composeUiBaselineRecord({
63
+ manifest, prdId, optOuts = [], overrides = {}, now = new Date(),
64
+ }) {
65
+ const isoNow = now.toISOString();
66
+ const id = nextUiBaselineId(manifest, now);
67
+ const defaults = composeDefaults(overrides);
68
+ const record = {
69
+ id,
70
+ createdAt: isoNow,
71
+ prdId,
72
+ defaults,
73
+ operatorAckAt: isoNow,
74
+ };
75
+ if (optOuts.length > 0) {
76
+ record.operatorOptOuts = optOuts.map((o) => ({
77
+ field: o.field,
78
+ reason: o.reason,
79
+ operatorAckAt: isoNow,
80
+ }));
81
+ }
82
+ return record;
83
+ }
84
+
85
+ /**
86
+ * Given a manifest and its preFlightConfig[] record set, collect the
87
+ * dot-path overrides the preflight design-shape answers imply for
88
+ * `uiBaseline.defaults`. Idempotent seam: the answers stay in
89
+ * preflight; the values land as defaults on baseline creation.
90
+ *
91
+ * Generalised (Track B review N-4): reads
92
+ * `uiBaselineWritePath` + `uiBaselineWriteValue` off the catalogue's
93
+ * per-choice metadata rather than hard-coding a single question id. Any
94
+ * answered question whose selected choice carries a write path targeting
95
+ * `defaults.*` lands as a seeded override; newest ratification wins.
96
+ * A future Track C+D catalogue addition that names its own write path
97
+ * flows through this seam without any wiring change here.
98
+ *
99
+ * The catalogue is injectable for tests via the second argument; in
100
+ * production, callers use the default `CATALOGUE_V1` import.
101
+ *
102
+ * @param {object|null} manifest
103
+ * @param {Readonly<import('../preflight/design-shapes.js').DesignShapeQuestion[]>} [catalogue]
104
+ * @returns {object} dot-path -> value overrides (relative to `defaults`)
105
+ */
106
+ export function preflightSeamOverrides(manifest, catalogue = CATALOGUE_V1) {
107
+ const overrides = {};
108
+ const questionsById = new Map();
109
+ for (const q of Array.isArray(catalogue) ? catalogue : []) {
110
+ if (q?.id) questionsById.set(q.id, q);
111
+ }
112
+ const records = Array.isArray(manifest?.preFlightConfig) ? manifest.preFlightConfig : [];
113
+ // Walk newest-first so a later ratification wins over an earlier one.
114
+ for (let i = records.length - 1; i >= 0; i -= 1) {
115
+ const rec = records[i];
116
+ const answers = Array.isArray(rec?.designShapeAnswers) ? rec.designShapeAnswers : [];
117
+ for (const answer of answers) {
118
+ const question = questionsById.get(answer?.questionId);
119
+ if (!question) continue;
120
+ const choice = Array.isArray(question.choices)
121
+ ? question.choices.find((c) => c?.value === answer?.answer)
122
+ : null;
123
+ if (!choice) continue;
124
+ const writePath = typeof choice.uiBaselineWritePath === 'string' ? choice.uiBaselineWritePath : null;
125
+ if (!writePath) continue;
126
+ // Strip the `defaults.` prefix (the write path is authored against
127
+ // the uiBaseline record; the seam applies against the defaults
128
+ // subtree, so the prefix is dropped here).
129
+ const key = writePath.startsWith('defaults.') ? writePath.slice('defaults.'.length) : writePath;
130
+ if (overrides[key] === undefined) {
131
+ overrides[key] = choice.uiBaselineWriteValue;
132
+ }
133
+ }
134
+ }
135
+ return overrides;
136
+ }
137
+
138
+ /**
139
+ * Persist a composed `uiBaseline` record. When a previous baseline
140
+ * exists and `options.reset` is true, the previous record is appended
141
+ * to `uiBaselineHistory[]` (spec §12 O-4).
142
+ *
143
+ * @param {object} args
144
+ * @param {string} args.projectRoot
145
+ * @param {import('#core/store/walker.js').TreeModel} args.tree
146
+ * @param {object} args.record
147
+ * @param {object} [args.options]
148
+ * @returns {Promise<{ record: object } | import('#core/errors').RcfError>}
149
+ */
150
+ export async function writeUiBaselineRecord({
151
+ projectRoot, tree, record, options = {},
152
+ }) {
153
+ const manifest = tree.manifest ?? {};
154
+ const nextManifest = { ...manifest };
155
+ const priorBaseline = nextManifest.uiBaseline ?? null;
156
+
157
+ if (priorBaseline && options.reset) {
158
+ const history = Array.isArray(nextManifest.uiBaselineHistory) ? nextManifest.uiBaselineHistory : [];
159
+ nextManifest.uiBaselineHistory = [...history, priorBaseline];
160
+ }
161
+ nextManifest.uiBaseline = record;
162
+
163
+ const relPath = 'rcf/manifest.json';
164
+ const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: relPath });
165
+ if (validation) return validation;
166
+
167
+ if (options.dryRun) return { record, dryRun: true };
168
+
169
+ const absPath = join(projectRoot, 'rcf', 'manifest.json');
170
+ try {
171
+ await mkdir(dirname(absPath), { recursive: true });
172
+ const tmp = `${absPath}.tmp`;
173
+ await writeFile(tmp, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
174
+ try {
175
+ await rename(tmp, absPath);
176
+ } catch (err) {
177
+ try { await unlink(tmp); } catch { /* ignore */ }
178
+ throw err;
179
+ }
180
+ } catch (err) {
181
+ return rcfError({
182
+ kind: 'ioFailure',
183
+ message: `ui-baseline: manifest write failed: ${err.message}`,
184
+ filePath: relPath,
185
+ stack: err.stack,
186
+ });
187
+ }
188
+ return { record };
189
+ }
190
+
191
+ /**
192
+ * Append an opt-out entry to an existing `uiBaseline` record. Refuses
193
+ * when no baseline exists (the operator must init first) or the field
194
+ * path is not a known baseline field.
195
+ *
196
+ * @param {object} args
197
+ * @param {string} args.projectRoot
198
+ * @param {import('#core/store/walker.js').TreeModel} args.tree
199
+ * @param {string} args.field dot-path into defaults (or `defaults.<...>`; both accepted)
200
+ * @param {string} args.reason operator's plain-text ruling
201
+ * @param {(path: string) => boolean} [args.isKnownField]
202
+ * @param {Date} [args.now]
203
+ * @returns {Promise<{ record: object } | import('#core/errors').RcfError>}
204
+ */
205
+ export async function writeUiBaselineOptOut({
206
+ projectRoot, tree, field, reason, isKnownField, now = new Date(),
207
+ }) {
208
+ const manifest = tree.manifest ?? {};
209
+ const priorBaseline = manifest.uiBaseline;
210
+ if (!priorBaseline) {
211
+ return rcfError({
212
+ kind: 'usage',
213
+ message: 'ui-baseline opt-out: no uiBaseline record exists yet; run \'rcf ui-baseline init\' first.',
214
+ });
215
+ }
216
+ const normalisedField = String(field).startsWith('defaults.') ? String(field).slice('defaults.'.length) : String(field);
217
+ if (typeof isKnownField === 'function' && !isKnownField(normalisedField)) {
218
+ return rcfError({
219
+ kind: 'usage',
220
+ message: `ui-baseline opt-out: unknown baseline field '${normalisedField}' (see 'rcf ui-baseline show' for the field list).`,
221
+ });
222
+ }
223
+ const isoNow = now.toISOString();
224
+ const nextManifest = { ...manifest };
225
+ const nextRecord = { ...priorBaseline };
226
+ const existing = Array.isArray(nextRecord.operatorOptOuts) ? nextRecord.operatorOptOuts : [];
227
+ nextRecord.operatorOptOuts = [
228
+ ...existing.filter((o) => o.field !== normalisedField),
229
+ { field: normalisedField, reason, operatorAckAt: isoNow },
230
+ ];
231
+ nextManifest.uiBaseline = nextRecord;
232
+
233
+ const relPath = 'rcf/manifest.json';
234
+ const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: relPath });
235
+ if (validation) return validation;
236
+
237
+ const absPath = join(projectRoot, 'rcf', 'manifest.json');
238
+ try {
239
+ await mkdir(dirname(absPath), { recursive: true });
240
+ const tmp = `${absPath}.tmp`;
241
+ await writeFile(tmp, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
242
+ try {
243
+ await rename(tmp, absPath);
244
+ } catch (err) {
245
+ try { await unlink(tmp); } catch { /* ignore */ }
246
+ throw err;
247
+ }
248
+ } catch (err) {
249
+ return rcfError({
250
+ kind: 'ioFailure',
251
+ message: `ui-baseline opt-out: manifest write failed: ${err.message}`,
252
+ filePath: relPath,
253
+ stack: err.stack,
254
+ });
255
+ }
256
+ return { record: nextRecord };
257
+ }
258
+
259
+ /**
260
+ * True when a value in the baseline's `defaults` disagrees with a
261
+ * value on a FBS's `designStage`. Used by the design-mark-complete gate.
262
+ * Returns null on agreement or when no opinion is expressible; returns
263
+ * `{ path, baselineValue, designValue }` on disagreement.
264
+ *
265
+ * @param {object|null} baseline
266
+ * @param {object|null} designStage
267
+ * @param {string} path dot-path relative to `defaults` (e.g. `themeMode`)
268
+ * @param {string} designStagePath dot-path relative to `designStage` (e.g. `themeAndA11y.themeMode`)
269
+ * @returns {{ path: string, baselineValue: *, designValue: * }|null}
270
+ */
271
+ export function baselineDesignDisagreement(baseline, designStage, path, designStagePath) {
272
+ if (!baseline || !designStage) return null;
273
+ const baselineValue = deepGet(baseline.defaults ?? {}, path);
274
+ const designValue = deepGet(designStage, designStagePath);
275
+ if (baselineValue === undefined || designValue === undefined) return null;
276
+ const optOuts = Array.isArray(baseline.operatorOptOuts) ? baseline.operatorOptOuts : [];
277
+ if (optOuts.some((o) => o.field === path)) return null;
278
+ if (baselineValue === designValue) return null;
279
+ return { path, baselineValue, designValue };
280
+ }
281
+
282
+ export { deepSet, deepGet };
@@ -0,0 +1,178 @@
1
+ // Interactive session for `rcf ui-baseline init` (spec §5.4).
2
+ //
3
+ // Two shapes:
4
+ // - `runInteractiveSession(...)` - the TTY path. Presents an
5
+ // enter-to-accept-all summary screen. Typing a field name opens
6
+ // per-field editing for that field alone; typing `edit-all` walks
7
+ // every field sequentially. This preserves the "explicit never by
8
+ // silence" property (operator has seen every field on the summary)
9
+ // without the twelve-field walk ceremony that would reintroduce the
10
+ // verbosity defect from the Entry 1 cold-run feedback log.
11
+ //
12
+ // - `normaliseNonInteractiveInput(input)` - CI / automation path. The
13
+ // input file is a plain object with `optOuts` (array of { field,
14
+ // reason }) and optional `overrides` (dot-path -> value). Every
15
+ // field is treated as ack'd; the file's presence is the "seen every
16
+ // field" signal.
17
+
18
+ import { UI_BASELINE_DEFAULTS_V1, deepGet, isKnownBaselinePath } from './defaults.js';
19
+
20
+ /**
21
+ * @typedef {object} UiBaselineSessionResult
22
+ * @property {Array<{ field: string, reason: string }>} optOuts
23
+ * @property {object} overrides dot-path -> value overrides for the defaults composer
24
+ */
25
+
26
+ /**
27
+ * Interactive session (TTY). Callers supply `prompt` and `write`
28
+ * closures wrapping `readline.createInterface`. Refuses to exit
29
+ * without visiting every field.
30
+ *
31
+ * @param {object} args
32
+ * @param {(q: string) => Promise<string>} args.prompt
33
+ * @param {(s: string) => void} args.write
34
+ * @param {object} [args.preflightOverrides] dot-path -> value seam pickup from preflight (spec §3.2)
35
+ * @returns {Promise<UiBaselineSessionResult>}
36
+ */
37
+ export async function runInteractiveSession({ prompt, write, preflightOverrides = {} }) {
38
+ /** @type {Array<{ field: string, reason: string }>} */
39
+ const optOuts = [];
40
+ /** @type {Record<string, *>} */
41
+ const overrides = { ...preflightOverrides };
42
+ const editedFields = new Set(Object.keys(preflightOverrides));
43
+
44
+ let done = false;
45
+ while (!done) {
46
+ renderSummary({ write, overrides, optOuts, editedFields });
47
+ const answer = (await prompt('> ')).trim();
48
+ if (answer === '' || answer.toLowerCase() === 'accept') {
49
+ done = true;
50
+ break;
51
+ }
52
+ if (answer.toLowerCase() === 'cancel') {
53
+ throw new Error('ui-baseline: session cancelled by operator');
54
+ }
55
+ if (answer.toLowerCase() === 'edit-all') {
56
+ for (const spec of UI_BASELINE_DEFAULTS_V1) {
57
+ await editField({ spec, prompt, write, overrides, optOuts, editedFields });
58
+ }
59
+ continue;
60
+ }
61
+ if (isKnownBaselinePath(answer)) {
62
+ const spec = UI_BASELINE_DEFAULTS_V1.find((s) => s.path === answer);
63
+ await editField({ spec, prompt, write, overrides, optOuts, editedFields });
64
+ continue;
65
+ }
66
+ write(`Unknown option '${answer}'. Type a field path (e.g. ${UI_BASELINE_DEFAULTS_V1[0].path}), 'edit-all', or press ENTER to accept.`);
67
+ }
68
+ return { optOuts, overrides };
69
+ }
70
+
71
+ function renderSummary({ write, overrides, optOuts, editedFields }) {
72
+ write('');
73
+ write('UI baseline defaults (v1). Every value below is ruled by the estate playbook.');
74
+ write('');
75
+ for (const spec of UI_BASELINE_DEFAULTS_V1) {
76
+ const effective = Object.prototype.hasOwnProperty.call(overrides, spec.path) ? overrides[spec.path] : spec.value;
77
+ const marker = optOuts.some((o) => o.field === spec.path)
78
+ ? '*'
79
+ : (editedFields.has(spec.path) ? '+' : ' ');
80
+ write(` ${marker} ${padEnd(spec.path, 46)}${formatValue(effective)}`);
81
+ }
82
+ write('');
83
+ write('Press ENTER to accept these defaults. Type a field path to edit one, or \'edit-all\' to walk every field, or \'cancel\'.');
84
+ write(' * = opt-out recorded, + = value differs from ruled default');
85
+ }
86
+
87
+ function padEnd(s, n) {
88
+ const str = String(s);
89
+ return str.length >= n ? str : str + ' '.repeat(n - str.length);
90
+ }
91
+
92
+ function formatValue(v) {
93
+ if (Array.isArray(v)) return `[${v.map((x) => JSON.stringify(x)).join(', ')}]`;
94
+ if (typeof v === 'string') return v;
95
+ return JSON.stringify(v);
96
+ }
97
+
98
+ async function editField({ spec, prompt, write, overrides, optOuts, editedFields }) {
99
+ write('');
100
+ write(`Field: ${spec.path}`);
101
+ write(` ruled: ${formatValue(spec.value)}`);
102
+ write(` source: ${spec.rulingSource}`);
103
+ const current = Object.prototype.hasOwnProperty.call(overrides, spec.path) ? overrides[spec.path] : spec.value;
104
+ write(` current: ${formatValue(current)}`);
105
+ const raw = await prompt(' new value (JSON) or ENTER to accept ruled, \'opt-out <reason>\' to record ledger entry: ');
106
+ const answer = raw.trim();
107
+ if (answer === '') {
108
+ delete overrides[spec.path];
109
+ for (let i = optOuts.length - 1; i >= 0; i -= 1) if (optOuts[i].field === spec.path) optOuts.splice(i, 1);
110
+ editedFields.delete(spec.path);
111
+ return;
112
+ }
113
+ if (answer.startsWith('opt-out')) {
114
+ const reason = answer.slice('opt-out'.length).trim();
115
+ if (reason.length < 20) {
116
+ write(` opt-out reason must be at least 20 characters (got ${reason.length}); no change written.`);
117
+ return;
118
+ }
119
+ for (let i = optOuts.length - 1; i >= 0; i -= 1) if (optOuts[i].field === spec.path) optOuts.splice(i, 1);
120
+ optOuts.push({ field: spec.path, reason });
121
+ editedFields.add(spec.path);
122
+ return;
123
+ }
124
+ try {
125
+ const parsed = JSON.parse(answer);
126
+ overrides[spec.path] = parsed;
127
+ editedFields.add(spec.path);
128
+ } catch (err) {
129
+ write(` parse error (${err.message}); no change written. Value stays: ${formatValue(current)}`);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Normalise a non-interactive input file into the session result shape.
135
+ * Input file shape (spec §5.4):
136
+ * {
137
+ * "optOuts": [ { "field": "path.to.field", "reason": "..." } ],
138
+ * "overrides": { "path.to.field": <json value>, ... }
139
+ * }
140
+ * Every field named must be a known baseline path; opt-out reasons must
141
+ * be at least 20 characters (matches the opt-out ledger floor).
142
+ *
143
+ * @param {any} input
144
+ * @returns {UiBaselineSessionResult}
145
+ */
146
+ export function normaliseNonInteractiveInput(input) {
147
+ if (input === null || typeof input !== 'object') {
148
+ throw new Error('ui-baseline: non-interactive input must be an object');
149
+ }
150
+ const optOuts = [];
151
+ const overrides = {};
152
+ for (const raw of Array.isArray(input.optOuts) ? input.optOuts : []) {
153
+ if (!raw || typeof raw.field !== 'string') {
154
+ throw new Error('ui-baseline: input.optOuts[] entry missing field');
155
+ }
156
+ const field = raw.field.startsWith('defaults.') ? raw.field.slice('defaults.'.length) : raw.field;
157
+ if (!isKnownBaselinePath(field)) {
158
+ throw new Error(`ui-baseline: input.optOuts[] unknown field '${field}'`);
159
+ }
160
+ if (typeof raw.reason !== 'string' || raw.reason.length < 20) {
161
+ throw new Error(`ui-baseline: input.optOuts[] for '${field}' needs a reason of at least 20 characters`);
162
+ }
163
+ optOuts.push({ field, reason: raw.reason });
164
+ }
165
+ const rawOverrides = input.overrides ?? {};
166
+ if (rawOverrides !== null && typeof rawOverrides === 'object' && !Array.isArray(rawOverrides)) {
167
+ for (const [rawKey, value] of Object.entries(rawOverrides)) {
168
+ const key = rawKey.startsWith('defaults.') ? rawKey.slice('defaults.'.length) : rawKey;
169
+ if (!isKnownBaselinePath(key)) {
170
+ throw new Error(`ui-baseline: input.overrides has unknown field '${key}'`);
171
+ }
172
+ overrides[key] = value;
173
+ }
174
+ }
175
+ return { optOuts, overrides };
176
+ }
177
+
178
+ export { deepGet };
@@ -0,0 +1,192 @@
1
+ // UI-bearing FBS classifier (ui-design-gate-0.7.0-spec §4).
2
+ //
3
+ // Deterministic keyword scan over an FBS's own summary/title plus every
4
+ // in-scope AC's description/given/when/then, every parent US's
5
+ // iWant/soThat, every ancestor REQ's description/rationale, plus a
6
+ // dependsOnServices signal (auth-shaped services imply an HTML login
7
+ // page unless the operator has ruled otherwise).
8
+ //
9
+ // The classifier does NOT read source code. Design happens before code;
10
+ // source signals are unavailable at classification time and would be
11
+ // circular for an FBS classified pre-Build.
12
+ //
13
+ // Composition: `classifyFbs(tree, fbsId)` returns a `uiClassification`
14
+ // block per spec §3.1 without writing the FBS document. The caller
15
+ // (the `rcf ui-classify` verb + the `rcf build --next` runbook) is
16
+ // responsible for surfacing the verdict to the operator; writing the
17
+ // FBS document is the operator's choice via `rcf update`.
18
+ //
19
+ // Operator override precedence (§4.4): when `fbs.uiBearing` is a
20
+ // boolean, that operator ruling wins over the classifier's opinion and
21
+ // the returned block carries `verdict: operatorOverride` with the
22
+ // classifier's own evidence preserved in `signals[]` for provenance.
23
+
24
+ import { matchUiSignals } from '#core/patterns/ui-shapes';
25
+
26
+ /**
27
+ * @typedef {'ui'|'notUi'|'operatorOverride'} UiClassifierVerdict
28
+ */
29
+
30
+ /**
31
+ * @typedef {'keyword-scan'|'operator-tag'|'inheritedFromParent'|'operatorOverride'} UiClassifierReason
32
+ */
33
+
34
+ /**
35
+ * @typedef {object} UiClassifierSignal
36
+ * @property {'summary'|'ac.description'|'us.iWant'|'us.soThat'|'req.description'} source
37
+ * @property {string} match
38
+ * @property {string} [acId]
39
+ */
40
+
41
+ /**
42
+ * @typedef {object} UiClassificationBlock
43
+ * @property {UiClassifierVerdict} verdict
44
+ * @property {UiClassifierReason} reason
45
+ * @property {UiClassifierSignal[]} [signals]
46
+ * @property {string} classifiedAt
47
+ */
48
+
49
+ const AUTH_SERVICE_CATEGORIES = new Set([
50
+ 'auth', 'oauth', 'identityProvider', 'emailAuth',
51
+ ]);
52
+
53
+ /**
54
+ * Classify an FBS as UI-bearing or not.
55
+ *
56
+ * @param {import('#core/store/walker.js').TreeModel} tree
57
+ * @param {string} fbsId
58
+ * @param {object} [opts]
59
+ * @param {Date} [opts.now]
60
+ * @returns {UiClassificationBlock|null}
61
+ */
62
+ export function classifyFbs(tree, fbsId, opts = {}) {
63
+ const fbs = tree?.byId?.get(fbsId);
64
+ if (!fbs || tree.kindById?.get(fbsId) !== 'fbs') return null;
65
+ const now = (opts.now instanceof Date ? opts.now : new Date()).toISOString();
66
+
67
+ const signals = collectSignals(tree, fbs);
68
+
69
+ // Operator override wins. Field on the FBS document is authoritative
70
+ // per spec §4.4; we still preserve the classifier's evidence in
71
+ // signals[] for provenance so the operator can see what the machine
72
+ // would have said.
73
+ if (typeof fbs.uiBearing === 'boolean') {
74
+ const block = {
75
+ verdict: 'operatorOverride',
76
+ reason: 'operatorOverride',
77
+ classifiedAt: now,
78
+ };
79
+ if (signals.length > 0) block.signals = signals;
80
+ return block;
81
+ }
82
+
83
+ if (signals.length > 0) {
84
+ return {
85
+ verdict: 'ui',
86
+ reason: 'keyword-scan',
87
+ signals,
88
+ classifiedAt: now,
89
+ };
90
+ }
91
+ return {
92
+ verdict: 'notUi',
93
+ reason: 'keyword-scan',
94
+ classifiedAt: now,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Collect UI signals for one FBS. In-order: summary, then per-AC
100
+ * description/given/when/then with acId anchored, then parent US
101
+ * iWant/soThat, then ancestor REQ description/rationale, then the
102
+ * dependsOnServices auth-signal.
103
+ *
104
+ * @param {import('#core/store/walker.js').TreeModel} tree
105
+ * @param {object} fbs
106
+ * @returns {UiClassifierSignal[]}
107
+ */
108
+ export function collectSignals(tree, fbs) {
109
+ /** @type {UiClassifierSignal[]} */
110
+ const signals = [];
111
+
112
+ // Summary + title. Title concatenated first so a title-only signal
113
+ // still lands under source: 'summary' (only one FBS-body source in
114
+ // the schema; the two fields cover the same intent).
115
+ const summaryText = [fbs.title, fbs.summary].filter(Boolean).join('. ');
116
+ for (const m of matchUiSignals(summaryText)) {
117
+ signals.push({ source: 'summary', match: m.match });
118
+ }
119
+
120
+ // ACs (in-scope for this FBS via acIds) + parents. Walker exposes
121
+ // arrays; build local ac/us/req lookups on the fly (small trees, one
122
+ // shot per classification, cheaper than an extra derived map on the
123
+ // walker for a build-side classifier that runs occasionally).
124
+ const usByAcId = new Map();
125
+ const reqById = new Map();
126
+ for (const us of Array.isArray(tree?.userStories) ? tree.userStories : []) {
127
+ for (const ac of Array.isArray(us?.acceptanceCriteria) ? us.acceptanceCriteria : []) {
128
+ if (ac?.id) usByAcId.set(ac.id, us);
129
+ }
130
+ }
131
+ for (const req of Array.isArray(tree?.requirements) ? tree.requirements : []) {
132
+ if (req?.reqId) reqById.set(req.reqId, req);
133
+ }
134
+ const seenUsIds = new Set();
135
+ const seenReqIds = new Set();
136
+ const fbsAcIds = Array.isArray(fbs.acIds) ? fbs.acIds : [];
137
+ for (const acId of fbsAcIds) {
138
+ const us = usByAcId.get(acId);
139
+ const ac = us?.acceptanceCriteria?.find((a) => a.id === acId);
140
+ if (ac) {
141
+ const acText = [ac.description, ac.given, ac.when, ac.then].filter(Boolean).join('. ');
142
+ for (const m of matchUiSignals(acText)) {
143
+ signals.push({ source: 'ac.description', acId, match: m.match });
144
+ }
145
+ }
146
+ if (us && !seenUsIds.has(us.usId)) {
147
+ seenUsIds.add(us.usId);
148
+ for (const m of matchUiSignals(us.iWant ?? '')) {
149
+ signals.push({ source: 'us.iWant', match: m.match });
150
+ }
151
+ for (const m of matchUiSignals(us.soThat ?? '')) {
152
+ signals.push({ source: 'us.soThat', match: m.match });
153
+ }
154
+ const req = reqById.get(us.reqId);
155
+ if (req && !seenReqIds.has(req.reqId)) {
156
+ seenReqIds.add(req.reqId);
157
+ const reqText = [req.description, req.rationale].filter(Boolean).join('. ');
158
+ for (const m of matchUiSignals(reqText)) {
159
+ signals.push({ source: 'req.description', match: m.match });
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ // Auth-shaped service dependency implies an HTML login flow (spec
166
+ // §4.2 bullet 5); the signal is packaged as a summary-level match so
167
+ // the operator sees it in the same list.
168
+ for (const dep of Array.isArray(fbs.dependsOnServices) ? fbs.dependsOnServices : []) {
169
+ const category = String(dep?.serviceCategory ?? '');
170
+ if (AUTH_SERVICE_CATEGORIES.has(category)) {
171
+ signals.push({ source: 'summary', match: `dependsOnServices:${category}` });
172
+ }
173
+ }
174
+
175
+ return signals;
176
+ }
177
+
178
+ /**
179
+ * True when the FBS is UI-bearing (operator-tagged true). Used by
180
+ * downstream gates that must not silently promote the classifier's
181
+ * `verdict: ui` into enforcement: the operator's boolean is the
182
+ * authority per spec §4.4, and the classifier verdict is a proposal
183
+ * the operator sees before ratifying.
184
+ *
185
+ * @param {object} fbs
186
+ * @returns {boolean}
187
+ */
188
+ export function isUiBearing(fbs) {
189
+ if (!fbs) return false;
190
+ if (fbs.uiBearing === true) return true;
191
+ return false;
192
+ }