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,11 @@
1
+ // Public surface for the design substage module
2
+ // (ui-design-gate-0.7.0-spec §5). CLI handler at `src/cli/design.js`.
3
+
4
+ export {
5
+ firstBaselineDisagreement,
6
+ missingDesignStageArtefacts,
7
+ writeJourneyAdd,
8
+ writeMarkComplete,
9
+ writeNavSet,
10
+ writeThemeA11ySet,
11
+ } from './writer.js';
@@ -0,0 +1,271 @@
1
+ // Design substage writer (ui-design-gate-0.7.0-spec §5, §6.2).
2
+ //
3
+ // Persistence primitives for the three artefacts (journeys, navModel,
4
+ // themeAndA11y) plus the `designStageComplete` boolean. Each verb
5
+ // writes via the core store's `updateDocument` path so the writer
6
+ // bumps `updatedAt` and schema-validates the FBS document.
7
+ //
8
+ // Grammar constraint (spec §5.5): the sub-verb parser lives in the CLI
9
+ // handler (`src/cli/design.js`); this module accepts already-parsed
10
+ // arguments and does not need to defend against string positional
11
+ // confusion at this layer.
12
+
13
+ import { rcfError } from '#core/errors';
14
+ import { updateDocument } from '#core/store';
15
+
16
+ import { baselineDesignDisagreement } from '../ui-baseline/manifest-writer.js';
17
+
18
+ /**
19
+ * @typedef {object} DesignJourney
20
+ * @property {string} id
21
+ * @property {string} actor
22
+ * @property {string} goal
23
+ * @property {string[]} steps
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} DesignNavRoute
28
+ * @property {string} path
29
+ * @property {string} label
30
+ * @property {boolean} authRequired
31
+ */
32
+
33
+ const NAV_SHAPES = new Set([
34
+ 'shared-persistent',
35
+ 'shared-per-section',
36
+ 'none-single-page',
37
+ 'operator-declared-other',
38
+ ]);
39
+ const THEME_MODES = new Set([
40
+ 'light-default-with-toggle',
41
+ 'dark-default-with-toggle',
42
+ 'single-theme-declared',
43
+ ]);
44
+
45
+ /**
46
+ * Append a journey to `fbs.designStage.journeys[]`. Writes via
47
+ * `updateDocument`.
48
+ *
49
+ * @param {object} args
50
+ * @param {string} args.projectRoot
51
+ * @param {import('#core/store/walker.js').TreeModel} args.tree
52
+ * @param {string} args.fbsId
53
+ * @param {DesignJourney} args.journey
54
+ * @param {string} [args.authoredBy]
55
+ * @param {Date} [args.now]
56
+ */
57
+ export async function writeJourneyAdd({ projectRoot, tree, fbsId, journey, authoredBy = 'operator', now = new Date() }) {
58
+ const fbs = tree.byId?.get(fbsId);
59
+ if (!fbs || tree.kindById?.get(fbsId) !== 'fbs') {
60
+ return rcfError({ kind: 'usage', message: `design journeys add: ${fbsId} not found or not an FBS`, documentId: fbsId });
61
+ }
62
+ const validation = validateJourneyShape(journey);
63
+ if (validation) return validation;
64
+ const existing = fbs.designStage?.journeys ?? [];
65
+ if (existing.some((j) => j.id === journey.id)) {
66
+ return rcfError({ kind: 'usage', message: `design journeys add: journey id '${journey.id}' already exists on ${fbsId}`, documentId: fbsId });
67
+ }
68
+ const nextStage = {
69
+ ...(fbs.designStage ?? {}),
70
+ journeys: [...existing, { ...journey }],
71
+ authoredAt: now.toISOString(),
72
+ authoredBy,
73
+ };
74
+ return await updateDocument({
75
+ projectRoot, tree, id: fbsId,
76
+ sets: [{ path: 'designStage', value: nextStage }],
77
+ options: {},
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Overwrite `fbs.designStage.navModel`. Writes via `updateDocument`.
83
+ *
84
+ * @param {object} args
85
+ */
86
+ export async function writeNavSet({ projectRoot, tree, fbsId, shape, routes, signedInAsAffordance, notes, authoredBy = 'operator', now = new Date() }) {
87
+ const fbs = tree.byId?.get(fbsId);
88
+ if (!fbs || tree.kindById?.get(fbsId) !== 'fbs') {
89
+ return rcfError({ kind: 'usage', message: `design nav set: ${fbsId} not found or not an FBS`, documentId: fbsId });
90
+ }
91
+ if (!NAV_SHAPES.has(shape)) {
92
+ return rcfError({ kind: 'usage', message: `design nav set: unknown --shape '${shape}' (expected ${[...NAV_SHAPES].join(' | ')})` });
93
+ }
94
+ if (!Array.isArray(routes) || routes.length === 0) {
95
+ return rcfError({ kind: 'usage', message: 'design nav set: at least one --route is required' });
96
+ }
97
+ for (const r of routes) {
98
+ if (!r || typeof r.path !== 'string' || typeof r.label !== 'string' || typeof r.authRequired !== 'boolean') {
99
+ return rcfError({ kind: 'usage', message: `design nav set: bad route '${JSON.stringify(r)}'; expected path=label:authRequired` });
100
+ }
101
+ }
102
+ const navModel = { shape, routes: routes.map((r) => ({ path: r.path, label: r.label, authRequired: r.authRequired })) };
103
+ if (typeof signedInAsAffordance === 'boolean') navModel.signedInAsAffordance = signedInAsAffordance;
104
+ if (typeof notes === 'string' && notes.length > 0) navModel.notes = notes;
105
+ const nextStage = {
106
+ ...(fbs.designStage ?? {}),
107
+ navModel,
108
+ authoredAt: now.toISOString(),
109
+ authoredBy,
110
+ };
111
+ return await updateDocument({
112
+ projectRoot, tree, id: fbsId,
113
+ sets: [{ path: 'designStage', value: nextStage }],
114
+ options: {},
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Overwrite `fbs.designStage.themeAndA11y`. Writes via `updateDocument`.
120
+ *
121
+ * @param {object} args
122
+ */
123
+ export async function writeThemeA11ySet({
124
+ projectRoot, tree, fbsId, themeMode, themeTokensModule, contrastTargets,
125
+ contrastTestPath, contrastTestAuthoredBeforePalette, authoredBy = 'operator', now = new Date(),
126
+ }) {
127
+ const fbs = tree.byId?.get(fbsId);
128
+ if (!fbs || tree.kindById?.get(fbsId) !== 'fbs') {
129
+ return rcfError({ kind: 'usage', message: `design theme-a11y set: ${fbsId} not found or not an FBS`, documentId: fbsId });
130
+ }
131
+ if (!THEME_MODES.has(themeMode)) {
132
+ return rcfError({ kind: 'usage', message: `design theme-a11y set: unknown --mode '${themeMode}' (expected ${[...THEME_MODES].join(' | ')})` });
133
+ }
134
+ if (typeof themeTokensModule !== 'string' || themeTokensModule.length === 0) {
135
+ return rcfError({ kind: 'usage', message: 'design theme-a11y set: --tokens is required' });
136
+ }
137
+ if (typeof contrastTestPath !== 'string' || contrastTestPath.length === 0) {
138
+ return rcfError({ kind: 'usage', message: 'design theme-a11y set: --contrast-test is required' });
139
+ }
140
+ if (typeof contrastTestAuthoredBeforePalette !== 'boolean') {
141
+ return rcfError({ kind: 'usage', message: 'design theme-a11y set: --contrast-before-palette (true|false) is required' });
142
+ }
143
+ const themeAndA11y = {
144
+ themeMode,
145
+ themeTokensModule,
146
+ contrastTargets: contrastTargets || 'WCAG AA',
147
+ contrastTestPath,
148
+ contrastTestAuthoredBeforePalette,
149
+ };
150
+ const nextStage = {
151
+ ...(fbs.designStage ?? {}),
152
+ themeAndA11y,
153
+ authoredAt: now.toISOString(),
154
+ authoredBy,
155
+ };
156
+ return await updateDocument({
157
+ projectRoot, tree, id: fbsId,
158
+ sets: [{ path: 'designStage', value: nextStage }],
159
+ options: {},
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Set `designStageComplete: true`. Refuses when any of the three
165
+ * artefacts is absent or empty, and when the baseline disagrees with
166
+ * the designStage without an opt-out (spec §6.2 second refusal shape).
167
+ *
168
+ * @param {object} args
169
+ * @returns {Promise<{ ok: true } | import('#core/errors').RcfError>}
170
+ */
171
+ export async function writeMarkComplete({
172
+ projectRoot, tree, fbsId, authoredBy = 'operator', now = new Date(),
173
+ }) {
174
+ const fbs = tree.byId?.get(fbsId);
175
+ if (!fbs || tree.kindById?.get(fbsId) !== 'fbs') {
176
+ return rcfError({ kind: 'usage', message: `design --mark-complete: ${fbsId} not found or not an FBS`, documentId: fbsId });
177
+ }
178
+ const missing = missingDesignStageArtefacts(fbs);
179
+ if (missing.length > 0) {
180
+ return rcfError({
181
+ kind: 'usage',
182
+ message: `design --mark-complete: refused - ${fbsId} designStage is missing: ${missing.join(', ')}. `
183
+ + 'Author the missing artefacts first via `rcf design ' + fbsId + ' journeys add ...`, `nav set ...`, or `theme-a11y set ...`.',
184
+ documentId: fbsId,
185
+ });
186
+ }
187
+ const disagreement = firstBaselineDisagreement(tree, fbs);
188
+ if (disagreement) {
189
+ return rcfError({
190
+ kind: 'usage',
191
+ message: `design --mark-complete: refused - ${fbsId} designStage.${disagreement.designStagePath} = ${JSON.stringify(disagreement.designValue)} conflicts with uiBaseline.defaults.${disagreement.path} = ${JSON.stringify(disagreement.baselineValue)} and there is no operatorOptOuts entry. `
192
+ + 'Options: (1) change designStage to match, (2) rcf ui-baseline opt-out --field '
193
+ + disagreement.path + ' --reason "..." (project override), (3) rcf update '
194
+ + fbsId + ' --set designStage.' + disagreement.designStagePath + '=... (per-FBS override).',
195
+ documentId: fbsId,
196
+ });
197
+ }
198
+ const nextStage = {
199
+ ...(fbs.designStage ?? {}),
200
+ authoredAt: now.toISOString(),
201
+ authoredBy,
202
+ };
203
+ const result = await updateDocument({
204
+ projectRoot, tree, id: fbsId,
205
+ sets: [
206
+ { path: 'designStage', value: nextStage },
207
+ { path: 'designStageComplete', value: true },
208
+ ],
209
+ options: {},
210
+ });
211
+ if (result && 'kind' in result && 'message' in result) return result;
212
+ return { ok: true };
213
+ }
214
+
215
+ /**
216
+ * Which of the three required artefacts is missing or empty on the FBS.
217
+ *
218
+ * @param {object} fbs
219
+ * @returns {string[]}
220
+ */
221
+ export function missingDesignStageArtefacts(fbs) {
222
+ const missing = [];
223
+ const stage = fbs?.designStage ?? {};
224
+ if (!Array.isArray(stage.journeys) || stage.journeys.length === 0) missing.push('journeys');
225
+ if (!stage.navModel || !Array.isArray(stage.navModel.routes) || stage.navModel.routes.length === 0) missing.push('navModel');
226
+ if (!stage.themeAndA11y || typeof stage.themeAndA11y.themeMode !== 'string') missing.push('themeAndA11y');
227
+ return missing;
228
+ }
229
+
230
+ /**
231
+ * First baseline-vs-designStage disagreement (per spec §6.2). Returns
232
+ * null on agreement or when no baseline / designStage exists.
233
+ *
234
+ * @param {import('#core/store/walker.js').TreeModel} tree
235
+ * @param {object} fbs
236
+ * @returns {{ path: string, designStagePath: string, baselineValue: *, designValue: * }|null}
237
+ */
238
+ export function firstBaselineDisagreement(tree, fbs) {
239
+ const baseline = tree?.manifest?.uiBaseline;
240
+ const stage = fbs?.designStage;
241
+ if (!baseline || !stage) return null;
242
+ // Fields that pair between baseline defaults and designStage.
243
+ const pairs = [
244
+ { defaults: 'themeMode', designStage: 'themeAndA11y.themeMode' },
245
+ { defaults: 'designTokensModule', designStage: 'themeAndA11y.themeTokensModule' },
246
+ { defaults: 'contrastTestBeforePalette', designStage: 'themeAndA11y.contrastTestAuthoredBeforePalette' },
247
+ ];
248
+ for (const p of pairs) {
249
+ const disagreement = baselineDesignDisagreement(baseline, stage, p.defaults, p.designStage);
250
+ if (disagreement) {
251
+ return { ...disagreement, designStagePath: p.designStage };
252
+ }
253
+ }
254
+ return null;
255
+ }
256
+
257
+ function validateJourneyShape(j) {
258
+ if (!j || typeof j !== 'object') return rcfError({ kind: 'usage', message: 'design journeys add: journey object required' });
259
+ if (typeof j.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(j.id)) {
260
+ return rcfError({ kind: 'usage', message: `design journeys add: --id must be a lowercase slug matching /^[a-z][a-z0-9-]*$/, got '${j.id}'` });
261
+ }
262
+ if (typeof j.actor !== 'string' || j.actor.length === 0) return rcfError({ kind: 'usage', message: 'design journeys add: --actor is required' });
263
+ if (typeof j.goal !== 'string' || j.goal.length === 0) return rcfError({ kind: 'usage', message: 'design journeys add: --goal is required' });
264
+ if (!Array.isArray(j.steps) || j.steps.length < 2 || j.steps.length > 8) {
265
+ return rcfError({ kind: 'usage', message: `design journeys add: --step required 2 to 8 times, got ${Array.isArray(j.steps) ? j.steps.length : 0}` });
266
+ }
267
+ for (const s of j.steps) if (typeof s !== 'string' || s.length === 0) {
268
+ return rcfError({ kind: 'usage', message: 'design journeys add: each --step must be a non-empty string' });
269
+ }
270
+ return null;
271
+ }
@@ -0,0 +1,129 @@
1
+ // rcf-verify install detection (spec §8.3, amendment 5 - install-together
2
+ // posture). build-lite's finalise gate MUST detect whether `rcf-verify` is
3
+ // resolvable and, when it is absent, prompt to install it - NEVER silently
4
+ // skip the ship gate (the one behaviour §8.3 explicitly forbids).
5
+ //
6
+ // Two detection routes, in order, matching how the two packages are actually
7
+ // installed:
8
+ // 1. The `rcf-verify` bin on PATH - the install-together default is two
9
+ // global bins (`npm i -g @stravica-ai/rcf-build-lite @stravica-ai/rcf-verify-lite`).
10
+ // 2. Package resolution from the project dir - the local-project install
11
+ // (`npm i @stravica-ai/rcf-verify-lite` in a repo's node_modules).
12
+ // Either hit yields a concrete invocation the finalise spawn (spawn.js) uses
13
+ // verbatim. A miss returns { installed:false } and the caller enters the
14
+ // prompt-or-explicit-flag path.
15
+
16
+ import { access, constants } from 'node:fs/promises';
17
+ import { createRequire } from 'node:module';
18
+ import { delimiter, dirname, join, resolve } from 'node:path';
19
+
20
+ const VERIFY_PACKAGE = '@stravica-ai/rcf-verify-lite';
21
+ const VERIFY_BIN = 'rcf-verify';
22
+
23
+ /**
24
+ * The concrete way to launch rcf-verify as a fresh subprocess.
25
+ * - `{ command: '<abs-bin>', prefixArgs: [] }` for a PATH / shim bin
26
+ * (its own shebang runs it).
27
+ * - `{ command: process.execPath, prefixArgs: ['<abs-entry.js>'] }` for a
28
+ * package-resolved entry (run it under the current node).
29
+ * @typedef {{ command: string, prefixArgs: string[], source: 'path'|'package' }} VerifyInvocation
30
+ */
31
+
32
+ /**
33
+ * @typedef {{ installed: boolean, invocation: VerifyInvocation | null }} VerifyDetection
34
+ */
35
+
36
+ /**
37
+ * Scan PATH for an executable named `rcf-verify` (plus the Windows
38
+ * `.cmd`/`.exe` shim variants). Returns the first absolute path that exists
39
+ * and is executable, or null.
40
+ *
41
+ * @param {string} name
42
+ * @param {object} [io]
43
+ * @param {NodeJS.ProcessEnv} [io.env]
44
+ * @returns {Promise<string|null>}
45
+ */
46
+ export async function findOnPath(name, { env = process.env } = {}) {
47
+ const rawPath = env.PATH ?? env.Path ?? '';
48
+ if (!rawPath) return null;
49
+ const dirs = rawPath.split(delimiter).filter(Boolean);
50
+ // On Windows a bare name resolves via PATHEXT-style shims; probe the common
51
+ // npm shim names. On POSIX only the bare name matters.
52
+ const candidates = process.platform === 'win32'
53
+ ? [`${name}.cmd`, `${name}.exe`, name]
54
+ : [name];
55
+ for (const dir of dirs) {
56
+ for (const candidate of candidates) {
57
+ const full = resolve(dir, candidate);
58
+ try {
59
+ // X_OK is meaningless for the .cmd/.exe shims on Windows; existence is
60
+ // enough there. On POSIX require the execute bit.
61
+ await access(full, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
62
+ return full;
63
+ } catch {
64
+ // not here; keep scanning
65
+ }
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Resolve the rcf-verify package's bin entry point from a starting directory,
73
+ * following the normal node_modules resolution the caller's project sees.
74
+ * Returns the absolute path to `bin/rcf-verify.js`, or null if the package is
75
+ * not installed / not resolvable from there.
76
+ *
77
+ * @param {string} fromDir - directory to resolve from (the project root / cwd)
78
+ * @returns {Promise<string|null>}
79
+ */
80
+ export async function resolvePackageBin(fromDir) {
81
+ try {
82
+ // Resolve from a synthetic module living in fromDir so node walks that
83
+ // project's node_modules chain, not build-lite's own.
84
+ const req = createRequire(join(fromDir, 'noop.js'));
85
+ const pkgJsonPath = req.resolve(`${VERIFY_PACKAGE}/package.json`);
86
+ const req2 = createRequire(pkgJsonPath);
87
+ const pkg = req2(`${VERIFY_PACKAGE}/package.json`);
88
+ const binField = pkg.bin;
89
+ const rel = typeof binField === 'string' ? binField : binField?.[VERIFY_BIN];
90
+ if (!rel) return null;
91
+ const abs = resolve(dirname(pkgJsonPath), rel);
92
+ await access(abs, constants.F_OK);
93
+ return abs;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Detect whether rcf-verify is resolvable and, if so, how to launch it.
101
+ * Deps are injectable so the finalise gate can be exercised without a real
102
+ * rcf-verify on the test machine.
103
+ *
104
+ * @param {object} [deps]
105
+ * @param {string} [deps.cwd] - project dir to resolve a local install from
106
+ * @param {typeof findOnPath} [deps.findOnPath]
107
+ * @param {typeof resolvePackageBin} [deps.resolvePackageBin]
108
+ * @param {NodeJS.ProcessEnv} [deps.env]
109
+ * @returns {Promise<VerifyDetection>}
110
+ */
111
+ export async function detectVerify(deps = {}) {
112
+ const cwd = deps.cwd ?? process.cwd();
113
+ const onPath = deps.findOnPath ? await deps.findOnPath(VERIFY_BIN, { env: deps.env })
114
+ : await findOnPath(VERIFY_BIN, { env: deps.env });
115
+ if (onPath) {
116
+ return { installed: true, invocation: { command: onPath, prefixArgs: [], source: 'path' } };
117
+ }
118
+ const resolved = deps.resolvePackageBin ? await deps.resolvePackageBin(cwd)
119
+ : await resolvePackageBin(cwd);
120
+ if (resolved) {
121
+ return {
122
+ installed: true,
123
+ invocation: { command: process.execPath, prefixArgs: [resolved], source: 'package' },
124
+ };
125
+ }
126
+ return { installed: false, invocation: null };
127
+ }
128
+
129
+ export { VERIFY_PACKAGE, VERIFY_BIN };
@@ -0,0 +1,17 @@
1
+ // Finalise-gate barrel (spec §8). build-lite's finalise step invokes rcf-verify
2
+ // as a fresh subprocess with the isolation env (spawn.js), gates the
3
+ // complete -> verified transition on the subprocess exit code AND the report's
4
+ // ship authority (spec §4 - a correctness-only pass holds without promoting),
5
+ // ingests findings from the --out report (ingest.js), and - when rcf-verify is
6
+ // absent - prompts to install rather than silently skipping the gate (detect.js
7
+ // + install.js).
8
+
9
+ export { detectVerify, findOnPath, resolvePackageBin, VERIFY_PACKAGE, VERIFY_BIN } from './detect.js';
10
+ export { buildVerifyArgs, spawnVerify } from './spawn.js';
11
+ export { promptYesNo, installVerify, resolveAbsentVerify } from './install.js';
12
+ export { loadReport, summariseReport, findMockOnlyDeclaredAcs, reportHasMockOnlyDeclared } from './ingest.js';
13
+ export {
14
+ composeShipWithoutVerifiedRecord,
15
+ nextShipWithoutVerifiedId,
16
+ writeShipWithoutVerifiedRecord,
17
+ } from './ship-without-verified.js';
@@ -0,0 +1,119 @@
1
+ // Report ingestion (spec §8.2 + §5.4). Findings flow from verify to build-lite
2
+ // via the --out report file (chain-node-addressed camelCase JSON), NEVER via
3
+ // stdout scraping. build-lite reads the artifact and, on a blocked gate,
4
+ // surfaces the findings mapped to their contract lines (acId / chain node) so
5
+ // the operator can drive the §5.4 verify -> fix -> re-verify loop.
6
+ //
7
+ // This is deliberately a READ of verify's artifact - build never re-derives a
8
+ // verdict, it consumes the one verify stamped.
9
+
10
+ import { readFile } from 'node:fs/promises';
11
+
12
+ /**
13
+ * Load and parse a verify report artifact. Returns the parsed report, or a
14
+ * shape describing why it could not be read (missing / unparseable) so the
15
+ * caller can degrade gracefully - a gate failure with an unreadable report is
16
+ * still a gate failure, never a pass.
17
+ *
18
+ * @param {string} reportPath
19
+ * @param {object} [deps]
20
+ * @param {typeof readFile} [deps.readFile]
21
+ * @returns {Promise<{ ok: true, report: object } | { ok: false, reason: string }>}
22
+ */
23
+ export async function loadReport(reportPath, deps = {}) {
24
+ const read = deps.readFile ?? readFile;
25
+ let raw;
26
+ try {
27
+ raw = await read(reportPath, 'utf8');
28
+ } catch (err) {
29
+ return { ok: false, reason: `report not found at ${reportPath}: ${err.message}` };
30
+ }
31
+ try {
32
+ return { ok: true, report: JSON.parse(raw) };
33
+ } catch (err) {
34
+ return { ok: false, reason: `report at ${reportPath} is not valid JSON: ${err.message}` };
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Render a compact, human-readable finalise summary of a verify report. Maps
40
+ * each finding to its contract line (acId) - the RCF payoff. Pure so it is
41
+ * directly testable; the caller writes the returned string to its sink.
42
+ *
43
+ * The verification-integrity 0.7.0 additions (spec §5.2 / §4.5 finalise
44
+ * text): if the report carries per-AC verdicts in
45
+ * {MOCK-ONLY-DECLARED, BLOCKED-BY-DECLARATION}, they are surfaced as a
46
+ * dedicated section so the operator sees an honest picture of what
47
+ * shipped mock-only. The section renders even on a passing verdict
48
+ * (it is disclosure, not a refusal).
49
+ *
50
+ * @param {object} report - a parsed verify report (§5.3 schema)
51
+ * @returns {string}
52
+ */
53
+ export function summariseReport(report) {
54
+ const lines = [];
55
+ const authority = report.verdictAuthority ? ` [${report.verdictAuthority}]` : '';
56
+ lines.push(`verdict: ${report.verdict}${authority}`);
57
+ const run = report.run ?? {};
58
+ if (run.profile || run.url) {
59
+ lines.push(`runtime: profile=${run.profile ?? '?'} url=${run.url ?? '?'}`
60
+ + (run.parityEnv ? ' parity-env' : ''));
61
+ }
62
+ const findings = Array.isArray(report.findings) ? report.findings : [];
63
+ if (findings.length > 0) {
64
+ lines.push(`findings (${findings.length}):`);
65
+ for (const f of findings) {
66
+ const ac = f.acId ? ` ${f.acId}` : '';
67
+ const journey = f.journey ? ` (${f.journey})` : '';
68
+ lines.push(` - ${f.severity ?? '?'}${ac}${journey}`);
69
+ }
70
+ }
71
+ const blocked = Array.isArray(report.blockedAcs) ? report.blockedAcs : [];
72
+ if (blocked.length > 0) {
73
+ lines.push(`blocked ACs (${blocked.length}):`);
74
+ for (const b of blocked) {
75
+ lines.push(` - ${b.acId ?? '?'}: ${b.reason ?? 'unprovisionable'}`);
76
+ }
77
+ }
78
+ const declared = findMockOnlyDeclaredAcs(report);
79
+ if (declared.length > 0) {
80
+ lines.push(`mock-only declared (${declared.length}):`);
81
+ for (const d of declared) {
82
+ lines.push(` - ${d.acId ?? '?'} (${d.verdict}): ${d.reason ?? 'declaredMockOnly at pre-flight; verify emitted the honest verdict rather than a false PASS.'}`);
83
+ }
84
+ }
85
+ if (report.launchFailure?.message) {
86
+ lines.push(`launch failure: ${report.launchFailure.message}`);
87
+ }
88
+ return `${lines.join('\n')}\n`;
89
+ }
90
+
91
+ /**
92
+ * Extract per-AC verdicts in {MOCK-ONLY-DECLARED, BLOCKED-BY-DECLARATION}
93
+ * from a verify report. Verify authors these under `perAcVerdicts[]` in
94
+ * the 0.7.0 extension; earlier reports carry no field, in which case
95
+ * this returns an empty array (graceful when no verify-side extension
96
+ * has landed — verify's train car is later).
97
+ *
98
+ * @param {object} report
99
+ * @returns {Array<{ acId: string, verdict: string, reason?: string }>}
100
+ */
101
+ export function findMockOnlyDeclaredAcs(report) {
102
+ const perAc = Array.isArray(report?.perAcVerdicts) ? report.perAcVerdicts : [];
103
+ return perAc
104
+ .filter((e) => e && (e.verdict === 'MOCK-ONLY-DECLARED' || e.verdict === 'BLOCKED-BY-DECLARATION'))
105
+ .map((e) => ({ acId: e.acId, verdict: e.verdict, reason: e.reason }));
106
+ }
107
+
108
+ /**
109
+ * True when a verify report carries at least one MOCK-ONLY-DECLARED or
110
+ * BLOCKED-BY-DECLARATION verdict. Used by the finalise gate to refuse
111
+ * promotion to `verified` on such reports unless the operator has
112
+ * explicitly shipped-without-verified (spec §5.2 finalise gate rule).
113
+ *
114
+ * @param {object} report
115
+ * @returns {boolean}
116
+ */
117
+ export function reportHasMockOnlyDeclared(report) {
118
+ return findMockOnlyDeclaredAcs(report).length > 0;
119
+ }
@@ -0,0 +1,119 @@
1
+ // Absent-verify handling (spec §8.3, amendment 5). When `rcf-verify` is not
2
+ // resolvable, the finalise gate MUST NOT silently skip - that recreates the
3
+ // false-confidence failure the whole programme exists to prevent. The
4
+ // sanctioned paths are exactly two, and nothing else:
5
+ // - interactive TTY -> prompt the operator to install now (y/N);
6
+ // - non-interactive -> require an explicit --install-verify flag.
7
+ // A silent skip and a silent auto-install are BOTH violations. Declining the
8
+ // prompt (or omitting the flag off a TTY) aborts finalise - it does not pass
9
+ // the gate.
10
+
11
+ import { spawn } from 'node:child_process';
12
+ import { createInterface } from 'node:readline';
13
+
14
+ import { VERIFY_PACKAGE } from './detect.js';
15
+
16
+ /**
17
+ * Interactive yes/no prompt over stdin/stdout. Resolves true only on an
18
+ * explicit affirmative; EOF / anything else is a decline (safe default: do
19
+ * not install, do not skip the gate).
20
+ *
21
+ * @param {string} question
22
+ * @param {object} [io]
23
+ * @param {NodeJS.ReadableStream} [io.input]
24
+ * @param {NodeJS.WritableStream} [io.output]
25
+ * @returns {Promise<boolean>}
26
+ */
27
+ export function promptYesNo(question, { input = process.stdin, output = process.stdout } = {}) {
28
+ return new Promise((resolvePromise) => {
29
+ const rl = createInterface({ input, output });
30
+ rl.question(`${question} `, (answer) => {
31
+ rl.close();
32
+ resolvePromise(/^y(es)?$/i.test((answer ?? '').trim()));
33
+ });
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Install rcf-verify globally (matches the install-together default of two
39
+ * global bins). Streams npm's output to the operator. Returns the install
40
+ * process exit code.
41
+ *
42
+ * @param {object} [deps]
43
+ * @param {typeof spawn} [deps.spawn]
44
+ * @param {NodeJS.WritableStream} [deps.stdout]
45
+ * @param {NodeJS.WritableStream} [deps.stderr]
46
+ * @param {string} [deps.packageSpec]
47
+ * @returns {Promise<number>}
48
+ */
49
+ export function installVerify(deps = {}) {
50
+ const spawnFn = deps.spawn ?? spawn;
51
+ const spec = deps.packageSpec ?? VERIFY_PACKAGE;
52
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
53
+ return new Promise((resolvePromise, reject) => {
54
+ const child = spawnFn(npm, ['install', '-g', spec], { stdio: 'inherit' });
55
+ child.on('error', reject);
56
+ child.on('close', (code, signal) => resolvePromise(code ?? (signal ? 1 : 0)));
57
+ });
58
+ }
59
+
60
+ /**
61
+ * The absent-verify decision (§8.3). Given the runtime shape (TTY? explicit
62
+ * flag?) decides among: install-then-proceed, or abort. NEVER skip.
63
+ *
64
+ * @param {object} args
65
+ * @param {boolean} args.installFlag - --install-verify was passed
66
+ * @param {boolean} args.isTty - stdin+stdout are a TTY
67
+ * @param {object} io - { stdout, stderr, input, output }
68
+ * @param {object} [deps]
69
+ * @param {typeof promptYesNo} [deps.promptYesNo]
70
+ * @param {typeof installVerify} [deps.installVerify]
71
+ * @returns {Promise<{ action: 'installed' } | { action: 'abort', reason: string, code: number }>}
72
+ */
73
+ export async function resolveAbsentVerify({ installFlag, isTty }, io, deps = {}) {
74
+ const prompt = deps.promptYesNo ?? promptYesNo;
75
+ const install = deps.installVerify ?? installVerify;
76
+
77
+ let wantsInstall = false;
78
+ if (installFlag) {
79
+ // Explicit flag: the sanctioned non-interactive install path.
80
+ io.stdout.write(`[finalise] rcf-verify not found; installing ${VERIFY_PACKAGE} (--install-verify)...\n`);
81
+ wantsInstall = true;
82
+ } else if (isTty) {
83
+ // Interactive: prompt. Declining aborts (never a silent skip).
84
+ io.stderr.write(`[finalise] rcf-verify is not installed. The ship gate cannot run without it.\n`);
85
+ wantsInstall = await prompt(`Install ${VERIFY_PACKAGE} now? [y/N]`, {
86
+ input: io.input, output: io.output,
87
+ });
88
+ if (!wantsInstall) {
89
+ return {
90
+ action: 'abort',
91
+ code: 4,
92
+ reason: `finalise refused: rcf-verify is required and install was declined. `
93
+ + `Install it (npm i -g ${VERIFY_PACKAGE}) or re-run with --install-verify. `
94
+ + `The ship gate is never skipped.`,
95
+ };
96
+ }
97
+ } else {
98
+ // Non-interactive without the flag: cannot prompt, must not auto-install
99
+ // silently, must not skip. Abort with the exact remedy.
100
+ return {
101
+ action: 'abort',
102
+ code: 4,
103
+ reason: `finalise refused: rcf-verify is not installed and no TTY is available to prompt. `
104
+ + `Install it (npm i -g ${VERIFY_PACKAGE}) or re-run with --install-verify. `
105
+ + `The ship gate is never silently skipped.`,
106
+ };
107
+ }
108
+
109
+ const installCode = await install({ stdout: io.stdout, stderr: io.stderr });
110
+ if (installCode !== 0) {
111
+ return {
112
+ action: 'abort',
113
+ code: 1,
114
+ reason: `finalise aborted: installing ${VERIFY_PACKAGE} failed (npm exit ${installCode}). `
115
+ + `Install it manually and re-run finalise.`,
116
+ };
117
+ }
118
+ return { action: 'installed' };
119
+ }