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.
- package/CHANGELOG.md +288 -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 +69 -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/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 +355 -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 +162 -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 +216 -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 +211 -0
- package/src/core/store/tp-resolve.js +176 -0
- package/src/core/store/validator.js +191 -0
- package/src/core/store/walker.js +898 -0
- package/src/core/store/writer.js +1849 -0
- package/src/design/index.js +11 -0
- package/src/design/writer.js +271 -0
- package/src/finalise/detect.js +129 -0
- package/src/finalise/index.js +17 -0
- package/src/finalise/ingest.js +119 -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 +1204 -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 +9 -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/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 +190 -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 +246 -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,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
|
+
}
|