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.
- package/CHANGELOG.md +344 -0
- package/LICENSE +202 -0
- package/README.md +92 -4
- package/bin/rcf-verify.js +122 -0
- package/bin/rcf.js +174 -0
- package/bin/view-supervisor-child.mjs +14 -0
- package/fixtures/canary-manifest.json +103 -0
- package/guidance/README.md +25 -0
- package/guidance/build-cycle-playbook.md +492 -0
- package/guidance/build-cycle.md +40 -0
- package/guidance/document-model.md +45 -0
- package/guidance/elicitation-playbook.md +442 -0
- package/guidance/harness-template.md +246 -0
- package/guidance/managed/README.md +63 -0
- package/guidance/managed/agent-instructions-block.hash +1 -0
- package/guidance/managed/agent-instructions-block.md +220 -0
- package/guidance/managed/legacy-fragment-hashes.json +22 -0
- package/guidance/manifest.json +21 -0
- package/guidance/overview.md +48 -0
- package/guidance/persona-programme.md +44 -0
- package/package.json +71 -8
- package/rcf/adrs/adr-001.json +25 -0
- package/rcf/adrs/adr-002.json +25 -0
- package/rcf/adrs/adr-003.json +25 -0
- package/rcf/adrs/adr-004.json +20 -0
- package/rcf/adrs/adr-005.json +20 -0
- package/rcf/adrs/adr-006.json +25 -0
- package/rcf/adrs/adr-007.json +25 -0
- package/rcf/adrs/adr-008.json +25 -0
- package/rcf/adrs/adr-009.json +25 -0
- package/rcf/build-sequence.json +11 -0
- package/rcf/code-nodes/cn-001.json +14 -0
- package/rcf/code-nodes/cn-002.json +14 -0
- package/rcf/code-nodes/cn-003.json +15 -0
- package/rcf/code-nodes/cn-004.json +17 -0
- package/rcf/code-nodes/cn-005.json +19 -0
- package/rcf/code-nodes/cn-006.json +17 -0
- package/rcf/code-nodes/cn-007.json +16 -0
- package/rcf/code-nodes/cn-008.json +19 -0
- package/rcf/code-nodes/cn-009.json +16 -0
- package/rcf/code-nodes/cn-010.json +18 -0
- package/rcf/code-nodes/cn-011.json +18 -0
- package/rcf/code-nodes/cn-012.json +18 -0
- package/rcf/code-nodes/cn-013.json +19 -0
- package/rcf/code-nodes/cn-014.json +16 -0
- package/rcf/code-nodes/cn-015.json +16 -0
- package/rcf/code-nodes/cn-016.json +16 -0
- package/rcf/code-nodes/cn-017.json +16 -0
- package/rcf/code-nodes/cn-018.json +15 -0
- package/rcf/code-nodes/cn-019.json +16 -0
- package/rcf/code-nodes/cn-020.json +16 -0
- package/rcf/code-nodes/cn-021.json +16 -0
- package/rcf/code-nodes/cn-022.json +16 -0
- package/rcf/code-nodes/cn-023.json +16 -0
- package/rcf/code-nodes/cn-024.json +16 -0
- package/rcf/code-nodes/cn-025.json +16 -0
- package/rcf/code-nodes/cn-026.json +16 -0
- package/rcf/code-nodes/cn-027.json +16 -0
- package/rcf/code-nodes/cn-028.json +16 -0
- package/rcf/code-nodes/cn-029.json +19 -0
- package/rcf/code-nodes/cn-030.json +14 -0
- package/rcf/code-nodes/cn-031.json +14 -0
- package/rcf/code-nodes/cn-032.json +14 -0
- package/rcf/code-nodes/cn-033.json +14 -0
- package/rcf/code-nodes/cn-034.json +14 -0
- package/rcf/code-nodes/cn-035.json +14 -0
- package/rcf/code-nodes/cn-036.json +14 -0
- package/rcf/code-nodes/cn-037.json +14 -0
- package/rcf/code-nodes/cn-038.json +14 -0
- package/rcf/code-nodes/cn-039.json +14 -0
- package/rcf/code-nodes/cn-040.json +14 -0
- package/rcf/code-nodes/cn-041.json +14 -0
- package/rcf/code-nodes/cn-042.json +14 -0
- package/rcf/code-nodes/cn-043.json +14 -0
- package/rcf/code-nodes/cn-044.json +14 -0
- package/rcf/code-nodes/cn-045.json +14 -0
- package/rcf/code-nodes/cn-046.json +14 -0
- package/rcf/code-nodes/cn-047.json +14 -0
- package/rcf/code-nodes/cn-048.json +14 -0
- package/rcf/code-nodes/cn-049.json +14 -0
- package/rcf/code-nodes/cn-050.json +14 -0
- package/rcf/code-nodes/cn-051.json +14 -0
- package/rcf/code-nodes/cn-052.json +14 -0
- package/rcf/code-nodes/cn-053.json +14 -0
- package/rcf/code-nodes/cn-054.json +14 -0
- package/rcf/code-nodes/cn-055.json +14 -0
- package/rcf/code-nodes/cn-056.json +14 -0
- package/rcf/code-nodes/cn-057.json +14 -0
- package/rcf/fbs/fbs-001.json +49 -0
- package/rcf/fbs/fbs-002.json +42 -0
- package/rcf/fbs/fbs-003.json +37 -0
- package/rcf/fbs/fbs-004.json +39 -0
- package/rcf/fbs/fbs-005.json +38 -0
- package/rcf/fbs/fbs-006.json +48 -0
- package/rcf/fbs/fbs-007.json +39 -0
- package/rcf/fbs/fbs-008.json +40 -0
- package/rcf/fbs/fbs-009.json +36 -0
- package/rcf/fbs/fbs-010.json +41 -0
- package/rcf/fbs/fbs-011.json +36 -0
- package/rcf/fbs/fbs-012.json +46 -0
- package/rcf/fbs/fbs-013.json +42 -0
- package/rcf/fbs/fbs-014.json +49 -0
- package/rcf/fbs/fbs-015.json +32 -0
- package/rcf/manifest.json +17 -0
- package/rcf/prd.json +47 -0
- package/rcf/requirements/req-001.json +19 -0
- package/rcf/requirements/req-002.json +19 -0
- package/rcf/requirements/req-003.json +19 -0
- package/rcf/requirements/req-004.json +19 -0
- package/rcf/requirements/req-005.json +19 -0
- package/rcf/requirements/req-006.json +19 -0
- package/rcf/requirements/req-007.json +19 -0
- package/rcf/requirements/req-008.json +19 -0
- package/rcf/requirements/req-009.json +19 -0
- package/rcf/tacs/tac-001.json +45 -0
- package/rcf/tacs/tac-002.json +109 -0
- package/rcf/tacs/tac-003.json +40 -0
- package/rcf/tacs/tac-004.json +51 -0
- package/rcf/tacs/tac-005.json +52 -0
- package/rcf/tacs/tac-006.json +104 -0
- package/rcf/tacs/tac-007.json +38 -0
- package/rcf/tacs/tac-008.json +51 -0
- package/rcf/tad.json +51 -0
- package/rcf/test-suites/PENDING.md +23 -0
- package/rcf/test-suites/ts-001.json +38 -0
- package/rcf/test-suites/ts-002.json +38 -0
- package/rcf/test-suites/ts-003.json +43 -0
- package/rcf/test-suites/ts-004.json +44 -0
- package/rcf/test-suites/ts-005.json +30 -0
- package/rcf/test-suites/ts-006.json +36 -0
- package/rcf/test-suites/ts-007.json +43 -0
- package/rcf/test-suites/ts-008.json +37 -0
- package/rcf/test-suites/ts-009.json +38 -0
- package/rcf/test-suites/ts-010.json +38 -0
- package/rcf/test-suites/ts-011.json +44 -0
- package/rcf/test-suites/ts-012.json +36 -0
- package/rcf/test-suites/ts-013.json +38 -0
- package/rcf/test-suites/ts-014.json +38 -0
- package/rcf/test-suites/ts-015.json +38 -0
- package/rcf/test-suites/ts-016.json +37 -0
- package/rcf/test-suites/ts-017.json +38 -0
- package/rcf/test-suites/ts-018.json +38 -0
- package/rcf/test-suites/ts-019.json +37 -0
- package/rcf/test-suites/ts-020.json +46 -0
- package/rcf/test-suites/ts-021.json +46 -0
- package/rcf/test-suites/ts-022.json +46 -0
- package/rcf/test-suites/ts-023.json +46 -0
- package/rcf/test-suites/ts-024.json +46 -0
- package/rcf/test-suites/ts-025.json +52 -0
- package/rcf/user-stories/us-101.json +40 -0
- package/rcf/user-stories/us-102.json +40 -0
- package/rcf/user-stories/us-201.json +40 -0
- package/rcf/user-stories/us-202.json +40 -0
- package/rcf/user-stories/us-203.json +32 -0
- package/rcf/user-stories/us-301.json +40 -0
- package/rcf/user-stories/us-302.json +40 -0
- package/rcf/user-stories/us-303.json +40 -0
- package/rcf/user-stories/us-304.json +40 -0
- package/rcf/user-stories/us-401.json +40 -0
- package/rcf/user-stories/us-402.json +40 -0
- package/rcf/user-stories/us-403.json +40 -0
- package/rcf/user-stories/us-501.json +40 -0
- package/rcf/user-stories/us-502.json +40 -0
- package/rcf/user-stories/us-503.json +40 -0
- package/rcf/user-stories/us-601.json +40 -0
- package/rcf/user-stories/us-602.json +40 -0
- package/rcf/user-stories/us-701.json +40 -0
- package/rcf/user-stories/us-702.json +40 -0
- package/rcf/user-stories/us-801.json +49 -0
- package/rcf/user-stories/us-802.json +49 -0
- package/rcf/user-stories/us-803.json +49 -0
- package/rcf/user-stories/us-804.json +49 -0
- package/rcf/user-stories/us-805.json +49 -0
- package/rcf/user-stories/us-901.json +40 -0
- package/src/.gitkeep +0 -0
- package/src/admissibility/enforce.js +142 -0
- package/src/admissibility/index.js +8 -0
- package/src/admissibility/markers.js +104 -0
- package/src/admissibility/scope-lint.js +163 -0
- package/src/browser-verify/auth-smoke.js +109 -0
- package/src/browser-verify/index.js +29 -0
- package/src/browser-verify/invariants.js +336 -0
- package/src/browser-verify/manifest-writer.js +189 -0
- package/src/browser-verify/runner.js +171 -0
- package/src/build/bundle.js +198 -0
- package/src/build/formatters/json.js +18 -0
- package/src/build/formatters/markdown.js +406 -0
- package/src/build/index.js +14 -0
- package/src/build/mark.js +177 -0
- package/src/build/queue.js +285 -0
- package/src/cli/browser-verify.js +231 -0
- package/src/cli/build.js +584 -0
- package/src/cli/coverage.js +219 -0
- package/src/cli/create.js +358 -0
- package/src/cli/delete.js +127 -0
- package/src/cli/design.js +353 -0
- package/src/cli/doctor.js +548 -0
- package/src/cli/fbs.js +171 -0
- package/src/cli/finalise.js +377 -0
- package/src/cli/guidance.js +206 -0
- package/src/cli/help.js +156 -0
- package/src/cli/impact.js +119 -0
- package/src/cli/init.js +282 -0
- package/src/cli/intake.js +153 -0
- package/src/cli/link.js +128 -0
- package/src/cli/mcp.js +160 -0
- package/src/cli/preflight.js +220 -0
- package/src/cli/read.js +168 -0
- package/src/cli/req-baseline.js +269 -0
- package/src/cli/req-classify.js +135 -0
- package/src/cli/review.js +295 -0
- package/src/cli/test-suite.js +221 -0
- package/src/cli/trace.js +172 -0
- package/src/cli/ui-baseline.js +292 -0
- package/src/cli/ui-classify.js +108 -0
- package/src/cli/update.js +197 -0
- package/src/cli/validate.js +168 -0
- package/src/cli/view.js +452 -0
- package/src/core/baseline-catalog/data/auth.json +42 -0
- package/src/core/baseline-catalog/data/http-api.json +42 -0
- package/src/core/baseline-catalog/data/notifications.json +33 -0
- package/src/core/baseline-catalog/data/persistence.json +33 -0
- package/src/core/baseline-catalog/data/web-ui.json +60 -0
- package/src/core/baseline-catalog/index.js +121 -0
- package/src/core/errors/index.js +167 -0
- package/src/core/fixtures/register-canary/canary-prompt-01.json +18 -0
- package/src/core/fixtures/register-canary/canary-prompt-02.json +21 -0
- package/src/core/fixtures/register-canary/canary-prompt-03.json +17 -0
- package/src/core/isolation/index.js +60 -0
- package/src/core/mcp/framing.js +103 -0
- package/src/core/mcp/index.js +8 -0
- package/src/core/mcp/server.js +228 -0
- package/src/core/patterns/register-canary.js +209 -0
- package/src/core/patterns/req-shapes.js +158 -0
- package/src/core/patterns/services.js +358 -0
- package/src/core/patterns/ui-shapes.js +166 -0
- package/src/core/store/cn-resolve.js +134 -0
- package/src/core/store/derive-deps.js +93 -0
- package/src/core/store/ids.js +78 -0
- package/src/core/store/index.js +20 -0
- package/src/core/store/init.js +255 -0
- package/src/core/store/loader.js +214 -0
- package/src/core/store/tp-resolve.js +176 -0
- package/src/core/store/validator.js +191 -0
- package/src/core/store/walker.js +944 -0
- package/src/core/store/writer.js +1879 -0
- package/src/design/index.js +11 -0
- package/src/design/writer.js +271 -0
- package/src/finalise/detect.js +151 -0
- package/src/finalise/index.js +31 -0
- package/src/finalise/ingest.js +160 -0
- package/src/finalise/install.js +119 -0
- package/src/finalise/ship-without-verified.js +131 -0
- package/src/finalise/spawn.js +84 -0
- package/src/intake/fidelity.js +105 -0
- package/src/intake/index.js +6 -0
- package/src/intake/manifest-writer.js +100 -0
- package/src/intake/orchestrator.js +138 -0
- package/src/intake/validate.js +80 -0
- package/src/mcp/map-errors.js +131 -0
- package/src/mcp/prompts.js +49 -0
- package/src/mcp/resources.js +244 -0
- package/src/mcp/tools.js +1212 -0
- package/src/preflight/design-shapes.js +185 -0
- package/src/preflight/index.js +90 -0
- package/src/preflight/manifest-writer.js +264 -0
- package/src/preflight/scanner.js +206 -0
- package/src/preflight/secrets.js +134 -0
- package/src/preflight/session.js +246 -0
- package/src/query/attestation.js +285 -0
- package/src/query/coverage.js +308 -0
- package/src/query/formatters/json.js +21 -0
- package/src/query/formatters/mermaid.js +209 -0
- package/src/query/formatters/table.js +203 -0
- package/src/query/impact.js +173 -0
- package/src/query/index.js +13 -0
- package/src/query/refuse-on-admissibility.js +73 -0
- package/src/query/trace.js +345 -0
- package/src/register-canary/fixture-loader.js +87 -0
- package/src/register-canary/index.js +10 -0
- package/src/register-canary/record-writer.js +132 -0
- package/src/register-canary/runner.js +156 -0
- package/src/req-baseline/gate.js +86 -0
- package/src/req-baseline/index.js +27 -0
- package/src/req-baseline/open-candidates.js +143 -0
- package/src/req-baseline/opt-out.js +195 -0
- package/src/req-baseline/sweep.js +230 -0
- package/src/req-detection/classifier.js +181 -0
- package/src/req-detection/index.js +9 -0
- package/src/req-detection/persist.js +55 -0
- package/src/review/index.js +325 -0
- package/src/review/mutation.js +117 -0
- package/src/review/ui-baseline-drift.js +138 -0
- package/src/ruleset/index.js +140 -0
- package/src/ruleset/ruleset.json +146 -0
- package/src/server/index.js +178 -0
- package/src/server/routes.js +110 -0
- package/src/server/sse.js +118 -0
- package/src/setup/agent-setup.js +362 -0
- package/src/setup/identity-seed.js +104 -0
- package/src/setup/knowledge-seed.js +123 -0
- package/src/setup/managed-block.js +193 -0
- package/src/setup/managed-gitignore.js +166 -0
- package/src/setup/managed-markers.js +49 -0
- package/src/ui-baseline/defaults.js +119 -0
- package/src/ui-baseline/index.js +25 -0
- package/src/ui-baseline/manifest-writer.js +282 -0
- package/src/ui-baseline/session.js +178 -0
- package/src/ui-detection/classifier.js +192 -0
- package/src/verify/chain/index.js +221 -0
- package/src/verify/cli/cleanup.js +61 -0
- package/src/verify/cli/help.js +56 -0
- package/src/verify/cli/mcp.js +98 -0
- package/src/verify/cli/provision.js +71 -0
- package/src/verify/cli/report.js +71 -0
- package/src/verify/cli/run.js +155 -0
- package/src/verify/engine/brief.js +87 -0
- package/src/verify/engine/index.js +177 -0
- package/src/verify/engine/launcher.js +307 -0
- package/src/verify/mcp/tools.js +107 -0
- package/src/verify/profile/index.js +146 -0
- package/src/verify/provision/index.js +256 -0
- package/src/verify/report/index.js +139 -0
- package/src/verify/report/renderer.js +118 -0
- package/src/verify/verdict/index.js +313 -0
- package/src/view/doc-renderers/adr.js +44 -0
- package/src/view/doc-renderers/build-sequence.js +40 -0
- package/src/view/doc-renderers/fbs.js +128 -0
- package/src/view/doc-renderers/helpers.js +159 -0
- package/src/view/doc-renderers/index.js +12 -0
- package/src/view/doc-renderers/prd.js +45 -0
- package/src/view/doc-renderers/req.js +43 -0
- package/src/view/doc-renderers/tac.js +38 -0
- package/src/view/doc-renderers/tad.js +74 -0
- package/src/view/doc-renderers/test-suite.js +45 -0
- package/src/view/doc-renderers/user-story.js +63 -0
- package/src/view/html-page.js +462 -0
- package/src/view/index.js +63 -0
- package/src/view/live-client.js +338 -0
- package/src/view/mermaid-diagram.js +178 -0
- package/src/view/style.css +735 -0
- package/src/view/tree-model.js +152 -0
- package/src/view/vendored/mermaid.min.js +2607 -0
- package/src/view-supervisor/index.js +26 -0
- package/src/view-supervisor/logs.js +32 -0
- package/src/view-supervisor/manifest-writer.js +178 -0
- package/src/view-supervisor/persist-until.js +85 -0
- package/src/view-supervisor/supervisor.js +276 -0
- 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
|
+
}
|