@dzhechkov/p-replicator 1.13.1 → 1.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.dz-manifest.json +93 -25
  2. package/README.md +19 -1
  3. package/package.json +5 -4
  4. package/sbom.json +194 -24
  5. package/scripts/check-pipeline-gaps.sh +510 -21
  6. package/src/utils.js +2 -0
  7. package/templates/.claude/commands/feature.md +43 -1
  8. package/templates/.claude/commands/replicate.md +9 -1
  9. package/templates/.claude/hooks/check-dangling-refs.cjs +89 -0
  10. package/templates/.claude/hooks/check-docs-complete.cjs +7 -0
  11. package/templates/.claude/hooks/check-review-contract.cjs +205 -0
  12. package/templates/.claude/hooks/statusline.cjs +1 -1
  13. package/templates/.claude/rules/cost-of-detection-ladder.md +37 -4
  14. package/templates/.claude/rules/docker-ports.md +28 -0
  15. package/templates/.claude/rules/feature-lifecycle.md +21 -0
  16. package/templates/.claude/rules/replicate-pipeline.md +5 -4
  17. package/templates/.claude/skills/brutal-honesty-review/resources/assessment-rubrics.md +12 -2
  18. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/security-patterns-library.md +45 -0
  19. package/templates/.claude/skills/requirements-validator/SKILL.md +18 -5
  20. package/templates/.claude/skills/requirements-validator/references/feature-report-contracts.md +67 -0
  21. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +15 -6
  22. package/tests/e2e/feature-contour.test.js +176 -0
  23. package/tests/fixtures/feature-contour/docs/features/demo-gate/01_specification.md +15 -0
  24. package/tests/fixtures/feature-contour/docs/features/demo-gate/02_pseudocode.md +19 -0
  25. package/tests/fixtures/feature-contour/docs/features/demo-gate/03_architecture.md +3 -0
  26. package/tests/fixtures/feature-contour/docs/features/demo-gate/04_refinement.md +3 -0
  27. package/tests/fixtures/feature-contour/docs/features/demo-gate/05_completion.md +7 -0
  28. package/tests/fixtures/feature-contour/docs/features/demo-gate/review-report.md +11 -0
  29. package/tests/fixtures/feature-contour/docs/features/demo-gate/validation-report.md +10 -0
  30. package/tests/fixtures/feature-contour/tests/demo.test.js +16 -0
  31. package/tests/snapshot/baseline.json +17 -14
  32. package/tests/unit/capture-source-path.test.js +73 -24
  33. package/tests/unit/check-dangling-refs.test.js +91 -0
  34. package/tests/unit/check-review-contract.test.js +181 -0
  35. package/tests/unit/guard-honest-input-meta.test.js +49 -0
  36. package/tests/unit/honest-failure-rules.test.js +40 -3
  37. package/tests/unit/negative-conclusion-gate.test.js +3 -3
  38. package/tests/unit/optional-doc-idiom.test.js +83 -0
  39. package/tests/unit/quote-provenance.test.js +4 -0
  40. package/tests/unit/sync-templates-guard.test.js +46 -3
  41. package/tests/unit/traceability-completion-gate.test.js +267 -0
  42. package/tests/unit/traceability-negative-fixture.test.js +19 -5
  43. package/tests/unit/verdict-vocabulary.test.js +72 -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
- Findings classified by severity. Critical (blocker | high) MUST be fixed.
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
 
@@ -343,7 +343,15 @@ Security Pattern: # If external integrations
343
343
  server_side: "No key storage on backend"
344
344
  ```
345
345
 
346
- **Mode:** MANUAL (checkpoint at each phase inside sparc-prd-mini)
346
+ **Mode:** MANUAL checkpoint at each phase inside `sparc-prd-mini`.
347
+
348
+ > **Решение владельца 2026-09-03: MANUAL остаётся.** Ночью 2026-09-03 строка была изменена на AUTO
349
+ > как устранение противоречия: навык `sparc-prd-mini` объявляет AUTO своим режимом по умолчанию
350
+ > (`SKILL.md:35`), а эта команда навязывала MANUAL. Противоречие реально, но владелец разрешил его
351
+ > в другую сторону: остановки на фазах — не накладной расход, а точки, где он может отказаться от
352
+ > рамки до того, как она станет кодом. ИЗМЕРЕНО, чтобы цена решения была известна: остановок до
353
+ > первой строки кода тринадцать — пять в этом файле и восемь внутри навыка. Их сохраняют
354
+ > сознательно.
347
355
 
348
356
  **Output location:** `docs/` directory (NOT `/output/` — write directly into the project)
349
357
 
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * check-dangling-refs.cjs — текст ссылается на файл, которого нет?
5
+ *
6
+ * NOT an event hook. Как `check-look-trace.cjs` и соседи, лежит здесь потому, что каталог уже несёт
7
+ * простые утилиты на Node; в `settings.json` он не зарегистрирован и потому вправе отказывать
8
+ * ненулевым кодом.
9
+ *
10
+ * ─── ЗАЧЕМ ───────────────────────────────────────────────────────────────────
11
+ * Один класс дефекта ловился вручную минимум четыре раза: список хуков называл 4 из 8; таблица
12
+ * говорила «Rules 5» при шести; справка обещала 18 видов отказа при 17 в массиве; путь вывода
13
+ * называл каталог, которого не бывает. Форма всегда одна — ТЕКСТ ССЫЛАЕТСЯ НА ОБЪЕКТ, КОТОРОГО НЕТ,
14
+ * и ни одна застава об этом не спрашивала. `verify` спросить не может по построению: он обходит
15
+ * зарегистрированные КОМПОНЕНТЫ и проверяет их наличие, то есть идёт от объекта к тексту, а не от
16
+ * текста к объекту.
17
+ *
18
+ * ─── ЧТО ОН ДЕЛАЕТ, И ЧЕГО НЕ ДЕЛАЕТ ─────────────────────────────────────────
19
+ * Обходит отгружаемые `*.md`, вытаскивает ссылки вида `` `.claude/<путь>` `` на файлы с известными
20
+ * расширениями и проверяет существование цели. Он НЕ разбирает прозу и НЕ угадывает намерение:
21
+ * ссылка засчитывается, только если она в обратных кавычках и оканчивается на расширение из списка.
22
+ * Ссылка на каталог не проверяется вовсе — каталог может создаваться в работе.
23
+ *
24
+ * ─── ПОЧЕМУ БАЗА, А НЕ ПРОСТО ОТКАЗ ──────────────────────────────────────────
25
+ * ИЗМЕРЕНО 2026-09-03 на свежем дереве: 58 висячих ссылок в 23 файлах, и они не появляются после
26
+ * `init` — проверено на пустом проекте. Отказать на всех значило бы отказать каждому проекту прямо
27
+ * сейчас, то есть выключить заставу в первый же день. Поэтому база ЗАКРЕПЛЕНА числом и может только
28
+ * УМЕНЬШАТЬСЯ: новая висячая ссылка даёт отказ, а починка старой обязана уменьшить базу, иначе
29
+ * тест краснеет. Это тот же приём, которым в пакете уже ретирован `Final_Summary.md`: сообщаем,
30
+ * пока не решили, но фиксируем состояние датой и не даём ему ухудшаться.
31
+ *
32
+ * Коды: 0 — не хуже базы · 1 — база превышена (названы новые) · 2 — не удалось установить.
33
+ */
34
+
35
+ const fs = require('node:fs');
36
+ const path = require('node:path');
37
+
38
+ /** ИЗМЕРЕНО 2026-09-03 на снимке дерева; может только уменьшаться. */
39
+ const BASELINE = 58;
40
+
41
+ const REF = /`(\.claude\/[A-Za-z0-9_\-./]+\.(?:cjs|mjs|js|sh|md|json|yaml|yml))`/g;
42
+
43
+ function walk(dir) {
44
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
45
+ const p = path.join(dir, e.name);
46
+ return e.isDirectory() ? walk(p) : [p];
47
+ });
48
+ }
49
+
50
+ /** Pure half: given a root holding `.claude/`, return every dangling reference found. */
51
+ function danglingRefs(root) {
52
+ const base = path.join(root, '.claude');
53
+ if (!fs.existsSync(base)) return null; // не установлено — код 2, а не «чисто»
54
+ const out = [];
55
+ for (const file of walk(base)) {
56
+ if (!file.endsWith('.md')) continue;
57
+ const src = fs.readFileSync(file, 'utf8');
58
+ let m;
59
+ while ((m = REF.exec(src))) {
60
+ const target = path.join(root, m[1]);
61
+ if (!fs.existsSync(target)) out.push({ from: path.relative(root, file), to: m[1] });
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function main(argv) {
68
+ const root = argv[2] || process.cwd();
69
+ const found = danglingRefs(root);
70
+ if (found === null) {
71
+ console.error(`НЕ УСТАНОВЛЕНО: каталог .claude не найден в ${root} — проверка не выполнялась, и это не «чисто»`);
72
+ return 2;
73
+ }
74
+ const unique = [...new Set(found.map((f) => `${f.from} → ${f.to}`))];
75
+ if (unique.length > BASELINE) {
76
+ console.error(`❌ висячих ссылок ${unique.length} при базе ${BASELINE} — текст обещает файлы, которых нет:`);
77
+ unique.slice(0, 20).forEach((u) => console.error(` ${u}`));
78
+ if (unique.length > 20) console.error(` … и ещё ${unique.length - 20}`);
79
+ return 1;
80
+ }
81
+ console.log(`✅ висячих ссылок ${unique.length}, база ${BASELINE} — не хуже. Проверено файлов: ${walk(path.join(root, '.claude')).filter((f) => f.endsWith('.md')).length}`);
82
+ if (unique.length < BASELINE) {
83
+ console.log(` База устарела в лучшую сторону: опустите BASELINE до ${unique.length}, чтобы достижение закрепилось.`);
84
+ }
85
+ return 0;
86
+ }
87
+
88
+ module.exports = { danglingRefs, BASELINE, REF };
89
+ if (require.main === module) process.exit(main(process.argv));
@@ -43,7 +43,14 @@ const DOCS = [
43
43
  // practice — and blocking on it would have refused every project that ran like that one.
44
44
  // The discrepancy is filed; until it is settled this reports rather than refuses.
45
45
  { file: 'Final_Summary.md', optional: true, expected: true },
46
+ // Both entries below were made optional with no recorded reason — the gap the dated-receipt
47
+ // guard (`tests/unit/optional-doc-idiom.test.js`) found. What would SETTLE it is unmeasured:
48
+ // whether real projects produce them. So `expected` is deliberately NOT set on either — calling
49
+ // them expected asserts something nobody measured, calling them dispensable retires a promise
50
+ // silently. The receipts record the state; they do not resolve it.
51
+ // MEASURED 2026-09-03: promised twice by `commands/replicate.md`, required by nothing here.
46
52
  { file: 'C4_Diagrams.md', optional: true },
53
+ // MEASURED 2026-09-03: promised SEVEN times by `commands/replicate.md`, required by nothing here.
47
54
  { file: 'ADR.md', optional: true },
48
55
  ];
49
56
 
@@ -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: 24, // 4 event hooks + statusline + state-update + writer + 14 checks + 1 capture
248
+ hooksExpected: 26, // 4 event hooks + statusline + state-update + writer + 15 checks + 1 capture
249
249
  };
250
250
  }
251
251
 
@@ -62,9 +62,9 @@ directly—for example, a static grep cannot establish runtime resilience.
62
62
  Every safeguard must connect the reason for the property to an observable signal, a recurring trigger,
63
63
  and a response. Record it with this shape:
64
64
 
65
- | Cause / property | Observable signal | Check kind | Layer | Trigger / cadence | Reaction | Owner |
66
- |---|---|---|---|---|---|---|
67
- | Why the constraint exists | What changes when it is violated | How it is observed | Where it is enforced | When it runs | What happens on failure | Who acts |
65
+ | Cause / property | Observable signal | Check kind | Layer | Scope — what it does NOT cover | Trigger / cadence | Reaction | Owner |
66
+ |---|---|---|---|---|---|---|---|
67
+ | Why the constraint exists | What changes when it is violated | How it is observed | Where it is enforced | The boundary, stated so nobody has to infer it | When it runs | What happens on failure | | Who acts |
68
68
 
69
69
  **Reaction must name a concrete action.** Valid reactions include: block or return the change, repair
70
70
  the practice or implementation, escalate to the named owner, or revisit the decision explicitly.
@@ -77,7 +77,12 @@ A blank cell, “note the warning,” or “the reviewer decides” does not clo
77
77
  3. Select a check kind that can observe that signal.
78
78
  4. Place the check on the strongest layer that can express it reliably.
79
79
  5. Define its trigger or cadence, concrete Reaction, and owner.
80
- 6. Test that the safeguard fires on a deliberately bad input before trusting the happy path.
80
+ 6. Test that the safeguard FIRES on a deliberately bad input and, in the same breath, that it
81
+ stays CLEAN on a correct and COMPLETE one. Both halves or neither: a guard proven only to fire is
82
+ indistinguishable from one that fires at everything, and a guard proven only to pass is
83
+ indistinguishable from one that cannot see. The two cases are cheap together and worthless apart.
84
+ 7. State the check's SCOPE — what it does not look at. Every guard has a boundary, and a boundary
85
+ left unsaid is read by the next person as "covers everything".
81
86
 
82
87
  ## Anti-pattern: “the critic/reviewer will catch it”
83
88
 
@@ -94,3 +99,31 @@ may complement the check for semantics; it must not carry a deterministic invari
94
99
  The same property written only as “remember to include the file” would sit on Layer 5 and could fail
95
100
  silently. The artifact test observes the real distribution boundary and defines what happens when it
96
101
  breaks.
102
+
103
+ ---
104
+
105
+ ## A guard that cannot fail is not on Layer 1 — it is decoration
106
+
107
+ A check earns its layer by what it does on BAD input, not by existing. A guard nobody ever saw refuse
108
+ is indistinguishable from a guard that cannot refuse, and the two are told apart by one act: feed it
109
+ the thing it exists to catch and watch it go red.
110
+
111
+ **The rule.** Every guard you add ships with a case that makes it FAIL. Not a case that exercises it —
112
+ a case that would pass if the guard were deleted, and fails because it is there.
113
+
114
+ Three ways a guard silently cannot fail, all of them observed:
115
+
116
+ | How it dies | What it looks like | How to tell |
117
+ |---|---|---|
118
+ | Its predicate matches nothing | green forever, no findings ever | inject the exact shape it names; it must go red |
119
+ | It is never invoked | green because it never ran | grep the pipeline for its invocation and for consumption of its exit code |
120
+ | It refuses on absence | red always, for a reason unrelated to the defect | run it on known-good input; it must go green |
121
+
122
+ **The honest half of this rule.** No mechanism in this package enforces it. There is no meta-test
123
+ walking every guard and demanding a failing case beside it. Until such a test exists, this rule lives
124
+ on **Layer 4** — it works when someone reads it and applies it, and lapses silently when nobody does.
125
+ Writing it here does not make it Layer 1; only the meta-test would.
126
+
127
+ That admission is the point. A rule that overstates its own layer is exactly the defect it warns
128
+ about: a Layer-4 habit wearing Layer-1 clothes, which is the worst outcome the ladder describes,
129
+ because it buys the confidence of a deterministic check while keeping the reliability of a reminder.
@@ -123,6 +123,34 @@ bindings/Compose-метки и только там проверяет runtime-а
123
123
  **Чего она НЕ делает.** Она не привязана ни к какому событию — её надо позвать. Не выводите из
124
124
  присутствия правила или последней зелёной квитанции, что кто-то продолжает смотреть за машиной.
125
125
 
126
+ ## Тестовый compose: две вещи, которые ломают не тест, а прод
127
+
128
+ Обе — обобщённые из отклонённой заявки на `compose.test.yml`; правила ниже generic, самого файла в
129
+ пакете нет и не будет.
130
+
131
+ **1. Пароли тестового стека задаются через `${VAR:?}` — без значения по умолчанию.**
132
+
133
+ ```yaml
134
+ environment:
135
+ POSTGRES_PASSWORD: ${TEST_DB_PASSWORD:?переменная обязана быть задана}
136
+ ```
137
+
138
+ Запись `${VAR:-по-умолчанию}` кажется удобной и делает ровно одну вещь: превращает забытую
139
+ переменную в ТИХО РАБОТАЮЩИЙ стек с известным паролем. Форма `:?` останавливает запуск с
140
+ названной причиной, то есть переводит отказ из молчаливого в громкий. Пароль по умолчанию в
141
+ тестовом стеке — это пароль по умолчанию, который однажды переедет в прод вместе с файлом.
142
+
143
+ **2. Тестовый compose обязан задавать своё `name:`.**
144
+
145
+ ```yaml
146
+ name: myproject-test
147
+ ```
148
+
149
+ Без него docker выводит имя проекта из ИМЕНИ КАТАЛОГА, поэтому тестовый и рабочий стек в одном
150
+ каталоге получают одно имя. Практическое следствие: `docker compose -f compose.test.yml up`
151
+ останавливает и пересоздаёт контейнеры рабочего стека, потому что для docker это тот же проект.
152
+ Отказ здесь не в тесте — он в том, что тест сносит то, что тестом не является.
153
+
126
154
  ## Быстрая самопроверка
127
155
 
128
156
  ```bash
@@ -96,6 +96,27 @@ After 3 retries with 🔴, halt and surface to user.
96
96
 
97
97
  **Quality gate:** tests pass, lint clean, build succeeds.
98
98
 
99
+ ### Comment density — match the host project, not this toolkit
100
+
101
+ **Правило одной строкой: комментарий пишется только для ограничения, которое КОД НЕ МОЖЕТ ПОКАЗАТЬ
102
+ САМ, а плотность комментариев берётся от целевого репозитория, а не от этого тулкита.**
103
+
104
+ Что считается таким ограничением: измеренная причина («порог 2, потому что при 1 счётчик указывает
105
+ на одну запись»), внешний контракт, который нельзя вывести из кода, ссылка на инцидент, объясняющая
106
+ неочевидную защиту. Что им НЕ является: пересказ того, что делает следующая строка; заголовок
107
+ раздела; преамбула о замысле функции, чьё имя уже это говорит.
108
+
109
+ **Почему это правило здесь.** [FIELD, 2026-08-30] Коллега владельца о сгенерированном коде:
110
+ «пишется очень много документации, в коде в основном одни комментарии, это переполняет контекст».
111
+ ИЗМЕРЕНО 2026-09-03 на отгружаемых шаблонах этого пакета: проза составляет 70,3% их объёма
112
+ (1 105 072 байта разметки против 466 914 байт кода), а внутри самого кода комментарии занимают
113
+ 33,6% (156 660 байт). Это стиль ЭТОГО репозитория, и в нём он оправдан: код тут читают агенты,
114
+ которым неоткуда узнать историю. В чужом проекте он и чужая конвенция, и налог на контекст каждого
115
+ будущего чтения.
116
+
117
+ **Практическая проверка перед тем, как оставить комментарий:** удалите его мысленно и спросите,
118
+ теряется ли при этом факт, который нельзя восстановить из кода. Нет — удаляйте по-настоящему.
119
+
99
120
  ### Positive file receipt (required)
100
121
 
101
122
  Each unit gets a unique `WORK_UNIT_ID` and unique absolute `TRACE_PATH`. Its worker MUST write a
@@ -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 (24 files in `.claude/hooks/`, cross-platform Node).** Only four are wired to an
218
+ **Hooks (26 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 (20):* `statusline.cjs` (a statusLine, not a hook) ·
225
+ *Invoked deliberately, wired to nothing (22):* `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,8 +234,9 @@ 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)
238
- `check-canon.cjs` (before a WRITING fan-out: is the shared canon named and pinned, 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)
239
+ `check-canon.cjs` · `check-dangling-refs.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)
241
242
  `check-handoff-manifest.cjs` (did every enumerated Phase-0 output get an answer from Phase 1, exits 0/1/2)
@@ -21,12 +21,22 @@
21
21
  |-------|----------|---------|
22
22
  | 🔴 **Failing** | Crashes on invalid input | Uncaught exceptions, panics |
23
23
  | 🟡 **Passing** | Returns error codes/exceptions | `try/catch`, error returns |
24
- | 🟢 **Excellent** | Graceful degradation + logging | Circuit breakers, retry logic |
24
+ | 🟢 **Excellent** | Graceful degradation + logging — **но НЕ для значения, которое продукт ОТДАЁТ наружу** | Circuit breakers, retry logic |
25
+
26
+ > **Оговорка к высшей оценке, и она несущая.** Мягкая деградация заслуживает 🟢 для НЕДОСТУПНОСТИ
27
+ > (сервис не ответил — вернём кэш, попробуем позже) и заслуживает 🔴 для ЗНАЧЕНИЯ, которое уходит
28
+ > потребителю (цена, остаток, право доступа, результат расчёта). Подставить приблизительное вместо
29
+ > точного и записать это в журнал — значит выдать неверный ответ и назвать это устойчивостью.
30
+ > Правильный исход для значения — ОТКАЗ С НАЗВАННОЙ ПРИЧИНОЙ, а не правдоподобная замена.
31
+ >
32
+ > Оговорка появилась потому, что прежняя формулировка прямо ПООЩРЯЛА механизм, который в разборе
33
+ > реальных отказов назван причиной каждого происшествия высшей категории: подстановка запасного
34
+ > значения там, где честный ответ — «не знаю».
25
35
 
26
36
  ### Concurrency Safety
27
37
  | Level | Criteria | Example |
28
38
  |-------|----------|---------|
29
- | 🔴 **Failing** | Race conditions, deadlocks | Shared mutable state, no locks |
39
+ | 🔴 **Failing** | Race conditions, deadlocks; **а также: последовательный тест, поданный как доказательство параллельной безопасности** | Shared mutable state, no locks; «тест проходит» при одном писателе |
30
40
  | 🟡 **Passing** | Thread-safe with locks | Proper mutex usage |
31
41
  | 🟢 **Excellent** | Lock-free or proven safe | Immutable data, atomic operations |
32
42
 
@@ -49,6 +49,28 @@ ALWAYS use parameterized set_config():
49
49
  `SET LOCAL key = '${value}'`. Applies to all session-level config.
50
50
  ```
51
51
 
52
+ **Шесть строк к S-01 и S-02, каждая ловит отдельный способ обойти изоляцию арендаторов:**
53
+
54
+ ```
55
+ RULE: BYPASSRLS on the service role is NOT a safety net — it is the removal of one.
56
+ Under it a FORGOTTEN tenant filter returns other tenants' rows SILENTLY,
57
+ with no error to notice. Reserve it for migrations, never for request paths.
58
+ RULE: SET LOCAL ROLE, never SET ROLE. Plain SET ROLE outlives the transaction and
59
+ leaks into whatever the pooled connection serves next.
60
+ RULE: current_setting('app.tenant_id', true) — the second argument makes a missing
61
+ setting return NULL instead of raising. Without it an unset tenant is an
62
+ exception you will catch and swallow; with it, it is a value you can test for.
63
+ RULE: Tests MUST NOT run as a superuser. A superuser bypasses RLS unconditionally,
64
+ so every policy test passes and proves nothing.
65
+ RULE: A table carrying a policy but no CROSS-TENANT test counts as UNPROTECTED.
66
+ The policy is a claim; the test is the evidence.
67
+ ```
68
+
69
+ **Почему они здесь, а не отдельным разделом.** Каждая — способ, которым изоляция ЕСТЬ в коде и
70
+ НЕ РАБОТАЕТ на прогоне. Все пять отказывают молча: под `BYPASSRLS` нет ошибки, у суперпользователя
71
+ нет ошибки, у утёкшей роли нет ошибки. Молчаливый отказ защиты — единственный вид, который доживает
72
+ до продакшена, потому что громкий чинят в первый же день.
73
+
52
74
  ---
53
75
 
54
76
  ### S-03: Fail-Fast Secret Validation at Startup
@@ -159,6 +181,29 @@ NEVER: expose stack traces in production error responses
159
181
 
160
182
  ---
161
183
 
184
+ ### S-08: Image Type Comes From CONTENT, Never From `Content-Type`
185
+
186
+ **Pattern:**
187
+ ```
188
+ RULE: An uploaded image's type is decided by INSPECTING ITS BYTES, never by the
189
+ Content-Type header or the file extension — both are attacker-supplied.
190
+ RULE: SVG is REJECTED WHOLESALE for user uploads. It is a script-bearing document
191
+ that happens to render as a picture; sanitising it is a losing arms race.
192
+ RULE: Serve user-supplied files with `X-Content-Type-Options: nosniff`, so a browser
193
+ cannot re-decide the type you already decided.
194
+
195
+ NEVER: trust `req.file.mimetype` as the type
196
+ NEVER: allow `image/svg+xml` through an "allowed image types" list
197
+ NEVER: serve uploads from the same origin as the application without nosniff
198
+ ```
199
+
200
+ **Why all three, and not just the first.** Sniffing the bytes stops a `.png` that is really a
201
+ script. It does NOT stop SVG, because an SVG genuinely IS an image by content and genuinely CAN
202
+ carry script. And neither stops a browser that ignores your decision and sniffs for itself — that
203
+ is what the header is for. Drop any one of the three and the other two leave a path open.
204
+
205
+ ---
206
+
162
207
  ## Integration Patterns for Generated Toolkit
163
208
 
164
209
  ### How Patterns Map to Generated Files