@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.
- package/.dz-manifest.json +93 -25
- package/README.md +19 -1
- package/package.json +5 -4
- package/sbom.json +194 -24
- package/scripts/check-pipeline-gaps.sh +510 -21
- package/src/utils.js +2 -0
- package/templates/.claude/commands/feature.md +43 -1
- package/templates/.claude/commands/replicate.md +9 -1
- package/templates/.claude/hooks/check-dangling-refs.cjs +89 -0
- package/templates/.claude/hooks/check-docs-complete.cjs +7 -0
- package/templates/.claude/hooks/check-review-contract.cjs +205 -0
- package/templates/.claude/hooks/statusline.cjs +1 -1
- package/templates/.claude/rules/cost-of-detection-ladder.md +37 -4
- package/templates/.claude/rules/docker-ports.md +28 -0
- package/templates/.claude/rules/feature-lifecycle.md +21 -0
- package/templates/.claude/rules/replicate-pipeline.md +5 -4
- package/templates/.claude/skills/brutal-honesty-review/resources/assessment-rubrics.md +12 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/security-patterns-library.md +45 -0
- 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 +17 -14
- package/tests/unit/capture-source-path.test.js +73 -24
- package/tests/unit/check-dangling-refs.test.js +91 -0
- package/tests/unit/check-review-contract.test.js +181 -0
- package/tests/unit/guard-honest-input-meta.test.js +49 -0
- package/tests/unit/honest-failure-rules.test.js +40 -3
- package/tests/unit/negative-conclusion-gate.test.js +3 -3
- package/tests/unit/optional-doc-idiom.test.js +83 -0
- package/tests/unit/quote-provenance.test.js +4 -0
- package/tests/unit/sync-templates-guard.test.js +46 -3
- package/tests/unit/traceability-completion-gate.test.js +267 -0
- package/tests/unit/traceability-negative-fixture.test.js +19 -5
- 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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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 (
|
|
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 (
|
|
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-
|
|
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
|