@dzhechkov/p-replicator 1.13.1 → 1.13.2
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/.dz-manifest.json +65 -13
- package/README.md +19 -1
- package/package.json +3 -3
- package/sbom.json +142 -12
- package/scripts/check-pipeline-gaps.sh +510 -21
- package/src/utils.js +1 -0
- package/templates/.claude/commands/feature.md +43 -1
- package/templates/.claude/hooks/check-review-contract.cjs +205 -0
- package/templates/.claude/hooks/statusline.cjs +1 -1
- package/templates/.claude/rules/replicate-pipeline.md +4 -3
- package/templates/.claude/skills/requirements-validator/SKILL.md +18 -5
- package/templates/.claude/skills/requirements-validator/references/feature-report-contracts.md +67 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +15 -6
- package/tests/e2e/feature-contour.test.js +176 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/01_specification.md +15 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/02_pseudocode.md +19 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/03_architecture.md +3 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/04_refinement.md +3 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/05_completion.md +7 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/review-report.md +11 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/validation-report.md +10 -0
- package/tests/fixtures/feature-contour/tests/demo.test.js +16 -0
- package/tests/snapshot/baseline.json +9 -7
- package/tests/unit/check-review-contract.test.js +181 -0
- package/tests/unit/honest-failure-rules.test.js +18 -3
- package/tests/unit/sync-templates-guard.test.js +46 -3
- package/tests/unit/traceability-completion-gate.test.js +267 -0
|
@@ -133,6 +133,17 @@ Run validation against INVEST + SMART + BDD scenario completeness.
|
|
|
133
133
|
|
|
134
134
|
Output: `docs/features/<feature>/validation-report.md`
|
|
135
135
|
|
|
136
|
+
The report carries the `Spec revision:` line and the `## Criterion scenarios` table (AC id → BDD
|
|
137
|
+
scenario) in the exact format of
|
|
138
|
+
`.claude/skills/requirements-validator/references/feature-report-contracts.md`. Then run:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
bash "$CHECK_PIPELINE_GAPS" "${CLAUDE_PROJECT_DIR:-.}" --report-revision --criterion-scenarios
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Same exit semantics as the Phase 1 gate: `0` advances; `1` (stale revision, AC id without a scenario
|
|
145
|
+
row) returns to Phase 2; `2` stops.
|
|
146
|
+
|
|
136
147
|
### Phase 3: IMPLEMENT (parallel agents)
|
|
137
148
|
|
|
138
149
|
1. Read SPARC docs from Phase 1
|
|
@@ -178,11 +189,39 @@ substantive, post-launch file with a terminal status. Narrative/chat/silence is
|
|
|
178
189
|
invalid receipt MUST block merge/completion. Full rule and bounded exception:
|
|
179
190
|
`.claude/rules/swarm-file-evidence.md`.
|
|
180
191
|
|
|
192
|
+
#### Threshold tests pin the number (layer-2 rule)
|
|
193
|
+
|
|
194
|
+
A test of a THRESHOLD asserts the literal number; a test of a DERIVED value calls the production
|
|
195
|
+
implementation. A test that computes its expectation from the production constant is green for any
|
|
196
|
+
value and proves nothing.
|
|
197
|
+
|
|
198
|
+
#### Blocking criterion-coverage gate
|
|
199
|
+
|
|
200
|
+
`05_completion.md` MUST carry the `## Criterion coverage` table (AC id → test file → test title; format
|
|
201
|
+
in `feature-report-contracts.md`). At the end of Phase 3 run:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
bash "$CHECK_PIPELINE_GAPS" "${CLAUDE_PROJECT_DIR:-.}" --completion
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`0` advances; `1` (an AC without a test, a row whose file or title does not exist) returns to
|
|
208
|
+
Phase 3; `2` stops. Never downgrade either non-zero status to a warning.
|
|
209
|
+
|
|
181
210
|
### Phase 4: REVIEW (brutal-honesty-review)
|
|
182
211
|
|
|
183
212
|
Read: `.claude/skills/brutal-honesty-review/SKILL.md`
|
|
184
213
|
|
|
185
|
-
|
|
214
|
+
Specification contract: the review receives `01_specification.md` + `validation-report.md` (a review
|
|
215
|
+
not given the specification is incomplete by construction and says so) and writes
|
|
216
|
+
`docs/features/<feature>/review-report.md` — `Reviewer family:` (disclosure) and `Spec revision:` in the
|
|
217
|
+
first 20 lines, an AC-by-AC `## Spec conformance` table — per `feature-report-contracts.md`. Then run:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/check-review-contract.cjs" "${CLAUDE_PROJECT_DIR:-.}" "<feature>"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`0` advances; `1` (named contract gaps) returns to Phase 4; `2` stops. Findings classified by
|
|
224
|
+
severity. Critical (blocker | high) MUST be fixed.
|
|
186
225
|
|
|
187
226
|
## Final Steps
|
|
188
227
|
|
|
@@ -196,8 +235,11 @@ Skip per-phase user confirmations. Auto-decisions:
|
|
|
196
235
|
- Phase 1: proceed if all docs exist
|
|
197
236
|
- Phase 1 traceability: proceed only when `check-pipeline-gaps.sh` exits `0`; preserve exit `1`/`2`
|
|
198
237
|
- Phase 2: proceed if 🟢 or 🟡; auto-retry once on 🔴
|
|
238
|
+
- Phase 2 report revision + criterion scenarios: proceed only when `--report-revision --criterion-scenarios` exits `0`; preserve exit `1`/`2`
|
|
199
239
|
- Phase 3: proceed if tests + lint + build green
|
|
240
|
+
- Phase 3 criterion coverage: proceed only when `--completion` exits `0`; preserve exit `1`/`2`
|
|
200
241
|
- Phase 4: auto-fix `high` if straightforward; halt on `blocker`
|
|
242
|
+
- Phase 4 review contract: proceed only when `check-review-contract.cjs` exits `0`; preserve exit `1`/`2`
|
|
201
243
|
|
|
202
244
|
## Related
|
|
203
245
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate the feature review's binding to the specification it judged.
|
|
6
|
+
*
|
|
7
|
+
* Usage: node check-review-contract.cjs <project-root> <feature-slug>
|
|
8
|
+
* Exit 0: contract passes; 1: named contract gaps; 2: inputs could not be established.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
|
|
15
|
+
const AC_ID_SOURCE = 'AC-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*-[0-9]+';
|
|
16
|
+
const AC_HEADING = new RegExp('^###\\s+(' + AC_ID_SOURCE + ')(?:\\s|$)');
|
|
17
|
+
const FAMILIES = new Set(['claude', 'codex', 'human', 'unknown']);
|
|
18
|
+
const VERDICTS = new Set(['met', 'not met', 'unverifiable']);
|
|
19
|
+
|
|
20
|
+
function say(line) { process.stdout.write(line + '\n'); }
|
|
21
|
+
|
|
22
|
+
function cannotCheck(reason) {
|
|
23
|
+
say('NOT-ESTABLISHED review contract: ' + reason);
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isInside(root, candidate) {
|
|
28
|
+
const relative = path.relative(root, candidate);
|
|
29
|
+
return relative !== '' && relative !== '..' && !relative.startsWith('..' + path.sep)
|
|
30
|
+
&& !path.isAbsolute(relative);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function projectRoot(input) {
|
|
34
|
+
let root;
|
|
35
|
+
try { root = fs.realpathSync(path.resolve(input)); } catch (error) {
|
|
36
|
+
cannotCheck('project root is missing or unreadable: ' + ((error && error.message) || error));
|
|
37
|
+
}
|
|
38
|
+
let stat;
|
|
39
|
+
try { stat = fs.statSync(root); } catch (error) {
|
|
40
|
+
cannotCheck('project root cannot be inspected: ' + ((error && error.message) || error));
|
|
41
|
+
}
|
|
42
|
+
if (!stat.isDirectory()) cannotCheck('project root is not a directory: ' + root);
|
|
43
|
+
return root;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function safeRead(root, relative, label) {
|
|
47
|
+
const candidate = path.resolve(root, relative);
|
|
48
|
+
if (!isInside(root, candidate)) cannotCheck(label + ' escapes the project root: ' + relative);
|
|
49
|
+
|
|
50
|
+
let current = root;
|
|
51
|
+
const parts = path.relative(root, candidate).split(path.sep);
|
|
52
|
+
for (let index = 0; index < parts.length; index++) {
|
|
53
|
+
current = path.join(current, parts[index]);
|
|
54
|
+
let stat;
|
|
55
|
+
try { stat = fs.lstatSync(current); } catch (error) {
|
|
56
|
+
cannotCheck(label + ' is missing or unreadable: ' + current + ' ('
|
|
57
|
+
+ ((error && error.message) || error) + ')');
|
|
58
|
+
}
|
|
59
|
+
if (stat.isSymbolicLink()) cannotCheck(label + ' is a symlink: ' + current);
|
|
60
|
+
if (index < parts.length - 1 && !stat.isDirectory()) {
|
|
61
|
+
cannotCheck(label + ' has a non-directory path component: ' + current);
|
|
62
|
+
}
|
|
63
|
+
if (index === parts.length - 1) {
|
|
64
|
+
if (!stat.isFile()) cannotCheck(label + ' is not a regular file: ' + current);
|
|
65
|
+
if ((stat.mode & 0o444) === 0) cannotCheck(label + ' is unreadable: ' + current);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let real;
|
|
70
|
+
try { real = fs.realpathSync(candidate); } catch (error) {
|
|
71
|
+
cannotCheck(label + ' cannot be resolved: ' + ((error && error.message) || error));
|
|
72
|
+
}
|
|
73
|
+
if (!isInside(root, real)) cannotCheck(label + ' resolves outside the project root: ' + real);
|
|
74
|
+
|
|
75
|
+
try { return fs.readFileSync(real); } catch (error) {
|
|
76
|
+
cannotCheck(label + ' is unreadable: ' + ((error && error.message) || error));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function specificationIds(buffer) {
|
|
81
|
+
const lines = buffer.toString('utf8').split(/\n/).map((line) => line.replace(/\r$/, ''));
|
|
82
|
+
const ids = [];
|
|
83
|
+
let fence = '';
|
|
84
|
+
for (let index = 0; index < lines.length; index++) {
|
|
85
|
+
const line = lines[index];
|
|
86
|
+
const marker = line.trimStart().match(/^(```|~~~)/);
|
|
87
|
+
if (marker) {
|
|
88
|
+
if (!fence) fence = marker[1][0];
|
|
89
|
+
else if (marker[1][0] === fence) fence = '';
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (fence) continue;
|
|
93
|
+
if (!/^###\s+AC-/.test(line)) continue;
|
|
94
|
+
const match = line.match(AC_HEADING);
|
|
95
|
+
if (!match) cannotCheck('malformed AC heading at specification line ' + (index + 1));
|
|
96
|
+
ids.push(match[1]);
|
|
97
|
+
}
|
|
98
|
+
if (fence) cannotCheck('specification has an unclosed fenced code block');
|
|
99
|
+
const duplicate = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))];
|
|
100
|
+
if (duplicate.length) cannotCheck('specification has duplicate AC id: ' + duplicate.join(', '));
|
|
101
|
+
return ids;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function reportRows(lines, gaps) {
|
|
105
|
+
const section = lines.findIndex((line) => line.trim() === '## Spec conformance');
|
|
106
|
+
if (section < 0) {
|
|
107
|
+
gaps.push('GAP Spec conformance section missing');
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let header = -1;
|
|
112
|
+
for (let index = section + 1; index < lines.length; index++) {
|
|
113
|
+
if (/^##\s+/.test(lines[index])) break;
|
|
114
|
+
if (lines[index].trim() === '| Criterion | Verdict | Evidence |') {
|
|
115
|
+
header = index;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (header < 0 || !/^\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|$/.test((lines[header + 1] || '').trim())) {
|
|
120
|
+
gaps.push('GAP Spec conformance table missing or malformed');
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const rows = [];
|
|
125
|
+
for (let index = header + 2; index < lines.length; index++) {
|
|
126
|
+
const line = lines[index];
|
|
127
|
+
if (!line.trim()) break;
|
|
128
|
+
if (/^##?\s+/.test(line)) break;
|
|
129
|
+
if (!line.trimStart().startsWith('|')) break;
|
|
130
|
+
const match = line.match(/^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*$/);
|
|
131
|
+
if (!match) cannotCheck('malformed Spec conformance row at report line ' + (index + 1));
|
|
132
|
+
rows.push({ id: match[1].trim(), verdict: match[2].trim(), evidence: match[3].trim() });
|
|
133
|
+
}
|
|
134
|
+
return rows;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateReport(specification, report, slug) {
|
|
138
|
+
const ids = specificationIds(specification);
|
|
139
|
+
const idSet = new Set(ids);
|
|
140
|
+
const lines = report.toString('utf8').split(/\n/).map((line) => line.replace(/\r$/, ''));
|
|
141
|
+
const firstTwenty = lines.slice(0, 20);
|
|
142
|
+
const gaps = [];
|
|
143
|
+
|
|
144
|
+
const familyLine = firstTwenty.find((line) => /^Reviewer family:/.test(line));
|
|
145
|
+
if (familyLine === undefined) gaps.push('GAP Reviewer family line missing');
|
|
146
|
+
else {
|
|
147
|
+
const family = familyLine.replace(/^Reviewer family:\s*/, '').trim();
|
|
148
|
+
if (!FAMILIES.has(family)) gaps.push('GAP Reviewer family invalid value=' + (family || '<empty>'));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const revisionLines = firstTwenty.filter((line) => /^Spec revision:/.test(line));
|
|
152
|
+
if (revisionLines.length !== 1) {
|
|
153
|
+
cannotCheck('first 20 report lines require exactly one Spec revision line');
|
|
154
|
+
}
|
|
155
|
+
const revision = revisionLines[0].match(/^Spec revision: sha256:([a-f0-9]{64})$/);
|
|
156
|
+
if (!revision) cannotCheck('malformed Spec revision line in first 20 report lines');
|
|
157
|
+
const actual = crypto.createHash('sha256').update(specification).digest('hex');
|
|
158
|
+
if (revision[1] !== actual) {
|
|
159
|
+
gaps.push('GAP Spec revision mismatch report=' + revision[1].slice(0, 12)
|
|
160
|
+
+ ' specification=' + actual.slice(0, 12));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const rows = reportRows(lines, gaps);
|
|
164
|
+
const counts = new Map();
|
|
165
|
+
for (const row of rows) counts.set(row.id, (counts.get(row.id) || 0) + 1);
|
|
166
|
+
|
|
167
|
+
for (const id of ids) {
|
|
168
|
+
if (!counts.has(id)) gaps.push('GAP ' + id + ' has no Spec conformance row');
|
|
169
|
+
}
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
if (!idSet.has(row.id)) gaps.push('GAP ' + row.id + ' row id is not in the specification');
|
|
172
|
+
if ((counts.get(row.id) || 0) > 1 && rows.findIndex((item) => item.id === row.id) === rows.indexOf(row)) {
|
|
173
|
+
gaps.push('GAP ' + row.id + ' duplicate Spec conformance row');
|
|
174
|
+
}
|
|
175
|
+
if (!VERDICTS.has(row.verdict)) {
|
|
176
|
+
gaps.push('GAP ' + row.id + ' verdict invalid value=' + (row.verdict || '<empty>'));
|
|
177
|
+
}
|
|
178
|
+
if ((row.verdict === 'met' || row.verdict === 'not met') && !row.evidence) {
|
|
179
|
+
gaps.push('GAP ' + row.id + ' evidence empty for verdict=' + row.verdict);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (gaps.length) {
|
|
184
|
+
for (const gap of gaps) say(gap);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
say('PASS review-contract feature=' + slug + ' AC-ids=' + ids.length + ' rows=' + rows.length);
|
|
188
|
+
process.exit(0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function main() {
|
|
192
|
+
const args = process.argv.slice(2);
|
|
193
|
+
if (args.length !== 2) cannotCheck('usage: node check-review-contract.cjs <project-root> <feature-slug>');
|
|
194
|
+
const [rootInput, slug] = args;
|
|
195
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) cannotCheck('malformed feature slug: ' + slug);
|
|
196
|
+
const root = projectRoot(rootInput);
|
|
197
|
+
const base = path.join('docs', 'features', slug);
|
|
198
|
+
const specification = safeRead(root, path.join(base, '01_specification.md'), 'specification');
|
|
199
|
+
const report = safeRead(root, path.join(base, 'review-report.md'), 'review report');
|
|
200
|
+
validateReport(specification, report, slug);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try { main(); } catch (error) {
|
|
204
|
+
cannotCheck('internal error: ' + String((error && error.message) || error));
|
|
205
|
+
}
|
|
@@ -245,7 +245,7 @@ function parseExpectedToolkit() {
|
|
|
245
245
|
commandsExpected: 11,
|
|
246
246
|
agentsExpected: 4, // pre-shipped only (project agents are extra)
|
|
247
247
|
rulesExpected: 13, // pre-shipped only (project rules are extra)
|
|
248
|
-
hooksExpected:
|
|
248
|
+
hooksExpected: 25, // 4 event hooks + statusline + state-update + writer + 15 checks + 1 capture
|
|
249
249
|
};
|
|
250
250
|
}
|
|
251
251
|
|
|
@@ -215,14 +215,14 @@ are project-agnostic and can be enhanced (read by Phase 3) but never recreated.
|
|
|
215
215
|
[`incoming-webhooks`](incoming-webhooks.md), [`long-running-job`](long-running-job.md),
|
|
216
216
|
[`model-call-cost`](model-call-cost.md)
|
|
217
217
|
|
|
218
|
-
**Hooks (
|
|
218
|
+
**Hooks (25 files in `.claude/hooks/`, cross-platform Node).** Only four are wired to an
|
|
219
219
|
event in `.claude/settings.json`; the rest are utilities you invoke deliberately, and the
|
|
220
220
|
difference matters — a hook of this package is NON-BLOCKING by contract and can only print.
|
|
221
221
|
|
|
222
222
|
*Wired to an event (4):* `session-insights.cjs` (SessionStart) · `autocommit-roadmap.cjs`,
|
|
223
223
|
`autocommit-insights.cjs`, `autocommit-plans.cjs` (Stop)
|
|
224
224
|
|
|
225
|
-
*Invoked deliberately, wired to nothing (
|
|
225
|
+
*Invoked deliberately, wired to nothing (21):* `statusline.cjs` (a statusLine, not a hook) ·
|
|
226
226
|
`state-update.cjs` (argv utility) · `write-insight.cjs` (harvest carrier writer) ·
|
|
227
227
|
`check-ports.cjs` (docker-ports Правило №0, exits 0/1/2) ·
|
|
228
228
|
`check-docs-complete.cjs` (are the Phase-1 documents written, exits 0/1/2) ·
|
|
@@ -234,7 +234,8 @@ difference matters — a hook of this package is NON-BLOCKING by contract and ca
|
|
|
234
234
|
`check-webhook-contract.cjs` (is the incoming webhook signed, deduplicated by a named repeat key and
|
|
235
235
|
safe against reordering, exits 0/1/2)
|
|
236
236
|
`check-job-contract.cjs` (does long-running work have a handle, three states and a resuming retry, exits 0/1/2)
|
|
237
|
-
`check-model-cost.cjs` (does every external model call name a binding spend ceiling, exits 0/1/2)
|
|
237
|
+
`check-model-cost.cjs` (does every external model call name a binding spend ceiling, exits 0/1/2) ·
|
|
238
|
+
`check-review-contract.cjs` (does review-report.md answer every AC id and name the spec revision it judged, exits 0/1/2)
|
|
238
239
|
`check-canon.cjs` (before a WRITING fan-out: is the shared canon named and pinned, exits 0/1/2)
|
|
239
240
|
`check-file-ownership.cjs` (one writer per file, and a split-born file owned at creation, exits 0/1/2)
|
|
240
241
|
`check-source-version.cjs` (does every edit and verdict declare the source version it was built on, exits 0/1/2)
|
|
@@ -65,11 +65,14 @@ Always flag these terms and suggest specific replacements:
|
|
|
65
65
|
**Score <50 = BLOCKED from development.** Provide rewrite suggestions.
|
|
66
66
|
|
|
67
67
|
**Blocking floor — the weakest link decides, never the average.** Independently of the score, a
|
|
68
|
-
requirement is BLOCKED if `Testable = 0` (no acceptance criteria)
|
|
69
|
-
AC")
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
`
|
|
68
|
+
requirement is BLOCKED if `Testable = 0` (no acceptance criteria), `Completeness = 0` (rubric "No
|
|
69
|
+
AC"), or `Traceability = 0` (no AC has a named scenario in the Criterion scenarios table). A story can
|
|
70
|
+
total 72/100 with no acceptance criteria, and 72 reads as "minor fixes" in the table above. A
|
|
71
|
+
non-zero `Testable` or `Completeness` REQUIRES quoting the acceptance criteria it scores; a non-zero
|
|
72
|
+
`Traceability` REQUIRES quoting the Criterion scenarios table or referencing its document and heading.
|
|
73
|
+
No artifact means 0, because the agent that scores is the agent the floor binds. The floor is closed
|
|
74
|
+
at those three; `Measurable` is deliberately excluded — see `references/scoring-system.md` →
|
|
75
|
+
"Blocking floor" for the worked cases and the reason.
|
|
73
76
|
|
|
74
77
|
## Output Format
|
|
75
78
|
|
|
@@ -77,6 +80,7 @@ floor is closed at those two: `Measurable` and `Traceability` are deliberately e
|
|
|
77
80
|
|
|
78
81
|
```markdown
|
|
79
82
|
# Requirements Testability Analysis
|
|
83
|
+
Spec revision: sha256:<digest of 01_specification.md>
|
|
80
84
|
|
|
81
85
|
## Summary
|
|
82
86
|
- Stories analyzed: X
|
|
@@ -90,6 +94,11 @@ floor is closed at those two: `Measurable` and `Traceability` are deliberately e
|
|
|
90
94
|
| US-001 | ... | 92/100 | 6/6 ✓ | 5/5 ✓ | READY |
|
|
91
95
|
| US-002 | ... | 45/100 | 3/6 ✗ | 2/5 ✗ | BLOCKED |
|
|
92
96
|
|
|
97
|
+
## Criterion scenarios
|
|
98
|
+
| Criterion | Scenario |
|
|
99
|
+
|-----------|----------|
|
|
100
|
+
| AC-example-1 | Successful example flow |
|
|
101
|
+
|
|
93
102
|
## Detailed Analysis: US-002 (BLOCKED)
|
|
94
103
|
|
|
95
104
|
### INVEST Analysis
|
|
@@ -110,6 +119,10 @@ floor is closed at those two: `Measurable` and `Traceability` are deliberately e
|
|
|
110
119
|
- Add AC: "Given X, when Y, then Z within 200ms"
|
|
111
120
|
```
|
|
112
121
|
|
|
122
|
+
Compute the revision line from the specification bytes with
|
|
123
|
+
`sha256sum docs/features/<f>/01_specification.md`. The `## Criterion scenarios` table is the artifact
|
|
124
|
+
the Traceability score keys on: it maps each AC id to a named scenario.
|
|
125
|
+
|
|
113
126
|
### Security Acceptance Criteria (scoring: +5 present / -10 missing, see Scoring Bonus below)
|
|
114
127
|
|
|
115
128
|
When requirements involve authentication, data storage, external APIs, or multi-tenancy,
|
package/templates/.claude/skills/requirements-validator/references/feature-report-contracts.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Feature report contracts (`/feature`, Phases 2–4)
|
|
2
|
+
|
|
3
|
+
Byte-level formats that the packaged gates enforce. Read this when WRITING one of the three reports;
|
|
4
|
+
the gates are the machine half: `scripts/check-pipeline-gaps.sh` (`--report-revision`,
|
|
5
|
+
`--criterion-scenarios`, `--completion`) and `.claude/hooks/check-review-contract.cjs`. Exit codes
|
|
6
|
+
everywhere: `0` pass · `1` named gap(s), one line each · `2` could not establish (missing, unreadable,
|
|
7
|
+
symlinked or malformed input). Neither non-zero status is ever a warning.
|
|
8
|
+
|
|
9
|
+
## Shared keys
|
|
10
|
+
|
|
11
|
+
- **AC ids** are `### AC-<slug>-<n>` headings in `docs/features/<f>/01_specification.md`
|
|
12
|
+
(`(FR|NFR|AC)-[A-Za-z0-9]+(-[A-Za-z0-9]+)*-[0-9]+`; fenced code blocks are ignored). FR/NFR stay under
|
|
13
|
+
the Phase 1 specification↔pseudocode gate; the three contracts below key on **AC-** ids only.
|
|
14
|
+
- **Spec revision line** — `Spec revision: sha256:<64 lowercase hex>`, the SHA-256 of the BYTES of
|
|
15
|
+
`01_specification.md` (`sha256sum docs/features/<f>/01_specification.md`), within the first 20 lines
|
|
16
|
+
of `validation-report.md` and of `review-report.md`. A report is bound to the revision it judged;
|
|
17
|
+
editing the specification afterwards makes the report stale by construction.
|
|
18
|
+
|
|
19
|
+
## validation-report.md (Phase 2)
|
|
20
|
+
|
|
21
|
+
```markdown
|
|
22
|
+
# Requirements Testability Analysis
|
|
23
|
+
Spec revision: sha256:<digest of 01_specification.md>
|
|
24
|
+
|
|
25
|
+
## Criterion scenarios
|
|
26
|
+
| Criterion | Scenario |
|
|
27
|
+
|-----------|----------|
|
|
28
|
+
| AC-example-1 | Successful example flow |
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`## Criterion scenarios` maps every AC id to the named BDD scenario that covers it — the artifact the
|
|
32
|
+
`Traceability` blocking floor keys on (`references/scoring-system.md`). It is NOT the algorithm-level
|
|
33
|
+
`## Scenario Coverage` block that `sparc-prd-mini` writes into `02_pseudocode.md`. An AC id without a
|
|
34
|
+
row, an undeclared id, or an empty scenario cell is a named gap; a missing table cannot be established.
|
|
35
|
+
|
|
36
|
+
## 05_completion.md (Phase 3)
|
|
37
|
+
|
|
38
|
+
```markdown
|
|
39
|
+
## Criterion coverage
|
|
40
|
+
| Criterion | Test file | Test title |
|
|
41
|
+
|-----------|-----------|------------|
|
|
42
|
+
| AC-example-1 | tests/example.test.js | rejects an invalid request |
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
One row per AC id. `Test file` is relative to the project root and must be a regular file inside it
|
|
46
|
+
(absolute, `..`-escaping and symlinked paths are refused); `Test title` must occur verbatim in that
|
|
47
|
+
file. Both directions compare: an AC id without a row and a row whose id is not in the specification
|
|
48
|
+
are gaps. A table of names is not evidence — the gate opens the file.
|
|
49
|
+
|
|
50
|
+
## review-report.md (Phase 4)
|
|
51
|
+
|
|
52
|
+
```markdown
|
|
53
|
+
Reviewer family: claude|codex|human|unknown
|
|
54
|
+
Spec revision: sha256:<64 lowercase hex>
|
|
55
|
+
|
|
56
|
+
## Spec conformance
|
|
57
|
+
| Criterion | Verdict | Evidence |
|
|
58
|
+
|-----------|---------|----------|
|
|
59
|
+
| AC-example-1 | met | tests/example.test.js — rejects an invalid request |
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Input contract: the review receives `01_specification.md` and `validation-report.md`; a review that
|
|
63
|
+
was not given the specification is incomplete by construction and must say so instead of grading code
|
|
64
|
+
quality alone. Output contract: both header lines within the first 20 lines (`Reviewer family:` is a
|
|
65
|
+
DISCLOSURE — the package refuses silence but cannot verify which family reviewed); every AC id exactly
|
|
66
|
+
once in `## Spec conformance`; `Verdict` is exactly `met`, `not met` or `unverifiable` (lowercase);
|
|
67
|
+
a `met` or `not met` row carries non-empty evidence; a `not met` row ideally carries a reproducer.
|
|
@@ -31,7 +31,7 @@ Total Score = INVEST Score (50%) + SMART Score (30%) + Quality Score (20%)
|
|
|
31
31
|
|
|
32
32
|
| Criterion | Weight | Points | Calculation |
|
|
33
33
|
|-----------|--------|--------|-------------|
|
|
34
|
-
| Traceability | 10% | 0-10 | 10 if
|
|
34
|
+
| Traceability | 10% | 0-10 | 10 if every AC id has a named scenario in the Criterion scenarios table; 5 if some do (the uncovered AC ids MUST be listed by name); 0 if none do, or the block is absent ⇒ the floor applies |
|
|
35
35
|
| Completeness | 10% | 0-10 | See completeness rubric below |
|
|
36
36
|
|
|
37
37
|
### Completeness Rubric
|
|
@@ -56,12 +56,13 @@ Total Score = INVEST Score (50%) + SMART Score (30%) + Quality Score (20%)
|
|
|
56
56
|
### Blocking floor (overrides the total, both directions)
|
|
57
57
|
|
|
58
58
|
**The weakest link decides, never the average.** A requirement is BLOCKED — whatever its total says —
|
|
59
|
-
if
|
|
59
|
+
if any of these is zero:
|
|
60
60
|
|
|
61
61
|
| Criterion | Zero means | Why it vetoes |
|
|
62
62
|
|-----------|-----------|---------------|
|
|
63
63
|
| `Testable` (INVEST, 8) | no acceptance criteria exist | the gate exists to block untestable requirements |
|
|
64
64
|
| `Completeness` (Quality, 10) | "No AC" on the rubric above | nothing states what "done" is |
|
|
65
|
+
| `Traceability` (Quality, 10) | no AC has a named scenario in the Criterion scenarios table | a story can otherwise pass validation with no scenario or test on any criterion |
|
|
65
66
|
|
|
66
67
|
Without this floor the total alone lets an untestable requirement through. Worked case: a story with
|
|
67
68
|
NO acceptance criteria and NO test links loses `Testable` 8, `Completeness` 10 and `Traceability` 10,
|
|
@@ -76,10 +77,18 @@ criteria AND is bound by the floor, so a score is not evidence of anything — r
|
|
|
76
77
|
> the actual text, with its document and heading. `Testable = 4` means the AC exist and are vague:
|
|
77
78
|
> quote them. **No quote ⇒ the score is 0 ⇒ the floor applies.**
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
> A non-zero `Traceability` REQUIRES the Criterion scenarios table (AC id → named scenario) quoted,
|
|
81
|
+
> or referenced by document and heading. **No table ⇒ the score is 0 ⇒ the floor applies.**
|
|
82
|
+
|
|
83
|
+
Measured worked case: story FR-009 had 27 criteria with no IDs, more than 10 of them uncovered by
|
|
84
|
+
any scenario, and passed validation. Its validation report 3.1 was written against document revision
|
|
85
|
+
2; only a human noticed. With no named AC-id → scenario artifact, `Traceability = 0` and the floor
|
|
86
|
+
blocks that contour instead of letting its total hide the missing coverage.
|
|
87
|
+
|
|
88
|
+
**The floor is CLOSED at these three.** `Measurable` is deliberately NOT on it: a requirement with no
|
|
89
|
+
number is often correct ("the user can export the report as PDF" is specific, testable and
|
|
90
|
+
unmeasurable), so vetoing on `Measurable` would turn a false pass into a false block. Widening this
|
|
91
|
+
list needs the same kind of worked case as the ones above.
|
|
83
92
|
|
|
84
93
|
## Quality Gate Rules
|
|
85
94
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Assert lifecycle contract content, not a template hash: a sha256 pin detects any change but
|
|
4
|
+
// cannot distinguish a defect from an intentional edit that preserves every required gate.
|
|
5
|
+
|
|
6
|
+
const { describe, test } = require('node:test');
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
const crypto = require('node:crypto');
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { spawnSync } = require('node:child_process');
|
|
13
|
+
|
|
14
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
|
|
15
|
+
const FIXTURE = path.join(PACKAGE_ROOT, 'tests', 'fixtures', 'feature-contour');
|
|
16
|
+
const CHECKER = path.join(PACKAGE_ROOT, 'scripts', 'check-pipeline-gaps.sh');
|
|
17
|
+
const REVIEW_HOOK = path.join(
|
|
18
|
+
PACKAGE_ROOT, 'templates', '.claude', 'hooks', 'check-review-contract.cjs',
|
|
19
|
+
);
|
|
20
|
+
const FEATURE_TEMPLATE = path.join(
|
|
21
|
+
PACKAGE_ROOT, 'templates', '.claude', 'commands', 'feature.md',
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// The fixture ships only the consumer's OWN files (docs/, tests/). The two shipped templates the
|
|
25
|
+
// checker reads for its role maps are materialised from templates/ at run time, byte-identical:
|
|
26
|
+
// a stub copy inside the fixture would be a third copy of a canonical skill and trip no-skill-drift.
|
|
27
|
+
const TEMPLATE_FILES = [
|
|
28
|
+
['commands', 'feature.md'],
|
|
29
|
+
['skills', 'sparc-prd-mini', 'SKILL.md'],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function project(t) {
|
|
33
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'feature-contour-e2e-'));
|
|
34
|
+
fs.cpSync(FIXTURE, root, { recursive: true });
|
|
35
|
+
for (const parts of TEMPLATE_FILES) {
|
|
36
|
+
const target = path.join(root, '.claude', ...parts);
|
|
37
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
38
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'templates', '.claude', ...parts), target);
|
|
39
|
+
}
|
|
40
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
41
|
+
return root;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function execute(command, args, cwd) {
|
|
45
|
+
const result = spawnSync(command, args, { cwd, encoding: 'utf8', timeout: 10000 });
|
|
46
|
+
return {
|
|
47
|
+
status: result.status,
|
|
48
|
+
signal: result.signal,
|
|
49
|
+
output: `${result.stdout || ''}${result.stderr || ''}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function checker(root, flags = [
|
|
54
|
+
'--traceability', '--completion', '--report-revision', '--criterion-scenarios',
|
|
55
|
+
]) {
|
|
56
|
+
return execute('bash', [CHECKER, root, ...flags], root);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hook(root) {
|
|
60
|
+
return execute(process.execPath, [REVIEW_HOOK, root, 'demo-gate'], root);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function replace(root, relative, transform) {
|
|
64
|
+
const target = path.join(root, relative);
|
|
65
|
+
fs.writeFileSync(target, transform(fs.readFileSync(target, 'utf8')));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function phase(text, number, nextNumber) {
|
|
69
|
+
const start = text.indexOf(`### Phase ${number}:`);
|
|
70
|
+
assert.notEqual(start, -1, `Phase ${number} heading missing`);
|
|
71
|
+
const end = nextNumber === undefined ? text.length : text.indexOf(`### Phase ${nextNumber}:`, start);
|
|
72
|
+
assert.notEqual(end, -1, `Phase ${nextNumber} heading missing`);
|
|
73
|
+
return text.slice(start, end);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('feature contour consumer contract', () => {
|
|
77
|
+
test('green fixture binds specification, validation, completion, review, and executable evidence', (t) => {
|
|
78
|
+
const root = project(t);
|
|
79
|
+
const specification = fs.readFileSync(path.join(
|
|
80
|
+
root, 'docs', 'features', 'demo-gate', '01_specification.md',
|
|
81
|
+
));
|
|
82
|
+
const validation = fs.readFileSync(path.join(
|
|
83
|
+
root, 'docs', 'features', 'demo-gate', 'validation-report.md',
|
|
84
|
+
), 'utf8');
|
|
85
|
+
const expectedDigest = crypto.createHash('sha256').update(specification).digest('hex');
|
|
86
|
+
assert.match(validation, new RegExp(`^Spec revision: sha256:${expectedDigest}$`, 'm'),
|
|
87
|
+
'the checked-in fixture revision must match the specification bytes');
|
|
88
|
+
|
|
89
|
+
const gate = checker(root);
|
|
90
|
+
assert.equal(gate.status, 0, gate.output);
|
|
91
|
+
assert.match(gate.output, /VERDICT traceability=PASS/);
|
|
92
|
+
assert.match(gate.output, /VERDICT completion=PASS/);
|
|
93
|
+
assert.match(gate.output, /VERDICT report-revision=PASS/);
|
|
94
|
+
assert.match(gate.output, /VERDICT criterion-scenarios=PASS/);
|
|
95
|
+
|
|
96
|
+
const review = hook(root);
|
|
97
|
+
assert.equal(review.status, 0, review.output);
|
|
98
|
+
const fixtureTests = execute(process.execPath, ['--test', 'tests/demo.test.js'], root);
|
|
99
|
+
assert.equal(fixtureTests.status, 0, fixtureTests.output);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('deleting AC-demo-gate-2 coverage is a named completion gap', (t) => {
|
|
103
|
+
const root = project(t);
|
|
104
|
+
replace(root, 'docs/features/demo-gate/05_completion.md', (body) =>
|
|
105
|
+
body.replace(/^\| AC-demo-gate-2 .*\n/m, ''));
|
|
106
|
+
const result = checker(root, ['--completion']);
|
|
107
|
+
assert.equal(result.status, 1, result.output);
|
|
108
|
+
assert.match(result.output,
|
|
109
|
+
/GAP contour=demo-gate completion AC-demo-gate-2 has no row in Criterion coverage/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('changing specification bytes invalidates both report revision contracts', (t) => {
|
|
113
|
+
const root = project(t);
|
|
114
|
+
fs.appendFileSync(path.join(
|
|
115
|
+
root, 'docs', 'features', 'demo-gate', '01_specification.md',
|
|
116
|
+
), '\n');
|
|
117
|
+
const revision = checker(root, ['--report-revision']);
|
|
118
|
+
assert.equal(revision.status, 1, revision.output);
|
|
119
|
+
assert.match(revision.output,
|
|
120
|
+
/GAP contour=demo-gate report-revision validation-report\.md sha256:.* != specification sha256:/);
|
|
121
|
+
|
|
122
|
+
const review = hook(root);
|
|
123
|
+
assert.equal(review.status, 1, review.output);
|
|
124
|
+
assert.match(review.output, /GAP Spec revision mismatch report=.* specification=/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('removing Reviewer family is a named review-contract gap', (t) => {
|
|
128
|
+
const root = project(t);
|
|
129
|
+
replace(root, 'docs/features/demo-gate/review-report.md', (body) =>
|
|
130
|
+
body.replace(/^Reviewer family:.*\n/m, ''));
|
|
131
|
+
const review = hook(root);
|
|
132
|
+
assert.equal(review.status, 1, review.output);
|
|
133
|
+
assert.match(review.output, /GAP Reviewer family line missing/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('changing a test title breaks completion evidence by name', (t) => {
|
|
137
|
+
const root = project(t);
|
|
138
|
+
replace(root, 'tests/demo.test.js', (body) =>
|
|
139
|
+
body.replace('rejects an invalid value', 'declines an invalid value'));
|
|
140
|
+
const result = checker(root, ['--completion']);
|
|
141
|
+
assert.equal(result.status, 1, result.output);
|
|
142
|
+
assert.match(result.output,
|
|
143
|
+
/AC-demo-gate-2 test file tests\/demo\.test\.js does not contain title "rejects an invalid value"/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('deleting AC-demo-gate-1 scenario is a named traceability-floor gap', (t) => {
|
|
147
|
+
const root = project(t);
|
|
148
|
+
replace(root, 'docs/features/demo-gate/validation-report.md', (body) =>
|
|
149
|
+
body.replace(/^\| AC-demo-gate-1 .*\n/m, ''));
|
|
150
|
+
const result = checker(root, ['--criterion-scenarios']);
|
|
151
|
+
assert.equal(result.status, 1, result.output);
|
|
152
|
+
assert.match(result.output,
|
|
153
|
+
/GAP contour=demo-gate criterion-scenarios AC-demo-gate-1 has no scenario row/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('feature template carries each blocking gate in its owning phase and AUTO mode', () => {
|
|
157
|
+
const template = fs.readFileSync(FEATURE_TEMPLATE, 'utf8');
|
|
158
|
+
const phase2 = phase(template, 2, 3);
|
|
159
|
+
const phase3 = phase(template, 3, 4);
|
|
160
|
+
const phase4 = phase(template, 4);
|
|
161
|
+
assert.match(phase2, /--report-revision/);
|
|
162
|
+
assert.match(phase2, /--criterion-scenarios/);
|
|
163
|
+
assert.match(phase3, /--completion/);
|
|
164
|
+
assert.match(phase3, /## Criterion coverage/);
|
|
165
|
+
assert.match(phase4, /check-review-contract\.cjs/);
|
|
166
|
+
|
|
167
|
+
const autoStart = template.indexOf('## AUTO mode');
|
|
168
|
+
const autoEnd = template.indexOf('\n## Related', autoStart);
|
|
169
|
+
assert.notEqual(autoStart, -1, 'AUTO mode heading missing');
|
|
170
|
+
assert.notEqual(autoEnd, -1, 'AUTO mode boundary missing');
|
|
171
|
+
const auto = template.slice(autoStart, autoEnd);
|
|
172
|
+
assert.match(auto, /--report-revision/);
|
|
173
|
+
assert.match(auto, /--criterion-scenarios/);
|
|
174
|
+
assert.match(auto, /--completion/);
|
|
175
|
+
});
|
|
176
|
+
});
|