rcf-lite 0.28.2 → 0.29.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 +317 -0
- package/bin/rcf.js +8 -0
- package/fixtures/canary-manifest.json +9 -9
- package/guidance/define-draft-shapes.md +89 -0
- package/guidance/define-intake.md +97 -0
- package/guidance/define-intent.md +107 -0
- package/guidance/discover-prototype.md +67 -0
- package/guidance/discovery-prototypes.md +69 -0
- package/guidance/harness-template.md +34 -0
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +34 -0
- package/guidance/manifest.json +9 -2
- package/package.json +1 -1
- package/rcf/adrs/adr-4120-canonical-json-hashing.json +30 -0
- package/rcf/adrs/adr-4121-define-sidecars.json +30 -0
- package/rcf/adrs/adr-4122-define-gates-blocking-and-optout.json +30 -0
- package/rcf/adrs/adr-4123-readiness-single-object.json +30 -0
- package/rcf/adrs/adr-4124-define-freeze-verb.json +30 -0
- package/rcf/adrs/adr-4126-persona-tagged-blockers-two-levels.json +40 -0
- package/rcf/adrs/adr-4127-readiness-tab-order-and-register.json +40 -0
- package/rcf/adrs/adr-4129-questions-harness-driven-loop.json +25 -0
- package/rcf/adrs/adr-4130-intake-harness-split-tool-scanned.json +20 -0
- package/rcf/adrs/adr-4132-prototypes-in-discovery-as-evidence.json +20 -0
- package/rcf/adrs/adr-4133-dag-dependency-depth-layout.json +40 -0
- package/rcf/adrs/adr-4134-index-json-route.json +35 -0
- package/rcf/adrs/adr-4135-readiness-po-register-and-adapter.json +35 -0
- package/rcf/adrs/adr-4136-embedding-host-fixture.json +35 -0
- package/rcf/code-nodes/cn-700.json +23 -0
- package/rcf/code-nodes/cn-701.json +14 -0
- package/rcf/code-nodes/cn-702.json +14 -0
- package/rcf/code-nodes/cn-703.json +16 -0
- package/rcf/code-nodes/cn-720.json +15 -0
- package/rcf/code-nodes/cn-721.json +15 -0
- package/rcf/code-nodes/cn-722.json +15 -0
- package/rcf/code-nodes/cn-723.json +18 -0
- package/rcf/code-nodes/cn-724.json +14 -0
- package/rcf/code-nodes/cn-725.json +37 -0
- package/rcf/code-nodes/cn-726.json +25 -0
- package/rcf/code-nodes/cn-727.json +23 -0
- package/rcf/code-nodes/cn-728.json +37 -0
- package/rcf/code-nodes/cn-729.json +26 -0
- package/rcf/code-nodes/cn-730.json +24 -0
- package/rcf/code-nodes/cn-731.json +20 -0
- package/rcf/code-nodes/cn-732.json +14 -0
- package/rcf/code-nodes/cn-733.json +14 -0
- package/rcf/code-nodes/cn-734.json +14 -0
- package/rcf/code-nodes/cn-735.json +20 -0
- package/rcf/code-nodes/cn-736.json +14 -0
- package/rcf/code-nodes/cn-737.json +14 -0
- package/rcf/code-nodes/cn-738.json +14 -0
- package/rcf/code-nodes/cn-739.json +18 -0
- package/rcf/code-nodes/cn-740.json +19 -0
- package/rcf/code-nodes/cn-741.json +16 -0
- package/rcf/code-nodes/cn-742.json +16 -0
- package/rcf/code-nodes/cn-743.json +15 -0
- package/rcf/code-nodes/cn-744.json +22 -0
- package/rcf/code-nodes/cn-745.json +16 -0
- package/rcf/code-nodes/cn-746.json +14 -0
- package/rcf/code-nodes/cn-747.json +18 -0
- package/rcf/code-nodes/cn-748.json +16 -0
- package/rcf/code-nodes/cn-749.json +16 -0
- package/rcf/code-nodes/cn-750.json +15 -0
- package/rcf/code-nodes/cn-751.json +18 -0
- package/rcf/code-nodes/cn-752.json +14 -0
- package/rcf/fbs/fbs-190.json +37 -0
- package/rcf/fbs/fbs-191.json +34 -0
- package/rcf/fbs/fbs-192.json +17 -0
- package/rcf/fbs/fbs-193.json +37 -0
- package/rcf/fbs/fbs-194.json +35 -0
- package/rcf/fbs/fbs-195.json +37 -0
- package/rcf/fbs/fbs-196.json +45 -0
- package/rcf/fbs/fbs-197.json +41 -0
- package/rcf/fbs/fbs-198.json +42 -0
- package/rcf/fbs/fbs-199.json +59 -0
- package/rcf/fbs/fbs-200.json +46 -0
- package/rcf/fbs/fbs-201.json +49 -0
- package/rcf/fbs/fbs-202.json +110 -0
- package/rcf/fbs/fbs-203.json +100 -0
- package/rcf/requirements/req-001.json +2 -1
- package/rcf/requirements/req-002.json +3 -2
- package/rcf/requirements/req-003.json +2 -1
- package/rcf/requirements/req-004.json +2 -1
- package/rcf/requirements/req-005.json +2 -1
- package/rcf/requirements/req-006.json +2 -1
- package/rcf/requirements/req-007.json +2 -1
- package/rcf/requirements/req-008.json +2 -1
- package/rcf/requirements/req-009.json +2 -1
- package/rcf/requirements/req-010.json +5 -2
- package/rcf/requirements/req-011.json +2 -1
- package/rcf/requirements/req-012.json +2 -1
- package/rcf/requirements/req-013.json +2 -1
- package/rcf/requirements/req-014.json +2 -1
- package/rcf/requirements/req-015.json +2 -1
- package/rcf/requirements/req-016.json +5 -2
- package/rcf/requirements/req-017.json +6 -2
- package/rcf/requirements/req-018.json +5 -2
- package/rcf/requirements/req-019.json +5 -2
- package/rcf/requirements/req-020.json +5 -2
- package/rcf/requirements/req-021.json +5 -2
- package/rcf/requirements/req-022.json +5 -2
- package/rcf/requirements/req-023.json +6 -4
- package/rcf/requirements/req-024.json +5 -2
- package/rcf/requirements/req-025.json +5 -2
- package/rcf/requirements/req-026.json +5 -2
- package/rcf/requirements/req-027.json +5 -2
- package/rcf/requirements/req-028.json +5 -2
- package/rcf/requirements/req-029.json +5 -2
- package/rcf/requirements/req-030.json +5 -2
- package/rcf/requirements/req-040.json +5 -2
- package/rcf/requirements/req-041.json +5 -2
- package/rcf/requirements/req-042.json +5 -2
- package/rcf/requirements/req-043.json +5 -2
- package/rcf/requirements/req-050.json +5 -2
- package/rcf/requirements/req-051.json +5 -2
- package/rcf/requirements/req-052.json +5 -2
- package/rcf/requirements/req-053.json +5 -2
- package/rcf/requirements/req-054.json +5 -2
- package/rcf/requirements/req-060.json +5 -2
- package/rcf/requirements/req-061.json +5 -2
- package/rcf/requirements/req-062.json +5 -2
- package/rcf/requirements/req-063.json +5 -2
- package/rcf/requirements/req-070.json +5 -2
- package/rcf/requirements/req-071.json +5 -2
- package/rcf/requirements/req-072.json +5 -2
- package/rcf/requirements/req-073.json +5 -2
- package/rcf/requirements/req-074.json +5 -2
- package/rcf/requirements/req-075.json +5 -2
- package/rcf/requirements/req-076.json +5 -2
- package/rcf/requirements/req-077.json +5 -2
- package/rcf/requirements/req-080.json +5 -2
- package/rcf/requirements/req-081.json +5 -2
- package/rcf/requirements/req-082.json +5 -2
- package/rcf/requirements/req-083.json +5 -2
- package/rcf/requirements/req-084.json +5 -2
- package/rcf/requirements/req-085.json +5 -2
- package/rcf/requirements/req-086.json +5 -2
- package/rcf/requirements/req-090.json +5 -2
- package/rcf/requirements/req-091.json +5 -2
- package/rcf/requirements/req-092.json +5 -2
- package/rcf/requirements/req-093.json +5 -2
- package/rcf/requirements/req-094.json +5 -2
- package/rcf/requirements/req-100.json +5 -2
- package/rcf/requirements/req-101.json +5 -2
- package/rcf/requirements/req-102.json +5 -2
- package/rcf/requirements/req-103.json +5 -2
- package/rcf/requirements/req-104.json +5 -2
- package/rcf/requirements/req-110.json +5 -2
- package/rcf/requirements/req-111.json +5 -2
- package/rcf/requirements/req-112.json +5 -2
- package/rcf/requirements/req-113.json +5 -2
- package/rcf/requirements/req-114.json +5 -2
- package/rcf/requirements/req-115.json +5 -2
- package/rcf/requirements/req-120.json +5 -2
- package/rcf/requirements/req-121.json +5 -2
- package/rcf/requirements/req-122.json +5 -2
- package/rcf/requirements/req-123.json +5 -2
- package/rcf/requirements/req-124.json +5 -2
- package/rcf/requirements/req-125.json +5 -2
- package/rcf/requirements/req-130.json +6 -2
- package/rcf/requirements/req-131.json +6 -2
- package/rcf/requirements/req-132.json +6 -2
- package/rcf/requirements/req-133.json +6 -2
- package/rcf/requirements/req-134.json +6 -2
- package/rcf/requirements/req-140.json +5 -2
- package/rcf/requirements/req-145.json +5 -2
- package/rcf/requirements/req-150.json +6 -2
- package/rcf/requirements/req-151.json +6 -2
- package/rcf/requirements/req-152.json +6 -2
- package/rcf/requirements/req-153.json +6 -2
- package/rcf/requirements/req-154.json +5 -2
- package/rcf/requirements/req-155.json +4 -1
- package/rcf/requirements/req-156.json +4 -1
- package/rcf/requirements/req-157.json +4 -1
- package/rcf/requirements/req-158.json +4 -1
- package/rcf/requirements/req-159.json +4 -1
- package/rcf/requirements/req-160.json +4 -1
- package/rcf/requirements/req-161.json +4 -1
- package/rcf/requirements/req-162.json +4 -1
- package/rcf/requirements/req-163.json +5 -2
- package/rcf/requirements/req-164.json +5 -2
- package/rcf/requirements/req-165.json +5 -2
- package/rcf/requirements/req-170.json +33 -0
- package/rcf/requirements/req-171.json +44 -0
- package/rcf/requirements/req-172.json +25 -0
- package/rcf/requirements/req-173.json +25 -0
- package/rcf/requirements/req-174.json +31 -0
- package/rcf/requirements/req-175.json +31 -0
- package/rcf/requirements/req-176.json +25 -0
- package/rcf/requirements/req-180.json +31 -0
- package/rcf/requirements/req-181.json +31 -0
- package/rcf/requirements/req-182.json +25 -0
- package/rcf/requirements/req-186.json +25 -0
- package/rcf/requirements/req-187.json +31 -0
- package/rcf/requirements/req-188.json +25 -0
- package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
- package/rcf/tacs/tac-4121-define-ledgers.json +35 -0
- package/rcf/tacs/tac-4122-define-gates.json +61 -0
- package/rcf/tacs/tac-4123-define-readiness.json +61 -0
- package/rcf/tacs/tac-4124-define-freeze.json +28 -0
- package/rcf/tacs/tac-4127-readiness-view.json +49 -0
- package/rcf/tacs/tac-4128-define-guidance.json +53 -0
- package/rcf/tacs/tac-4129-define-questions.json +45 -0
- package/rcf/tacs/tac-4130-define-intake-brief.json +35 -0
- package/rcf/tacs/tac-4131-build-dag-view.json +39 -0
- package/rcf/tacs/tac-4132-id-lookup.json +42 -0
- package/rcf/tacs/tac-4133-readiness-po-layer.json +37 -0
- package/rcf/tacs/tac-4134-wespa-host-fixture.json +54 -0
- package/rcf/test-suites/ts-016.json +3 -3
- package/rcf/test-suites/ts-204.json +30 -0
- package/rcf/test-suites/ts-205.json +22 -0
- package/rcf/test-suites/ts-206.json +22 -0
- package/rcf/test-suites/ts-207.json +22 -0
- package/rcf/test-suites/ts-208.json +22 -0
- package/rcf/test-suites/ts-209.json +22 -0
- package/rcf/test-suites/ts-210.json +22 -0
- package/rcf/test-suites/ts-211.json +22 -0
- package/rcf/test-suites/ts-212.json +22 -0
- package/rcf/test-suites/ts-213.json +50 -0
- package/rcf/test-suites/ts-214.json +74 -0
- package/rcf/test-suites/ts-215.json +211 -0
- package/rcf/test-suites/ts-216.json +94 -0
- package/rcf/test-suites/ts-217.json +62 -0
- package/rcf/test-suites/ts-218.json +78 -0
- package/rcf/test-suites/ts-219.json +70 -0
- package/rcf/test-suites/ts-220.json +78 -0
- package/rcf/test-suites/ts-221.json +78 -0
- package/rcf/test-suites/ts-222.json +100 -0
- package/rcf/test-suites/ts-223.json +94 -0
- package/rcf/test-suites/ts-224.json +70 -0
- package/rcf/test-suites/ts-225.json +78 -0
- package/rcf/test-suites/ts-226.json +158 -0
- package/rcf/test-suites/ts-227.json +38 -0
- package/rcf/test-suites/ts-228.json +86 -0
- package/rcf/test-suites/ts-229.json +83 -0
- package/rcf/user-stories/us-15601.json +4 -4
- package/rcf/user-stories/us-17001.json +27 -0
- package/rcf/user-stories/us-17002.json +22 -0
- package/rcf/user-stories/us-17003.json +22 -0
- package/rcf/user-stories/us-17004.json +22 -0
- package/rcf/user-stories/us-17005.json +22 -0
- package/rcf/user-stories/us-17006.json +21 -0
- package/rcf/user-stories/us-17007.json +21 -0
- package/rcf/user-stories/us-17008.json +22 -0
- package/rcf/user-stories/us-17101.json +22 -0
- package/rcf/user-stories/us-17102.json +22 -0
- package/rcf/user-stories/us-17201.json +38 -0
- package/rcf/user-stories/us-17301.json +38 -0
- package/rcf/user-stories/us-17302.json +50 -0
- package/rcf/user-stories/us-17401.json +70 -0
- package/rcf/user-stories/us-17402.json +55 -0
- package/rcf/user-stories/us-17403.json +60 -0
- package/rcf/user-stories/us-17501.json +48 -0
- package/rcf/user-stories/us-17502.json +60 -0
- package/rcf/user-stories/us-17503.json +65 -0
- package/rcf/user-stories/us-17601.json +58 -0
- package/rcf/user-stories/us-18001.json +55 -0
- package/rcf/user-stories/us-18002.json +35 -0
- package/rcf/user-stories/us-18101.json +35 -0
- package/rcf/user-stories/us-18202.json +40 -0
- package/rcf/user-stories/us-18601.json +55 -0
- package/rcf/user-stories/us-18602.json +50 -0
- package/rcf/user-stories/us-18603.json +45 -0
- package/rcf/user-stories/us-18701.json +50 -0
- package/rcf/user-stories/us-18702.json +50 -0
- package/rcf/user-stories/us-18801.json +45 -0
- package/rcf/user-stories/us-204.json +59 -0
- package/rcf/user-stories/us-205.json +64 -0
- package/rcf/user-stories/us-206.json +54 -0
- package/releases/releases.yaml +31 -1
- package/src/cli/feedback.js +126 -10
- package/src/cli/freeze.js +730 -0
- package/src/cli/help.js +23 -0
- package/src/cli/ledger.js +615 -0
- package/src/cli/questions.js +288 -0
- package/src/cli/readiness.js +498 -0
- package/src/cli/view.js +29 -0
- package/src/core/store/writer.js +86 -6
- package/src/define/freeze-record.js +247 -0
- package/src/define/ledgers.js +712 -0
- package/src/feedback/redact.js +105 -19
- package/src/intake/index.js +1 -1
- package/src/intake/orchestrator.js +117 -0
- package/src/mcp/tools.js +354 -0
- package/src/query/delta.js +282 -0
- package/src/query/gates.js +1107 -0
- package/src/query/index.js +50 -0
- package/src/query/questions.js +438 -0
- package/src/query/readiness.js +574 -0
- package/src/server/index.js +60 -1
- package/src/server/routes.js +133 -4
- package/src/setup/identity-seed.js +17 -0
- package/src/view/build-dag.js +563 -0
- package/src/view/components/badge.js +41 -0
- package/src/view/components/diff.js +72 -0
- package/src/view/components/doc-row.js +62 -0
- package/src/view/components/empty-state.js +35 -0
- package/src/view/components/entity-selector.js +137 -0
- package/src/view/components/filter-bar.js +97 -0
- package/src/view/components/findings-list.js +49 -0
- package/src/view/components/index.js +12 -0
- package/src/view/components/pill.js +46 -0
- package/src/view/components/sub-tab-strip.js +43 -0
- package/src/view/components/toast.js +25 -0
- package/src/view/components-fixture.js +265 -0
- package/src/view/doc-renderers/architecture.js +304 -0
- package/src/view/doc-renderers/build.js +160 -0
- package/src/view/doc-renderers/helpers.js +2 -2
- package/src/view/doc-renderers/index.js +2 -0
- package/src/view/doc-renderers/prd.js +35 -6
- package/src/view/doc-renderers/req.js +10 -2
- package/src/view/doc-renderers/user-story.js +10 -5
- package/src/view/html-page.js +717 -300
- package/src/view/index.js +109 -3
- package/src/view/live-client.js +61 -8
- package/src/view/needs-work.js +79 -0
- package/src/view/page-init.js +2193 -0
- package/src/view/product-map.js +751 -0
- package/src/view/readiness/phrasebook.js +115 -0
- package/src/view/readiness/po-layer.js +288 -0
- package/src/view/readiness/question-adapter.js +329 -0
- package/src/view/readiness/stage-legend.js +71 -0
- package/src/view/readiness.js +505 -0
- package/src/view/style.css +2279 -266
- package/src/view/test-host/embed-client.js +121 -0
- package/src/view/test-host/fixture.js +183 -0
- package/src/view/test-host/lints.js +220 -0
- package/src/view/view-index.js +323 -0
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
// DEFINE stage gates D1..D8 (REQ-174; proposal 2026-09-22 §2.4, §3.2,
|
|
2
|
+
// §5.2 v3; Baz 2026-09-24 renumbering: warn-with-ack D3/D5/D6 ships in
|
|
3
|
+
// 0.29.0, the fuller template parser / catalogue / probe runner in
|
|
4
|
+
// 0.30).
|
|
5
|
+
//
|
|
6
|
+
// One exported check function per stage. Each is pure: no filesystem
|
|
7
|
+
// I/O, no state, no time. The caller (computeReadiness) assembles the
|
|
8
|
+
// context: the walker tree, the four ledgers, the freeze record, the
|
|
9
|
+
// delta, the scope (changed union added union fan-out), validate
|
|
10
|
+
// errors, probe-ledger open count, tree-wide coverage rollup, and the
|
|
11
|
+
// operator profile string.
|
|
12
|
+
//
|
|
13
|
+
// Every function returns:
|
|
14
|
+
//
|
|
15
|
+
// {
|
|
16
|
+
// stage: 'D1'..'D8',
|
|
17
|
+
// gate: 'define.brief' | 'define.skeleton' | ...,
|
|
18
|
+
// state: 'passed' | 'failing' | 'acknowledged' | 'notApplicable',
|
|
19
|
+
// checks: [
|
|
20
|
+
// {
|
|
21
|
+
// name,
|
|
22
|
+
// ok, // boolean
|
|
23
|
+
// over, // 'delta' | 'tree'
|
|
24
|
+
// pass, total, // integers
|
|
25
|
+
// failing: [ { id, why } ]
|
|
26
|
+
// }, ...
|
|
27
|
+
// ]
|
|
28
|
+
// }
|
|
29
|
+
//
|
|
30
|
+
// State fold (ADR-4122):
|
|
31
|
+
// - Blocking stages (D1, D2, D4, D7, D8): 'passed' when every
|
|
32
|
+
// check.ok is true; 'failing' otherwise. 'notApplicable' is
|
|
33
|
+
// decided by the stage before checks run.
|
|
34
|
+
// - Warn-with-ack stages (D3, D5, D6): 'passed' when every check.ok
|
|
35
|
+
// is true; 'acknowledged' when any check is failing AND
|
|
36
|
+
// freeze.gates[<gate>].state === 'acknowledged' AND
|
|
37
|
+
// freeze.gates[<gate>].at.hash === currentTreeHash; 'failing'
|
|
38
|
+
// otherwise.
|
|
39
|
+
//
|
|
40
|
+
// Warn-with-ack stages in 0.29.0 run only the cheap data-backed
|
|
41
|
+
// checks proposal §3.2 lists; the template parser, entity join,
|
|
42
|
+
// catalogue walk, contradiction / unsatisfiable scans and probe
|
|
43
|
+
// runner are 0.30 and left as named seams (`SEAM 0.30:` comments
|
|
44
|
+
// mark them).
|
|
45
|
+
|
|
46
|
+
import { computeQueue } from '../build/queue.js';
|
|
47
|
+
import { isOptedOut } from '../req-baseline/opt-out.js';
|
|
48
|
+
import { BRIEF_KINDS, parseResolvedBy } from '../define/ledgers.js';
|
|
49
|
+
|
|
50
|
+
/** Canonical stage order (proposal §3.2). */
|
|
51
|
+
export const STAGE_ORDER = /** @type {const} */ (['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8']);
|
|
52
|
+
|
|
53
|
+
/** Stage -> gate id map (used by the freeze verb and the CLI --check flag). */
|
|
54
|
+
export const STAGE_GATES = /** @type {const} */ ({
|
|
55
|
+
D1: 'define.brief',
|
|
56
|
+
D2: 'define.skeleton',
|
|
57
|
+
D3: 'define.shapes',
|
|
58
|
+
D4: 'define.stories',
|
|
59
|
+
D5: 'define.crosscut',
|
|
60
|
+
D6: 'define.consistency',
|
|
61
|
+
D7: 'define.decisions',
|
|
62
|
+
D8: 'define.freeze',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Stage -> operator-friendly short name (--check <short> equivalent to --check <D>). */
|
|
66
|
+
export const STAGE_SHORT_NAMES = /** @type {const} */ ({
|
|
67
|
+
D1: 'brief',
|
|
68
|
+
D2: 'skeleton',
|
|
69
|
+
D3: 'shapes',
|
|
70
|
+
D4: 'stories',
|
|
71
|
+
D5: 'crosscut',
|
|
72
|
+
D6: 'consistency',
|
|
73
|
+
D7: 'decisions',
|
|
74
|
+
D8: 'freeze',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Reverse: short name / D-name -> D-stage. Consumers accept either. */
|
|
78
|
+
export const STAGE_ALIASES = /** @type {const} */ (() => {
|
|
79
|
+
/** @type {Record<string, string>} */
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const stage of STAGE_ORDER) {
|
|
82
|
+
out[stage] = stage;
|
|
83
|
+
out[STAGE_SHORT_NAMES[stage]] = stage;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
})();
|
|
87
|
+
|
|
88
|
+
/** Closed vocabulary for TAC.interfaces[].kind (proposal §5.2 v1). */
|
|
89
|
+
export const INTERFACE_KINDS = /** @type {const} */ ([
|
|
90
|
+
'recordShape', 'httpRoute', 'event', 'cliCommand', 'uiRoute',
|
|
91
|
+
'port', 'fileFormat', 'fixture', 'other',
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Owning persona for every stage-gate check in 0.29.0 (ADR-4126).
|
|
96
|
+
*
|
|
97
|
+
* Two values: `productOwner` (the person who owns what the product
|
|
98
|
+
* must do) and `engineer` (the person who owns how it is built).
|
|
99
|
+
* There is no `either` / `shared` / `both`; a check that would need
|
|
100
|
+
* both is split, which is what happens to `skeleton:reqFields`
|
|
101
|
+
* (becomes `skeleton:reqIntent` + `skeleton:reqShape`) and
|
|
102
|
+
* `decisions:allAnswered` (becomes `decisions:wellFormed` +
|
|
103
|
+
* `decisions:allAnswered`). A failing item inherits its check's
|
|
104
|
+
* persona; there is no per-item override.
|
|
105
|
+
*
|
|
106
|
+
* The `notApplicable` placeholder (`stage:<D>:scope`) carries the
|
|
107
|
+
* per-stage fallback persona from `STAGE_FALLBACK_PERSONA` below.
|
|
108
|
+
* It is always ok so it never affects a level verdict.
|
|
109
|
+
*/
|
|
110
|
+
export const CHECK_PERSONA = /** @type {const} */ ({
|
|
111
|
+
// D1 Brief: every check is PO (the brief is the PO's statement of intent).
|
|
112
|
+
'brief:sinceFreeze': 'productOwner',
|
|
113
|
+
'brief:kinds': 'productOwner',
|
|
114
|
+
'brief:openQuestions': 'productOwner',
|
|
115
|
+
'brief:profile': 'productOwner',
|
|
116
|
+
// D2 Skeleton: resolvedBy + reqIntent are PO; reqShape, tadPersistence, deployAdr are engineer.
|
|
117
|
+
'skeleton:resolvedBy': 'productOwner',
|
|
118
|
+
'skeleton:reqIntent': 'productOwner',
|
|
119
|
+
'skeleton:reqShape': 'engineer',
|
|
120
|
+
'skeleton:tadPersistence': 'engineer',
|
|
121
|
+
'skeleton:deployAdr': 'engineer',
|
|
122
|
+
// D3 Shapes: every check is engineer (shapes are the engineer's first act after hand-over).
|
|
123
|
+
'shapes:tacHasInterface': 'engineer',
|
|
124
|
+
'shapes:kindVocabulary': 'engineer',
|
|
125
|
+
'shapes:draftSettled': 'engineer',
|
|
126
|
+
// D4 Stories: reqHasUs is PO ("what does someone do with this?"); the floors are engineer.
|
|
127
|
+
'stories:reqHasUs': 'productOwner',
|
|
128
|
+
'stories:usFloors': 'engineer',
|
|
129
|
+
// D5 Crosscut: TAD + concern-ledger content is engineering.
|
|
130
|
+
'crosscut:securityArchitecture': 'engineer',
|
|
131
|
+
'crosscut:operationalConcerns': 'engineer',
|
|
132
|
+
'crosscut:concernsResolved': 'engineer',
|
|
133
|
+
// D6 Consistency: schema validity and probes are engineer.
|
|
134
|
+
'consistency:validateClean': 'engineer',
|
|
135
|
+
'consistency:probeCount': 'engineer',
|
|
136
|
+
// D7 Decisions: enumeration is PO (the PO answers or lets the default stand);
|
|
137
|
+
// zero-open is the engineer's hand-over condition.
|
|
138
|
+
'decisions:wellFormed': 'productOwner',
|
|
139
|
+
'decisions:allAnswered': 'engineer',
|
|
140
|
+
// D8 Freeze: every check is mechanical / engineering.
|
|
141
|
+
'freeze:priorGates': 'engineer',
|
|
142
|
+
'freeze:acFbsOwnership': 'engineer',
|
|
143
|
+
'freeze:queueHead': 'engineer',
|
|
144
|
+
'freeze:validateClean': 'engineer',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Per-check heading question in the owning persona's register
|
|
149
|
+
* (ADR-4126, proposal section 1.1). The text report and the
|
|
150
|
+
* Readiness tab print the question as the heading of each check's
|
|
151
|
+
* findings list. Static per check in 0.29.0; per-item plain-English
|
|
152
|
+
* wording is step 3's guided elicitation.
|
|
153
|
+
*/
|
|
154
|
+
export const CHECK_QUESTION = /** @type {const} */ ({
|
|
155
|
+
'brief:sinceFreeze': 'What has changed, or what is this project for?',
|
|
156
|
+
'brief:kinds': 'Is this a capability, a constraint, an actor, or something else?',
|
|
157
|
+
'brief:openQuestions': 'Any open questions in the brief, resolved or promoted to a decision?',
|
|
158
|
+
'brief:profile': 'How do you want to review, and who are you writing for?',
|
|
159
|
+
'skeleton:resolvedBy': 'Does this statement become a requirement, an entity, or an omission?',
|
|
160
|
+
'skeleton:reqIntent': 'What does this requirement mean and where does it sit?',
|
|
161
|
+
'skeleton:reqShape': 'Every requirement in scope carries a shape classification.',
|
|
162
|
+
'skeleton:tadPersistence': 'TAD data architecture lists dataStores and coreEntities for persistence REQs.',
|
|
163
|
+
'skeleton:deployAdr': 'Exactly one Deploy target or Deploy deferral ADR exists.',
|
|
164
|
+
'shapes:tacHasInterface': 'Every TAC in scope has at least one interface.',
|
|
165
|
+
'shapes:kindVocabulary': 'Every interface kind is in the closed vocabulary.',
|
|
166
|
+
'shapes:draftSettled': 'Every draft interface has been settled (the [draft] marker is removed).',
|
|
167
|
+
'stories:reqHasUs': 'What does someone do with this requirement?',
|
|
168
|
+
'stories:usFloors': 'Every story in scope meets the class, tacIds and testability floors.',
|
|
169
|
+
'crosscut:securityArchitecture': 'TAD.securityArchitecture is present when an auth or httpApi REQ exists.',
|
|
170
|
+
'crosscut:operationalConcerns': 'TAD.operationalConcerns is present when a deployed-scope AC exists.',
|
|
171
|
+
'crosscut:concernsResolved': 'Every concern-ledger entry on a REQ in scope is applied or waived.',
|
|
172
|
+
'consistency:validateClean': 'Validate is clean tree-wide.',
|
|
173
|
+
'consistency:probeCount': 'Zero open probe-ledger entries.',
|
|
174
|
+
'decisions:wellFormed': 'Every decision is enumerated (question, two or more options, a default).',
|
|
175
|
+
'decisions:allAnswered': 'Zero decisions are open.',
|
|
176
|
+
'freeze:priorGates': 'Every prior stage passed or is acknowledged at the current hash.',
|
|
177
|
+
'freeze:acFbsOwnership': 'Every AC is owned by exactly one FBS.',
|
|
178
|
+
'freeze:queueHead': 'Queue head is actionable and not a placeholder.',
|
|
179
|
+
'freeze:validateClean': 'Validate is clean tree-wide.',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Per-stage fallback persona for the `stage:<D>:scope` placeholder
|
|
184
|
+
* check that `notApplicable` envelopes carry. The placeholder is
|
|
185
|
+
* always ok so this never affects a level, but keeps "every check
|
|
186
|
+
* has a persona" true for consumers and the table test (ADR-4126
|
|
187
|
+
* decision 10).
|
|
188
|
+
*/
|
|
189
|
+
export const STAGE_FALLBACK_PERSONA = /** @type {const} */ ({
|
|
190
|
+
D1: 'productOwner',
|
|
191
|
+
D2: 'productOwner',
|
|
192
|
+
D3: 'engineer',
|
|
193
|
+
D4: 'productOwner',
|
|
194
|
+
D5: 'engineer',
|
|
195
|
+
D6: 'engineer',
|
|
196
|
+
D7: 'productOwner',
|
|
197
|
+
D8: 'engineer',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Look up the question heading for a check name.
|
|
202
|
+
* @param {string} name
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
export function checkQuestion(name) {
|
|
206
|
+
return /** @type {any} */ (CHECK_QUESTION)[name] ?? '';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Look up the owning persona for a check name.
|
|
211
|
+
* @param {string} name
|
|
212
|
+
* @returns {'productOwner' | 'engineer' | null}
|
|
213
|
+
*/
|
|
214
|
+
export function checkPersona(name) {
|
|
215
|
+
const v = /** @type {any} */ (CHECK_PERSONA)[name];
|
|
216
|
+
return v === 'productOwner' || v === 'engineer' ? v : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Draft marker (ADR-4126 for `TAC.interfaces[].description` in 0.29.0;
|
|
221
|
+
* REQ-188 extends it to `TAC.purpose` and `coreEntities[].description`
|
|
222
|
+
* in 0.30.0). A description that starts with `[draft]` after optional
|
|
223
|
+
* leading whitespace is pre-populated engineer entry material; the
|
|
224
|
+
* engineer removes the marker when the shape is settled. Decision 6
|
|
225
|
+
* uses the same bracket-prefix convention for AC class.
|
|
226
|
+
*/
|
|
227
|
+
const INTERFACE_DRAFT_RE = /^\[draft\]/;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* True when a string carries the leading `[draft]` marker
|
|
231
|
+
* (whitespace tolerated). Pure string test used by every draft-home
|
|
232
|
+
* reader below.
|
|
233
|
+
*
|
|
234
|
+
* @param {unknown} value
|
|
235
|
+
* @returns {boolean}
|
|
236
|
+
*/
|
|
237
|
+
export function hasDraftMarker(value) {
|
|
238
|
+
if (typeof value !== 'string') return false;
|
|
239
|
+
return INTERFACE_DRAFT_RE.test(value.trimStart());
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* True when an interface's `description` carries the leading
|
|
244
|
+
* `[draft]` marker (whitespace tolerated). Thin wrapper over
|
|
245
|
+
* `hasDraftMarker` kept for ADR-4126 call sites; REQ-188 adds
|
|
246
|
+
* `parseTacPurposeDraft` and `parseCoreEntityDraft` for the two new
|
|
247
|
+
* homes.
|
|
248
|
+
*
|
|
249
|
+
* @param {unknown} iface
|
|
250
|
+
* @returns {boolean}
|
|
251
|
+
*/
|
|
252
|
+
export function parseInterfaceDraft(iface) {
|
|
253
|
+
if (!iface || typeof iface !== 'object') return false;
|
|
254
|
+
return hasDraftMarker(/** @type {any} */ (iface).description);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* True when a TAC's `purpose` carries the leading `[draft]` marker
|
|
259
|
+
* (whitespace tolerated). REQ-188 draft home; the engineer removes
|
|
260
|
+
* the marker when the TAC's purpose is owned.
|
|
261
|
+
*
|
|
262
|
+
* @param {unknown} tac
|
|
263
|
+
* @returns {boolean}
|
|
264
|
+
*/
|
|
265
|
+
export function parseTacPurposeDraft(tac) {
|
|
266
|
+
if (!tac || typeof tac !== 'object') return false;
|
|
267
|
+
return hasDraftMarker(/** @type {any} */ (tac).purpose);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* True when a coreEntities entry's `description` carries the
|
|
272
|
+
* leading `[draft]` marker (whitespace tolerated). REQ-188 draft
|
|
273
|
+
* home; the engineer removes the marker when the entity is owned.
|
|
274
|
+
*
|
|
275
|
+
* @param {unknown} entity
|
|
276
|
+
* @returns {boolean}
|
|
277
|
+
*/
|
|
278
|
+
export function parseCoreEntityDraft(entity) {
|
|
279
|
+
if (!entity || typeof entity !== 'object') return false;
|
|
280
|
+
return hasDraftMarker(/** @type {any} */ (entity).description);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Warn-with-ack stages in 0.29.0 (ADR-4122). */
|
|
284
|
+
const WARN_WITH_ACK = new Set(['D3', 'D5', 'D6']);
|
|
285
|
+
|
|
286
|
+
/** Blocking stages in 0.29.0 (ADR-4122). */
|
|
287
|
+
const BLOCKING = new Set(['D1', 'D2', 'D4', 'D7', 'D8']);
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Signals a stage exports so consumers can render policy in the pill
|
|
291
|
+
* vocabulary without touching internals. Kept as a helper so the CLI
|
|
292
|
+
* text output and the viewer share one source.
|
|
293
|
+
* @param {string} stage
|
|
294
|
+
* @returns {'blocking' | 'warnWithAck'}
|
|
295
|
+
*/
|
|
296
|
+
export function stagePolicy(stage) {
|
|
297
|
+
return WARN_WITH_ACK.has(stage) ? 'warnWithAck' : 'blocking';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Scaffold TODO placeholder marker: uppercase "TODO" followed by a
|
|
302
|
+
* colon, matching every scaffold string `rcf init` and `rcf define
|
|
303
|
+
* create` write (see src/core/store/init.js and src/core/store/writer.js:
|
|
304
|
+
* every scaffold field is 'TODO: <text>'). Word-bounded and
|
|
305
|
+
* case-sensitive so incidental prose like "the scaffold TODO
|
|
306
|
+
* placeholder" or "no TODO markers" does not trip the gate. The
|
|
307
|
+
* validate CLI (src/cli/validate.js) keeps its own broader
|
|
308
|
+
* `\btodo\b/i` matcher for the informational tree-wide notice, so
|
|
309
|
+
* incidental prose still surfaces there without failing D2 / D4.
|
|
310
|
+
*/
|
|
311
|
+
const TODO_RE = /\bTODO:/;
|
|
312
|
+
|
|
313
|
+
/** Bracketed AC-class prefix (Baz decision 6, proposal §3.2 D4). */
|
|
314
|
+
const AC_CLASS_RE = /^\[(happy|edge|failure|must-not|non-functional)\]/;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Parse the leading bracketed class marker on `ac.description`.
|
|
318
|
+
* Returns the class token, or null when no marker is present.
|
|
319
|
+
*
|
|
320
|
+
* @param {unknown} ac
|
|
321
|
+
* @returns {string | null}
|
|
322
|
+
*/
|
|
323
|
+
export function parseAcClass(ac) {
|
|
324
|
+
const desc = ac && typeof ac === 'object' ? /** @type {any} */ (ac).description : null;
|
|
325
|
+
if (typeof desc !== 'string') return null;
|
|
326
|
+
const m = desc.trim().match(AC_CLASS_RE);
|
|
327
|
+
return m ? m[1] : null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Build a helper `{ name, ok, over, pass, total, failing }` in one
|
|
332
|
+
* call. `failing` is normalised to the { id, why } shape and omitted
|
|
333
|
+
* when ok is true (the surface is stable either way; the array is
|
|
334
|
+
* always present for consumers that fold on length).
|
|
335
|
+
*
|
|
336
|
+
* @param {string} name
|
|
337
|
+
* @param {'delta' | 'tree'} over
|
|
338
|
+
* @param {number} total
|
|
339
|
+
* @param {Array<{ id: string, why: string }>} failing
|
|
340
|
+
* @param {number} [totalFailingCount] Untruncated failing count. Defaults to
|
|
341
|
+
* `failing.length`. Callers that truncate the failing array for display
|
|
342
|
+
* (e.g. `.slice(0, 20)` on a large validate-error set) pass the full
|
|
343
|
+
* count here so `pass = total - untruncatedFailing`; without this the
|
|
344
|
+
* fold `pass = total - failing.length` over-counts passes beyond the
|
|
345
|
+
* twentieth failure. `ok` is likewise derived from the untruncated
|
|
346
|
+
* count. The section 2.4 shape stays intact -- `failing` remains the
|
|
347
|
+
* display array; no new field is exported.
|
|
348
|
+
* @returns {{ name: string, ok: boolean, over: 'delta'|'tree', pass: number, total: number, failing: Array<{ id: string, why: string }>, persona: 'productOwner'|'engineer', question: string }}
|
|
349
|
+
*/
|
|
350
|
+
function makeCheck(name, over, total, failing, totalFailingCount) {
|
|
351
|
+
const nFailing = typeof totalFailingCount === 'number' ? totalFailingCount : failing.length;
|
|
352
|
+
const pass = Math.max(0, total - nFailing);
|
|
353
|
+
// Resolve persona + question for the check. For stage placeholders
|
|
354
|
+
// (`stage:<D>:scope`) the persona comes from STAGE_FALLBACK_PERSONA.
|
|
355
|
+
// Unknown names fall back to engineer with an empty question so
|
|
356
|
+
// consumers never see null; the table test catches any new check
|
|
357
|
+
// that forgets to register in CHECK_PERSONA / CHECK_QUESTION.
|
|
358
|
+
let persona = /** @type {any} */ (CHECK_PERSONA)[name];
|
|
359
|
+
if (!persona) {
|
|
360
|
+
const stageMatch = /^stage:(D[1-8]):scope$/.exec(name);
|
|
361
|
+
persona = stageMatch
|
|
362
|
+
? /** @type {any} */ (STAGE_FALLBACK_PERSONA)[stageMatch[1]]
|
|
363
|
+
: 'engineer';
|
|
364
|
+
}
|
|
365
|
+
const question = /** @type {any} */ (CHECK_QUESTION)[name]
|
|
366
|
+
?? (name.startsWith('stage:') ? 'No documents of this stage’s type are in scope.' : '');
|
|
367
|
+
return { name, ok: nFailing === 0, over, pass, total, failing: [...failing], persona, question };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Fold a checks[] into a stage envelope with the ADR-4122 state rules
|
|
372
|
+
* applied. Blocking stages ignore acknowledgement; warn-with-ack
|
|
373
|
+
* stages honour a freeze.gates[<gate>] entry at the current tree hash.
|
|
374
|
+
*
|
|
375
|
+
* @param {string} stage
|
|
376
|
+
* @param {string} gate
|
|
377
|
+
* @param {Array<ReturnType<typeof makeCheck>>} checks
|
|
378
|
+
* @param {object} [freezeCtx]
|
|
379
|
+
* @param {string | null} [freezeCtx.currentTreeHash]
|
|
380
|
+
* @param {Record<string, { state?: string, at?: { hash?: string } }> | undefined} [freezeCtx.freezeGates]
|
|
381
|
+
* @returns {{ stage: string, gate: string, state: 'passed'|'failing'|'acknowledged', checks: Array<ReturnType<typeof makeCheck>> }}
|
|
382
|
+
*/
|
|
383
|
+
export function foldState(stage, gate, checks, freezeCtx = {}) {
|
|
384
|
+
const anyFailing = checks.some((c) => !c.ok);
|
|
385
|
+
if (!anyFailing) return { stage, gate, state: 'passed', checks };
|
|
386
|
+
if (WARN_WITH_ACK.has(stage)) {
|
|
387
|
+
const gates = freezeCtx.freezeGates ?? {};
|
|
388
|
+
const record = gates[gate];
|
|
389
|
+
const ackedAtHash = record?.state === 'acknowledged'
|
|
390
|
+
&& typeof record?.at?.hash === 'string'
|
|
391
|
+
&& record.at.hash === freezeCtx.currentTreeHash;
|
|
392
|
+
if (ackedAtHash) return { stage, gate, state: 'acknowledged', checks };
|
|
393
|
+
}
|
|
394
|
+
return { stage, gate, state: 'failing', checks };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* `notApplicable` envelope helper.
|
|
399
|
+
* @param {string} stage
|
|
400
|
+
* @param {string} gate
|
|
401
|
+
* @param {string} reason
|
|
402
|
+
*/
|
|
403
|
+
function notApplicable(stage, gate, reason) {
|
|
404
|
+
return {
|
|
405
|
+
stage, gate, state: 'notApplicable', checks: [
|
|
406
|
+
makeCheck(`stage:${stage}:scope`, 'delta', 0, []),
|
|
407
|
+
], reason,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The set of ids in scope, as a Set for O(1) hit checks.
|
|
413
|
+
* @param {Iterable<string> | undefined} scope
|
|
414
|
+
* @returns {Set<string>}
|
|
415
|
+
*/
|
|
416
|
+
function asSet(scope) {
|
|
417
|
+
return scope instanceof Set ? scope : new Set(scope ?? []);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// D1 -- Brief intake and ledger (blocking in 0.29.0).
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* @typedef {object} StageContext
|
|
426
|
+
* @property {import('#core/store/walker.js').TreeModel} tree
|
|
427
|
+
* @property {import('./delta.js').LedgerBundle | undefined} ledgers
|
|
428
|
+
* @property {import('./delta.js').DeltaResult | undefined} delta
|
|
429
|
+
* @property {import('./delta.js').FreezeRecord | null | undefined} freeze
|
|
430
|
+
* @property {Iterable<string> | undefined} scope
|
|
431
|
+
* @property {Array<unknown> | undefined} validateErrors tree-wide walker + validate errors
|
|
432
|
+
* @property {number | undefined} probeOpenCount for D6 (probe ledger open entries)
|
|
433
|
+
* @property {import('./coverage.js').CoverageResult | undefined} coverageTree optional, unused by gates in 0.29.0
|
|
434
|
+
* @property {string | null | undefined} profileText rcf/.identity/profile.md contents (null when absent)
|
|
435
|
+
* @property {{ skipReviewFor?: string } | undefined} profile parsed profile switches (light shape)
|
|
436
|
+
* @property {string | null | undefined} currentTreeHash for warn-with-ack acknowledgement match
|
|
437
|
+
* @property {Array<{ state: string }> | undefined} priorStages D8 reads earlier stage states from this
|
|
438
|
+
*/
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* D1 -- Brief intake and ledger.
|
|
442
|
+
*
|
|
443
|
+
* @param {StageContext} ctx
|
|
444
|
+
*/
|
|
445
|
+
export function checkD1Brief(ctx) {
|
|
446
|
+
const gate = STAGE_GATES.D1;
|
|
447
|
+
const checks = [];
|
|
448
|
+
const brief = /** @type {any} */ (ctx.ledgers?.brief);
|
|
449
|
+
const statements = Array.isArray(brief?.statements) ? brief.statements : [];
|
|
450
|
+
const decisions = /** @type {any} */ (ctx.ledgers?.decisions);
|
|
451
|
+
const decisionEntries = Array.isArray(decisions?.decisions) ? decisions.decisions : [];
|
|
452
|
+
|
|
453
|
+
// Check 1: at least one statement since the freeze (proposal §3.2 D1
|
|
454
|
+
// "at least one statement since the freeze"). briefSince is the
|
|
455
|
+
// delta's own high-water-mark projection; on an unfrozen tree the
|
|
456
|
+
// rule is "the brief itself" so every statement counts.
|
|
457
|
+
const highWater = Number.isFinite(ctx.freeze?.briefStatements) ? Number(ctx.freeze?.briefStatements) : 0;
|
|
458
|
+
const since = statements.filter((s) => Number(s?.id) > highWater);
|
|
459
|
+
checks.push(makeCheck(
|
|
460
|
+
'brief:sinceFreeze',
|
|
461
|
+
'delta',
|
|
462
|
+
Math.max(since.length, 1),
|
|
463
|
+
since.length === 0 && (ctx.freeze ? true : statements.length === 0)
|
|
464
|
+
? [{ id: 'brief-ledger', why: ctx.freeze
|
|
465
|
+
? 'no new brief statement since the freeze'
|
|
466
|
+
: 'brief ledger holds no statements' }]
|
|
467
|
+
: [],
|
|
468
|
+
));
|
|
469
|
+
|
|
470
|
+
// Check 2: every statement kinded from the closed set.
|
|
471
|
+
const badKind = [];
|
|
472
|
+
for (const s of statements) {
|
|
473
|
+
if (!s || typeof s.kind !== 'string' || !BRIEF_KINDS.includes(/** @type {any} */ (s.kind))) {
|
|
474
|
+
badKind.push({ id: `brief:${s?.id ?? '?'}`, why: `unknown kind ${s?.kind ?? '(missing)'}` });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
checks.push(makeCheck('brief:kinds', 'tree', statements.length, badKind));
|
|
478
|
+
|
|
479
|
+
// Check 3: every openQuestion resolved or promoted to a decision.
|
|
480
|
+
const openQuestions = statements.filter((s) => s?.kind === 'openQuestion' && s?.status !== 'resolved');
|
|
481
|
+
// A promoted openQuestion is one whose text is quoted or referenced in
|
|
482
|
+
// a decisions-ledger entry's question field; a cheap presence check.
|
|
483
|
+
const promoted = new Set();
|
|
484
|
+
for (const q of openQuestions) {
|
|
485
|
+
const text = typeof q?.text === 'string' ? q.text.trim() : '';
|
|
486
|
+
if (!text) continue;
|
|
487
|
+
if (decisionEntries.some((d) => typeof d?.question === 'string' && d.question.includes(text))) {
|
|
488
|
+
promoted.add(q.id);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const unresolvedOpen = openQuestions.filter((q) => !promoted.has(q.id));
|
|
492
|
+
checks.push(makeCheck(
|
|
493
|
+
'brief:openQuestions',
|
|
494
|
+
'tree',
|
|
495
|
+
openQuestions.length,
|
|
496
|
+
unresolvedOpen.map((q) => ({ id: `brief:${q.id}`, why: 'openQuestion still open (not resolved and not promoted to a decision)' })),
|
|
497
|
+
));
|
|
498
|
+
|
|
499
|
+
// Check 4: profile.md carries surface + register markers (light
|
|
500
|
+
// presence check; the fuller elicitation prompt lives in slice 6).
|
|
501
|
+
const surfaceMarkers = ['viewer', 'runningApp', 'prDiff'];
|
|
502
|
+
const registerMarkers = ['productOwner', 'engineer', 'unstated'];
|
|
503
|
+
const profileText = ctx.profileText ?? '';
|
|
504
|
+
const surfaceHit = surfaceMarkers.some((m) => profileText.includes(m));
|
|
505
|
+
const registerHit = registerMarkers.some((m) => profileText.includes(m));
|
|
506
|
+
const profileFail = [];
|
|
507
|
+
if (!surfaceHit) profileFail.push({ id: 'profile:surface', why: 'profile.md is missing a review-surface marker (viewer | runningApp | prDiff)' });
|
|
508
|
+
if (!registerHit) profileFail.push({ id: 'profile:register', why: 'profile.md is missing a register marker (productOwner | engineer | unstated)' });
|
|
509
|
+
checks.push(makeCheck('brief:profile', 'tree', 2, profileFail));
|
|
510
|
+
|
|
511
|
+
return foldState('D1', gate, checks, {
|
|
512
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
513
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// D2 -- Requirements and architecture skeleton (blocking in 0.29.0).
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
/** Kinds whose resolution D2 checks: capability / constraint / entity / actor / externalSystem / surface. */
|
|
522
|
+
const D2_RESOLVING_KINDS = new Set(['capability', 'constraint', 'entity', 'actor', 'externalSystem', 'surface']);
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* D2 -- Requirements and architecture skeleton.
|
|
526
|
+
*
|
|
527
|
+
* @param {StageContext} ctx
|
|
528
|
+
*/
|
|
529
|
+
export function checkD2Skeleton(ctx) {
|
|
530
|
+
const gate = STAGE_GATES.D2;
|
|
531
|
+
const scope = asSet(ctx.scope);
|
|
532
|
+
const tree = ctx.tree;
|
|
533
|
+
const brief = /** @type {any} */ (ctx.ledgers?.brief);
|
|
534
|
+
const statements = Array.isArray(brief?.statements) ? brief.statements : [];
|
|
535
|
+
const highWater = Number.isFinite(ctx.freeze?.briefStatements) ? Number(ctx.freeze?.briefStatements) : 0;
|
|
536
|
+
const stmtsInScope = statements.filter((s) => Number(s?.id) > highWater);
|
|
537
|
+
const resolvingStmts = stmtsInScope.filter((s) => D2_RESOLVING_KINDS.has(String(s?.kind)));
|
|
538
|
+
const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
|
|
539
|
+
const prdInScope = tree.prd && scope.has(tree.prd.prdId ?? '');
|
|
540
|
+
const tadInScope = tree.tad && scope.has(tree.tad.tadId ?? '');
|
|
541
|
+
|
|
542
|
+
if (resolvingStmts.length === 0 && reqInScope.length === 0 && !prdInScope && !tadInScope) {
|
|
543
|
+
return notApplicable('D2', gate, 'no resolving brief statements and no REQ / PRD / TAD in scope');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const checks = [];
|
|
547
|
+
|
|
548
|
+
// Check 1 (0.30.0): every capability/constraint/entity/... statement
|
|
549
|
+
// resolves to a closed-grammar pointer in `resolvedBy`:
|
|
550
|
+
// REQ-nnn | TAD.entity:<name> | PRD.user:<name>
|
|
551
|
+
// | TAD.system:<name> | TAC-nnnn | omitted:<reason>
|
|
552
|
+
// The 0.29.0 REQ-title fallback is dropped (decision 3; a title hit
|
|
553
|
+
// becomes a suggestion the PR 2 computeQuestions surface returns).
|
|
554
|
+
// A statement with no `resolvedBy` fails; a statement with a
|
|
555
|
+
// `resolvedBy` outside the grammar fails with 'pointer does not
|
|
556
|
+
// resolve'.
|
|
557
|
+
/** @type {Array<{ id: string, why: string }>} */
|
|
558
|
+
const unresolvedFailing = [];
|
|
559
|
+
for (const s of resolvingStmts) {
|
|
560
|
+
const rb = s?.resolvedBy;
|
|
561
|
+
if (typeof rb !== 'string' || rb.length === 0) {
|
|
562
|
+
unresolvedFailing.push({ id: `brief:${s.id}`, why: `${s.kind} statement has no resolvedBy` });
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const parsed = parseResolvedBy(rb, tree);
|
|
566
|
+
if (!parsed.ok) {
|
|
567
|
+
unresolvedFailing.push({ id: `brief:${s.id}`, why: 'pointer does not resolve' });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
checks.push(makeCheck(
|
|
571
|
+
'skeleton:resolvedBy',
|
|
572
|
+
'delta',
|
|
573
|
+
resolvingStmts.length,
|
|
574
|
+
unresolvedFailing,
|
|
575
|
+
));
|
|
576
|
+
|
|
577
|
+
// Checks 2a / 2b: the former `skeleton:reqFields` split into two
|
|
578
|
+
// homogeneous checks (ADR-4126, proposal section 1.3). Each
|
|
579
|
+
// evaluates every REQ in scope independently so a REQ with a TODO
|
|
580
|
+
// description still has its shapes reported, and vice versa.
|
|
581
|
+
const reqIntentFail = [];
|
|
582
|
+
const reqShapeFail = [];
|
|
583
|
+
for (const req of reqInScope) {
|
|
584
|
+
// reqIntent (productOwner): description without a TODO + domain
|
|
585
|
+
// (what the requirement means and where it sits).
|
|
586
|
+
if (typeof req.description !== 'string' || req.description.length === 0 || TODO_RE.test(req.description)) {
|
|
587
|
+
reqIntentFail.push({ id: req.reqId, why: 'description missing or contains TODO placeholder' });
|
|
588
|
+
} else if (typeof req.domain !== 'string' || req.domain.length === 0) {
|
|
589
|
+
reqIntentFail.push({ id: req.reqId, why: 'domain missing' });
|
|
590
|
+
}
|
|
591
|
+
// reqShape (engineer): shapeClassification.shapes classification.
|
|
592
|
+
if (!req.shapeClassification || !Array.isArray(req.shapeClassification.shapes)) {
|
|
593
|
+
reqShapeFail.push({ id: req.reqId, why: 'shapeClassification.shapes missing' });
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
checks.push(makeCheck('skeleton:reqIntent', 'delta', reqInScope.length, reqIntentFail));
|
|
597
|
+
checks.push(makeCheck('skeleton:reqShape', 'delta', reqInScope.length, reqShapeFail));
|
|
598
|
+
|
|
599
|
+
// Check 3: TAD.dataArchitecture.dataStores + coreEntities present when any
|
|
600
|
+
// REQ exists tree-wide.
|
|
601
|
+
const persistenceReq = (tree.requirements ?? []).some((r) => Array.isArray(r?.shapeClassification?.shapes) && r.shapeClassification.shapes.includes('persistence'));
|
|
602
|
+
const tadFail = [];
|
|
603
|
+
if (persistenceReq) {
|
|
604
|
+
const tad = tree.tad ?? {};
|
|
605
|
+
const dataArch = tad?.dataArchitecture ?? {};
|
|
606
|
+
const dataStores = Array.isArray(dataArch.dataStores) ? dataArch.dataStores : [];
|
|
607
|
+
const coreEntities = Array.isArray(dataArch.coreEntities) ? dataArch.coreEntities : [];
|
|
608
|
+
if (dataStores.length === 0) tadFail.push({ id: 'TAD.dataArchitecture.dataStores', why: 'TAD.dataArchitecture.dataStores empty while persistence REQ exists' });
|
|
609
|
+
if (coreEntities.length === 0) tadFail.push({ id: 'TAD.dataArchitecture.coreEntities', why: 'TAD.dataArchitecture.coreEntities empty while persistence REQ exists' });
|
|
610
|
+
}
|
|
611
|
+
checks.push(makeCheck('skeleton:tadPersistence', 'tree', persistenceReq ? 2 : 0, tadFail));
|
|
612
|
+
|
|
613
|
+
// Check 4: exactly one deploy or deferral ADR tree-wide. Cheap
|
|
614
|
+
// detection by title prefix (proposal §11 assumption).
|
|
615
|
+
const deployAdrs = (tree.adrs ?? []).filter((a) => {
|
|
616
|
+
const t = typeof a?.title === 'string' ? a.title : '';
|
|
617
|
+
return t.startsWith('Deploy target:') || t.startsWith('Deploy deferral:');
|
|
618
|
+
});
|
|
619
|
+
const deployFail = [];
|
|
620
|
+
if (deployAdrs.length === 0) deployFail.push({ id: 'ADR:deploy', why: 'no Deploy target or Deploy deferral ADR found (title-prefix scan)' });
|
|
621
|
+
else if (deployAdrs.length > 1) deployFail.push({ id: 'ADR:deploy', why: `${deployAdrs.length} Deploy ADRs found; expected exactly one` });
|
|
622
|
+
checks.push(makeCheck('skeleton:deployAdr', 'tree', 1, deployFail));
|
|
623
|
+
|
|
624
|
+
return foldState('D2', gate, checks, {
|
|
625
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
626
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
// D3 -- Interface contracts and shapes (warn-with-ack in 0.29.0).
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
/** Set form of INTERFACE_KINDS for O(1) checks. */
|
|
635
|
+
const INTERFACE_KINDS_SET = new Set(INTERFACE_KINDS);
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* D3 -- Interface contracts and shapes. 0.29.0 runs the cheap
|
|
639
|
+
* presence, closed-vocabulary and draft-marker checks
|
|
640
|
+
* (`shapes:draftSettled` per ADR-4126 scans the `[draft]` prefix).
|
|
641
|
+
* REQ-188 (0.30.0): the draft marker extends to `TAC.purpose` and
|
|
642
|
+
* `TAD.dataArchitecture.coreEntities[].description`; `shapes:draftSettled`
|
|
643
|
+
* enumerates all three homes with ids `<tacId>:<name>`, `<tacId>`
|
|
644
|
+
* and `TAD.entity:<name>` respectively. `tacHasInterface` and
|
|
645
|
+
* `kindVocabulary` apply to drafts unchanged.
|
|
646
|
+
*
|
|
647
|
+
* SEAM 0.30: template markers per kind (recordShape.fields:, httpRoute
|
|
648
|
+
* method/path/request/response/errors, fixture.instances:); entity-name
|
|
649
|
+
* join across TAD.dataArchitecture.coreEntities and TAC.interfaces[];
|
|
650
|
+
* ownerRef and `authoredAt` path resolution. Those add checks to this
|
|
651
|
+
* stage; the fold shape stays the same.
|
|
652
|
+
*
|
|
653
|
+
* @param {StageContext} ctx
|
|
654
|
+
*/
|
|
655
|
+
export function checkD3Shapes(ctx) {
|
|
656
|
+
const gate = STAGE_GATES.D3;
|
|
657
|
+
const scope = asSet(ctx.scope);
|
|
658
|
+
const tree = ctx.tree;
|
|
659
|
+
const tacsInScope = (tree.tacs ?? []).filter((t) => scope.has(t.tacId));
|
|
660
|
+
const shapedReqInScope = (tree.requirements ?? []).filter((r) => {
|
|
661
|
+
if (!scope.has(r.reqId)) return false;
|
|
662
|
+
const shapes = r.shapeClassification?.shapes ?? [];
|
|
663
|
+
return shapes.includes('httpApi') || shapes.includes('persistence') || shapes.includes('auth');
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Core entities on TAD.dataArchitecture come from the whole tree,
|
|
667
|
+
// not the scope: an entity's draft marker is a tree-wide draft home
|
|
668
|
+
// (the engineer owns every entity in one place) and reporting it
|
|
669
|
+
// under D3 does not depend on which TAC a change touched.
|
|
670
|
+
const coreEntities = Array.isArray(tree.tad?.dataArchitecture?.coreEntities)
|
|
671
|
+
? /** @type {any[]} */ (tree.tad.dataArchitecture.coreEntities)
|
|
672
|
+
: [];
|
|
673
|
+
const draftEntities = coreEntities.filter(parseCoreEntityDraft);
|
|
674
|
+
|
|
675
|
+
if (tacsInScope.length === 0 && shapedReqInScope.length === 0 && draftEntities.length === 0) {
|
|
676
|
+
return notApplicable('D3', gate, 'no TAC, no shaped REQ (httpApi/persistence/auth) and no [draft] core entity in scope');
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const checks = [];
|
|
680
|
+
|
|
681
|
+
// Check 1: every TAC in scope has at least one interface.
|
|
682
|
+
const noIfaceTacs = tacsInScope.filter((t) => !Array.isArray(t.interfaces) || t.interfaces.length === 0);
|
|
683
|
+
checks.push(makeCheck(
|
|
684
|
+
'shapes:tacHasInterface',
|
|
685
|
+
'delta',
|
|
686
|
+
tacsInScope.length,
|
|
687
|
+
noIfaceTacs.map((t) => ({ id: t.tacId, why: 'no interfaces authored' })),
|
|
688
|
+
));
|
|
689
|
+
|
|
690
|
+
// Check 2: every interface kind is in the closed vocabulary.
|
|
691
|
+
// Check 3: no description across the three draft homes still
|
|
692
|
+
// carries the `[draft]` marker. A draft shape is entry material
|
|
693
|
+
// the agent pre-populates after L1; the engineer removes the
|
|
694
|
+
// marker when the shape is settled. `tacHasInterface` and
|
|
695
|
+
// `kindVocabulary` apply equally to drafts.
|
|
696
|
+
let totalInterfaces = 0;
|
|
697
|
+
const badKinds = [];
|
|
698
|
+
const draftFindings = [];
|
|
699
|
+
for (const tac of tacsInScope) {
|
|
700
|
+
if (parseTacPurposeDraft(tac)) {
|
|
701
|
+
draftFindings.push({ id: tac.tacId, why: 'draft TAC purpose pre-populated at L1, not yet settled' });
|
|
702
|
+
}
|
|
703
|
+
for (const iface of tac.interfaces ?? []) {
|
|
704
|
+
totalInterfaces += 1;
|
|
705
|
+
const kind = iface?.kind;
|
|
706
|
+
if (typeof kind !== 'string' || !INTERFACE_KINDS_SET.has(kind)) {
|
|
707
|
+
badKinds.push({ id: `${tac.tacId}:${iface?.name ?? '(unnamed)'}`, why: `unknown interface kind ${JSON.stringify(kind)}` });
|
|
708
|
+
}
|
|
709
|
+
if (parseInterfaceDraft(iface)) {
|
|
710
|
+
draftFindings.push({ id: `${tac.tacId}:${iface?.name ?? '(unnamed)'}`, why: 'draft interface pre-populated at L1, not yet settled' });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
for (const entity of draftEntities) {
|
|
715
|
+
draftFindings.push({ id: `TAD.entity:${entity?.name ?? '(unnamed)'}`, why: 'draft core entity pre-populated at L1, not yet settled' });
|
|
716
|
+
}
|
|
717
|
+
// Total for the draft check is every draft home that could carry
|
|
718
|
+
// the marker on an in-scope TAC plus every core entity (tree-wide):
|
|
719
|
+
// the TAC itself (its purpose) + every interface on it, plus the
|
|
720
|
+
// coreEntities list.
|
|
721
|
+
const totalDraftHomes = tacsInScope.length + totalInterfaces + coreEntities.length;
|
|
722
|
+
checks.push(makeCheck('shapes:kindVocabulary', 'delta', totalInterfaces, badKinds));
|
|
723
|
+
checks.push(makeCheck('shapes:draftSettled', 'delta', totalDraftHomes, draftFindings));
|
|
724
|
+
|
|
725
|
+
return foldState('D3', gate, checks, {
|
|
726
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
727
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
// D4 -- Stories and criteria (blocking floors in 0.29.0).
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* D4 -- Stories and criteria floors. 0.29.0 enforces the four floors
|
|
737
|
+
* proposal §3.2 blocks on: every REQ has a US, every US has a testable
|
|
738
|
+
* AC, a failure AC (or opt-out per class), a must-not AC (or opt-out),
|
|
739
|
+
* tacIds non-empty and resolving, no scaffold TODO placeholder.
|
|
740
|
+
*
|
|
741
|
+
* SEAM 0.30: closed-set scan across enumerations; ownerRef resolution
|
|
742
|
+
* to TAC.interfaces[<name>]; template-marker parse. Warn-only notices
|
|
743
|
+
* in 0.29.0 (see D3).
|
|
744
|
+
*
|
|
745
|
+
* @param {StageContext} ctx
|
|
746
|
+
*/
|
|
747
|
+
export function checkD4Stories(ctx) {
|
|
748
|
+
const gate = STAGE_GATES.D4;
|
|
749
|
+
const scope = asSet(ctx.scope);
|
|
750
|
+
const tree = ctx.tree;
|
|
751
|
+
const manifest = /** @type {any} */ (tree.manifest);
|
|
752
|
+
const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
|
|
753
|
+
const usInScope = (tree.userStories ?? []).filter((us) => scope.has(us.usId));
|
|
754
|
+
|
|
755
|
+
if (reqInScope.length === 0 && usInScope.length === 0) {
|
|
756
|
+
return notApplicable('D4', gate, 'no REQ and no US in scope');
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const checks = [];
|
|
760
|
+
|
|
761
|
+
// Check 1: every REQ in scope has at least one US.
|
|
762
|
+
const reqWithoutUs = [];
|
|
763
|
+
for (const req of reqInScope) {
|
|
764
|
+
const hasUs = (tree.userStories ?? []).some((us) => us.reqId === req.reqId);
|
|
765
|
+
if (!hasUs) reqWithoutUs.push({ id: req.reqId, why: 'no owning user story' });
|
|
766
|
+
}
|
|
767
|
+
checks.push(makeCheck('stories:reqHasUs', 'delta', reqInScope.length, reqWithoutUs));
|
|
768
|
+
|
|
769
|
+
// Check 2: every US in scope carries the class floors + tacIds + testable AC + no TODO.
|
|
770
|
+
const usFail = [];
|
|
771
|
+
for (const us of usInScope) {
|
|
772
|
+
const acs = Array.isArray(us.acceptanceCriteria) ? us.acceptanceCriteria : [];
|
|
773
|
+
// testable
|
|
774
|
+
const hasTestable = acs.some((ac) => ac?.testable !== false && typeof ac?.description === 'string' && ac.description.length > 0);
|
|
775
|
+
if (!hasTestable) usFail.push({ id: us.usId, why: 'no testable acceptance criterion' });
|
|
776
|
+
// failure class
|
|
777
|
+
const hasFailure = acs.some((ac) => parseAcClass(ac) === 'failure');
|
|
778
|
+
const failureOpted = isOptedOut(manifest, us.reqId, 'defineD4:failure');
|
|
779
|
+
if (!hasFailure && !failureOpted) {
|
|
780
|
+
usFail.push({ id: us.usId, why: 'no [failure] class AC and no defineD4:failure opt-out on this REQ' });
|
|
781
|
+
}
|
|
782
|
+
// must-not class
|
|
783
|
+
const hasMustNot = acs.some((ac) => parseAcClass(ac) === 'must-not');
|
|
784
|
+
const mustNotOpted = isOptedOut(manifest, us.reqId, 'defineD4:mustNot');
|
|
785
|
+
if (!hasMustNot && !mustNotOpted) {
|
|
786
|
+
usFail.push({ id: us.usId, why: 'no [must-not] class AC and no defineD4:mustNot opt-out on this REQ' });
|
|
787
|
+
}
|
|
788
|
+
// tacIds non-empty and resolving
|
|
789
|
+
const tacIds = Array.isArray(us.tacIds) ? us.tacIds : [];
|
|
790
|
+
if (tacIds.length === 0) {
|
|
791
|
+
usFail.push({ id: us.usId, why: 'tacIds is empty' });
|
|
792
|
+
} else {
|
|
793
|
+
const unresolved = tacIds.filter((id) => tree.kindById?.get(id) !== 'tac');
|
|
794
|
+
if (unresolved.length > 0) {
|
|
795
|
+
usFail.push({ id: us.usId, why: `tacIds names non-TAC ids: ${unresolved.join(', ')}` });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// no TODO placeholder in any AC description
|
|
799
|
+
const todoAc = acs.find((ac) => typeof ac?.description === 'string' && TODO_RE.test(ac.description));
|
|
800
|
+
if (todoAc) usFail.push({ id: us.usId, why: `scaffold TODO placeholder in ${todoAc.id ?? '(ac)'}` });
|
|
801
|
+
}
|
|
802
|
+
checks.push(makeCheck('stories:usFloors', 'delta', usInScope.length, usFail));
|
|
803
|
+
|
|
804
|
+
return foldState('D4', gate, checks, {
|
|
805
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
806
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
// D5 -- Cross-cutting weave (warn-with-ack in 0.29.0).
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* D5 -- Cross-cutting weave. 0.29.0 runs cheap presence checks.
|
|
816
|
+
*
|
|
817
|
+
* SEAM 0.30: full concern catalogue with per-shape applicability;
|
|
818
|
+
* per-(concern, REQ) pair enumeration; TAD securityArchitecture and
|
|
819
|
+
* operationalConcerns structure checks beyond presence.
|
|
820
|
+
*
|
|
821
|
+
* @param {StageContext} ctx
|
|
822
|
+
*/
|
|
823
|
+
export function checkD5Crosscut(ctx) {
|
|
824
|
+
const gate = STAGE_GATES.D5;
|
|
825
|
+
const scope = asSet(ctx.scope);
|
|
826
|
+
const tree = ctx.tree;
|
|
827
|
+
const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
|
|
828
|
+
|
|
829
|
+
if (reqInScope.length === 0) {
|
|
830
|
+
return notApplicable('D5', gate, 'no REQ in scope');
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const checks = [];
|
|
834
|
+
|
|
835
|
+
// Check 1: TAD.securityArchitecture non-empty when any REQ carries shape 'auth' or 'httpApi'.
|
|
836
|
+
const authApi = (tree.requirements ?? []).some((r) => {
|
|
837
|
+
const s = r.shapeClassification?.shapes ?? [];
|
|
838
|
+
return s.includes('auth') || s.includes('httpApi');
|
|
839
|
+
});
|
|
840
|
+
const tad = /** @type {any} */ (tree.tad ?? {});
|
|
841
|
+
const secFail = [];
|
|
842
|
+
if (authApi) {
|
|
843
|
+
const sa = tad.securityArchitecture;
|
|
844
|
+
if (!sa || (typeof sa === 'string' ? sa.length === 0 : Object.keys(sa).length === 0)) {
|
|
845
|
+
secFail.push({ id: 'TAD.securityArchitecture', why: 'empty while auth or httpApi REQ exists' });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
checks.push(makeCheck('crosscut:securityArchitecture', 'tree', authApi ? 1 : 0, secFail));
|
|
849
|
+
|
|
850
|
+
// Check 2: TAD.operationalConcerns non-empty when any AC description
|
|
851
|
+
// carries the '[deployed]' marker (a cheap presence signal that
|
|
852
|
+
// proposal §3.2 D5 lists as 'deployed-scope AC').
|
|
853
|
+
const hasDeployedAc = (tree.userStories ?? []).some((us) => (us.acceptanceCriteria ?? []).some((ac) => typeof ac?.description === 'string' && ac.description.includes('[deployed]')));
|
|
854
|
+
const opFail = [];
|
|
855
|
+
if (hasDeployedAc) {
|
|
856
|
+
const oc = tad.operationalConcerns;
|
|
857
|
+
if (!oc || (typeof oc === 'string' ? oc.length === 0 : Object.keys(oc).length === 0)) {
|
|
858
|
+
opFail.push({ id: 'TAD.operationalConcerns', why: 'empty while deployed-scope AC exists' });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
checks.push(makeCheck('crosscut:operationalConcerns', 'tree', hasDeployedAc ? 1 : 0, opFail));
|
|
862
|
+
|
|
863
|
+
// Check 3: no open concern-ledger entry on a REQ in scope.
|
|
864
|
+
const concerns = /** @type {any} */ (ctx.ledgers?.concerns);
|
|
865
|
+
const concernEntries = Array.isArray(concerns?.concerns) ? concerns.concerns : [];
|
|
866
|
+
const openInScope = concernEntries.filter((c) => c?.status === 'open' && scope.has(c?.reqId));
|
|
867
|
+
checks.push(makeCheck(
|
|
868
|
+
'crosscut:concernsResolved',
|
|
869
|
+
'delta',
|
|
870
|
+
concernEntries.filter((c) => scope.has(c?.reqId)).length,
|
|
871
|
+
openInScope.map((c) => ({ id: `concern:${c.id}`, why: `open concern on ${c.reqId}: ${c.concern}` })),
|
|
872
|
+
));
|
|
873
|
+
|
|
874
|
+
return foldState('D5', gate, checks, {
|
|
875
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
876
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ---------------------------------------------------------------------------
|
|
881
|
+
// D6 -- Consistency and satisfiability probe (warn-with-ack in 0.29.0).
|
|
882
|
+
// ---------------------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* D6 -- Consistency and satisfiability probe. 0.29.0 runs only the
|
|
886
|
+
* validate-clean tree-wide check and the probe-ledger open count.
|
|
887
|
+
*
|
|
888
|
+
* SEAM 0.30: contradiction-lite across criteria on one story and
|
|
889
|
+
* across their TACs' ADRs; unsatisfiable-lite (a `then` naming
|
|
890
|
+
* undefined state); duplicate scan; orphan-interface scan;
|
|
891
|
+
* regeneration probe runner injectable like `mutationRunner` in
|
|
892
|
+
* src/review/index.js.
|
|
893
|
+
*
|
|
894
|
+
* @param {StageContext} ctx
|
|
895
|
+
*/
|
|
896
|
+
export function checkD6Consistency(ctx) {
|
|
897
|
+
const gate = STAGE_GATES.D6;
|
|
898
|
+
const checks = [];
|
|
899
|
+
|
|
900
|
+
const validateErrors = Array.isArray(ctx.validateErrors) ? ctx.validateErrors : [];
|
|
901
|
+
checks.push(makeCheck(
|
|
902
|
+
'consistency:validateClean',
|
|
903
|
+
'tree',
|
|
904
|
+
// "Expected zero" pattern: total stays at Math.max(N, 1) so the
|
|
905
|
+
// reported ratio reads sensibly on a clean tree ("1/1") and on a
|
|
906
|
+
// dirty one ("0/N"). Truncate the failing array for display but
|
|
907
|
+
// fold `pass` from the untruncated count so beyond-20 failures do
|
|
908
|
+
// not turn into false passes.
|
|
909
|
+
Math.max(validateErrors.length, 1),
|
|
910
|
+
validateErrors.slice(0, 20).map((e, i) => ({
|
|
911
|
+
id: /** @type {any} */ (e)?.documentId ?? `validate:${i}`,
|
|
912
|
+
why: /** @type {any} */ (e)?.message ?? 'validate error',
|
|
913
|
+
})),
|
|
914
|
+
validateErrors.length,
|
|
915
|
+
));
|
|
916
|
+
|
|
917
|
+
const probeOpenCount = Number.isFinite(ctx.probeOpenCount) ? Number(ctx.probeOpenCount) : 0;
|
|
918
|
+
const probes = /** @type {any} */ (ctx.ledgers?.probes);
|
|
919
|
+
const probeEntries = Array.isArray(probes?.probes) ? probes.probes : [];
|
|
920
|
+
const openProbes = probeEntries.filter((p) => p?.status === 'open');
|
|
921
|
+
// Prefer caller-supplied probeOpenCount when set; fall back to bundle scan.
|
|
922
|
+
const effectiveOpen = ctx.probeOpenCount !== undefined ? probeOpenCount : openProbes.length;
|
|
923
|
+
const failing = [];
|
|
924
|
+
if (effectiveOpen > 0) {
|
|
925
|
+
const list = ctx.probeOpenCount !== undefined
|
|
926
|
+
? [{ id: 'probe-ledger', why: `${effectiveOpen} open probe finding(s)` }]
|
|
927
|
+
: openProbes.slice(0, 20).map((p) => ({ id: `probe:${p.id}`, why: `open probe on ${p.reqId}: ${p.finding}` }));
|
|
928
|
+
failing.push(...list);
|
|
929
|
+
}
|
|
930
|
+
// Fold `pass` from the untruncated open-probe count so a 25-open-
|
|
931
|
+
// probes ledger reports 0/25, not 5/25. The failing array itself
|
|
932
|
+
// stays truncated to 20 for display; the caller-supplied count path
|
|
933
|
+
// uses a single representative entry regardless.
|
|
934
|
+
checks.push(makeCheck('consistency:probeCount', 'tree', Math.max(effectiveOpen, 1), failing, effectiveOpen));
|
|
935
|
+
|
|
936
|
+
return foldState('D6', gate, checks, {
|
|
937
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
938
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ---------------------------------------------------------------------------
|
|
943
|
+
// D7 -- Decisions and review (blocking in 0.29.0).
|
|
944
|
+
// ---------------------------------------------------------------------------
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* D7 -- Decisions and review.
|
|
948
|
+
*
|
|
949
|
+
* @param {StageContext} ctx
|
|
950
|
+
*/
|
|
951
|
+
export function checkD7Decisions(ctx) {
|
|
952
|
+
const gate = STAGE_GATES.D7;
|
|
953
|
+
const decisions = /** @type {any} */ (ctx.ledgers?.decisions);
|
|
954
|
+
const decisionEntries = Array.isArray(decisions?.decisions) ? decisions.decisions : [];
|
|
955
|
+
const openDecisions = decisionEntries.filter((d) => d?.status === 'open');
|
|
956
|
+
|
|
957
|
+
const delta = ctx.delta ?? { changed: [], added: [] };
|
|
958
|
+
const deltaDocs = new Set([...(delta.changed ?? []), ...(delta.added ?? [])]);
|
|
959
|
+
const deltaSize = [...deltaDocs].filter((id) => !id.startsWith('ledger:')).length;
|
|
960
|
+
const skipSingle = ctx.profile?.skipReviewFor === 'singleDocument' && deltaSize === 1;
|
|
961
|
+
if (openDecisions.length === 0 && skipSingle) {
|
|
962
|
+
return notApplicable('D7', gate, 'no open decisions and profile skipReviewFor:singleDocument on a one-document delta');
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const checks = [];
|
|
966
|
+
// Check 1: `decisions:wellFormed` (productOwner, over tree) --
|
|
967
|
+
// every decision is enumerated (ADR-4126, proposal section 1.3):
|
|
968
|
+
// a non-empty question, at least two options, a default that
|
|
969
|
+
// names one of the option letters.
|
|
970
|
+
const wellFormedFail = [];
|
|
971
|
+
for (const d of decisionEntries) {
|
|
972
|
+
const why = 'decision is not enumerated (question, two or more options, a default)';
|
|
973
|
+
const id = `decision:${d?.id ?? '?'}`;
|
|
974
|
+
if (typeof d?.question !== 'string' || d.question.length === 0) {
|
|
975
|
+
wellFormedFail.push({ id, why });
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (!Array.isArray(d.options) || d.options.length < 2) {
|
|
979
|
+
wellFormedFail.push({ id, why });
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
const letters = new Set(d.options.map((o) => (o && typeof o === 'object') ? /** @type {any} */ (o).letter : undefined));
|
|
983
|
+
if (typeof d.default !== 'string' || !letters.has(d.default)) {
|
|
984
|
+
wellFormedFail.push({ id, why });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
checks.push(makeCheck('decisions:wellFormed', 'tree', decisionEntries.length, wellFormedFail));
|
|
988
|
+
|
|
989
|
+
// Check 2: `decisions:allAnswered` (engineer, over tree) -- zero
|
|
990
|
+
// open decisions. The hand-over condition for build.
|
|
991
|
+
checks.push(makeCheck(
|
|
992
|
+
'decisions:allAnswered',
|
|
993
|
+
'tree',
|
|
994
|
+
decisionEntries.length,
|
|
995
|
+
openDecisions.map((d) => ({ id: `decision:${d.id}`, why: `open: ${d.question ?? '(no question)'}` })),
|
|
996
|
+
));
|
|
997
|
+
|
|
998
|
+
return foldState('D7', gate, checks, {
|
|
999
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
1000
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ---------------------------------------------------------------------------
|
|
1005
|
+
// D8 -- Freeze (blocking in 0.29.0).
|
|
1006
|
+
// ---------------------------------------------------------------------------
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* D8 -- Freeze. Tree-wide, mechanical: prior gates passed or
|
|
1010
|
+
* acknowledged, every AC owned by exactly one FBS, queue head
|
|
1011
|
+
* actionable, validate clean.
|
|
1012
|
+
*
|
|
1013
|
+
* @param {StageContext} ctx
|
|
1014
|
+
*/
|
|
1015
|
+
export function checkD8Freeze(ctx) {
|
|
1016
|
+
const gate = STAGE_GATES.D8;
|
|
1017
|
+
const tree = ctx.tree;
|
|
1018
|
+
const checks = [];
|
|
1019
|
+
|
|
1020
|
+
// Check 1: prior stages passed or acknowledged.
|
|
1021
|
+
const priorStages = Array.isArray(ctx.priorStages) ? ctx.priorStages : [];
|
|
1022
|
+
const priorBad = priorStages
|
|
1023
|
+
.filter((s) => s.state !== 'passed' && s.state !== 'acknowledged' && s.state !== 'notApplicable')
|
|
1024
|
+
.map((s) => ({ id: /** @type {any} */ (s).stage ?? '(?)', why: `state ${s.state}` }));
|
|
1025
|
+
checks.push(makeCheck('freeze:priorGates', 'tree', priorStages.length, priorBad));
|
|
1026
|
+
|
|
1027
|
+
// Check 2: every AC owned by exactly one FBS. Walker gives us
|
|
1028
|
+
// fbsByAcId inversion; we assert length === 1 for every known AC.
|
|
1029
|
+
const acIds = collectAllAcIds(tree);
|
|
1030
|
+
const acFail = [];
|
|
1031
|
+
for (const acId of acIds) {
|
|
1032
|
+
const owners = tree.fbsByAcId?.get(acId) ?? [];
|
|
1033
|
+
if (owners.length === 0) acFail.push({ id: acId, why: 'no owning FBS' });
|
|
1034
|
+
else if (owners.length > 1) acFail.push({ id: acId, why: `${owners.length} FBS own this AC: ${owners.join(', ')}` });
|
|
1035
|
+
}
|
|
1036
|
+
checks.push(makeCheck('freeze:acFbsOwnership', 'tree', acIds.size, acFail));
|
|
1037
|
+
|
|
1038
|
+
// Check 3: queue head actionable and not a placeholder.
|
|
1039
|
+
const queue = computeQueue(tree);
|
|
1040
|
+
const queueFail = [];
|
|
1041
|
+
if (!queue.nextActionable) {
|
|
1042
|
+
queueFail.push({ id: 'queue:head', why: 'no actionable FBS at the queue head' });
|
|
1043
|
+
} else {
|
|
1044
|
+
const head = (tree.fbsItems ?? []).find((f) => f.fbsId === queue.nextActionable);
|
|
1045
|
+
const title = typeof head?.title === 'string' ? head.title.trim() : '';
|
|
1046
|
+
if (!title || /^todo\b/i.test(title)) {
|
|
1047
|
+
queueFail.push({ id: queue.nextActionable, why: 'queue head title is empty or a TODO placeholder' });
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
checks.push(makeCheck('freeze:queueHead', 'tree', 1, queueFail));
|
|
1051
|
+
|
|
1052
|
+
// Check 4: validate clean tree-wide (same signal as D6 but blocking here).
|
|
1053
|
+
// Truncate the failing array for display; fold `pass` from the
|
|
1054
|
+
// untruncated count so beyond-20 failures do not turn into false passes.
|
|
1055
|
+
const validateErrors = Array.isArray(ctx.validateErrors) ? ctx.validateErrors : [];
|
|
1056
|
+
const validateFail = validateErrors.slice(0, 20).map((e, i) => ({
|
|
1057
|
+
id: /** @type {any} */ (e)?.documentId ?? `validate:${i}`,
|
|
1058
|
+
why: /** @type {any} */ (e)?.message ?? 'validate error',
|
|
1059
|
+
}));
|
|
1060
|
+
checks.push(makeCheck('freeze:validateClean', 'tree', Math.max(validateErrors.length, 1), validateFail, validateErrors.length));
|
|
1061
|
+
|
|
1062
|
+
return foldState('D8', gate, checks, {
|
|
1063
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
1064
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Collect every AC id in the tree (walker keeps them inline on the
|
|
1070
|
+
* parent US). Kept here to avoid depending on the private helper in
|
|
1071
|
+
* `walker.js`.
|
|
1072
|
+
*
|
|
1073
|
+
* @param {import('#core/store/walker.js').TreeModel} tree
|
|
1074
|
+
* @returns {Set<string>}
|
|
1075
|
+
*/
|
|
1076
|
+
function collectAllAcIds(tree) {
|
|
1077
|
+
const out = new Set();
|
|
1078
|
+
for (const us of tree.userStories ?? []) {
|
|
1079
|
+
for (const ac of us.acceptanceCriteria ?? []) {
|
|
1080
|
+
if (typeof ac?.id === 'string') out.add(ac.id);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return out;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Dispatch by stage id (D1..D8) or by short name (brief..freeze).
|
|
1088
|
+
* Consumers call `runStage('D4', ctx)` or `runStage('stories', ctx)`.
|
|
1089
|
+
*
|
|
1090
|
+
* @param {string} stageOrShort
|
|
1091
|
+
* @param {StageContext} ctx
|
|
1092
|
+
*/
|
|
1093
|
+
export function runStage(stageOrShort, ctx) {
|
|
1094
|
+
const stage = STAGE_ALIASES[stageOrShort];
|
|
1095
|
+
if (!stage) throw new TypeError(`Unknown stage '${stageOrShort}'`);
|
|
1096
|
+
switch (stage) {
|
|
1097
|
+
case 'D1': return checkD1Brief(ctx);
|
|
1098
|
+
case 'D2': return checkD2Skeleton(ctx);
|
|
1099
|
+
case 'D3': return checkD3Shapes(ctx);
|
|
1100
|
+
case 'D4': return checkD4Stories(ctx);
|
|
1101
|
+
case 'D5': return checkD5Crosscut(ctx);
|
|
1102
|
+
case 'D6': return checkD6Consistency(ctx);
|
|
1103
|
+
case 'D7': return checkD7Decisions(ctx);
|
|
1104
|
+
case 'D8': return checkD8Freeze(ctx);
|
|
1105
|
+
default: throw new TypeError(`Unhandled stage '${stage}'`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|