@dzhechkov/p-replicator 1.13.2 → 1.13.4

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 (36) hide show
  1. package/.dz-manifest.json +56 -24
  2. package/bin/cli.js +0 -0
  3. package/package.json +11 -10
  4. package/sbom.json +103 -23
  5. package/scripts/check-pipeline-gaps.sh +0 -0
  6. package/src/utils.js +1 -0
  7. package/templates/.claude/commands/replicate.md +9 -1
  8. package/templates/.claude/hooks/check-dangling-refs.cjs +89 -0
  9. package/templates/.claude/hooks/check-docs-complete.cjs +7 -0
  10. package/templates/.claude/hooks/check-external-deps.cjs +18 -3
  11. package/templates/.claude/hooks/statusline.cjs +1 -1
  12. package/templates/.claude/rules/cost-of-detection-ladder.md +37 -4
  13. package/templates/.claude/rules/docker-ports.md +28 -0
  14. package/templates/.claude/rules/feature-lifecycle.md +21 -0
  15. package/templates/.claude/rules/replicate-pipeline.md +3 -3
  16. package/templates/.claude/skills/brutal-honesty-review/resources/assessment-rubrics.md +12 -2
  17. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/security-patterns-library.md +45 -0
  18. package/tests/dz-availability.js +34 -0
  19. package/tests/e2e/packed-insights-writer.test.js +5 -1
  20. package/tests/npm-cli-resolver.js +366 -0
  21. package/tests/npm-resolver-cases.json +128 -0
  22. package/tests/snapshot/baseline.json +13 -12
  23. package/tests/unit/capture-source-path.test.js +73 -24
  24. package/tests/unit/check-dangling-refs.test.js +91 -0
  25. package/tests/unit/check-external-deps.test.js +26 -2
  26. package/tests/unit/detection-ladder-contract.test.js +20 -10
  27. package/tests/unit/guard-honest-input-meta.test.js +49 -0
  28. package/tests/unit/honest-failure-rules.test.js +23 -1
  29. package/tests/unit/insights-writer.test.js +5 -1
  30. package/tests/unit/negative-conclusion-gate.test.js +3 -3
  31. package/tests/unit/npm-cli-resolver.test.js +314 -0
  32. package/tests/unit/optional-doc-idiom.test.js +83 -0
  33. package/tests/unit/quote-provenance.test.js +4 -0
  34. package/tests/unit/traceability-negative-fixture.test.js +19 -5
  35. package/tests/unit/verdict-vocabulary.test.js +72 -0
  36. package/LICENSE +0 -21
@@ -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
 
@@ -139,12 +139,26 @@ function section(text) {
139
139
  * an untouched template look like a filled-in inventory, which is the same substitution as an
140
140
  * absent section, one level in.
141
141
  */
142
+ /**
143
+ * A markdown separator row: every cell is dashes with optional colons (`|---|:--:|`). The row
144
+ * directly ABOVE it is the table header — in ANY language. MEASURED 2026-09-02 (backlog b8a7669d):
145
+ * recognising the header by the English word `capability` alone turned the Russian header
146
+ * «| Возможность | Провайдер | … |» into a data row «без вердикта», and that false finding fired
147
+ * first and masked the real one. Position is the markdown fact; the word was a guess.
148
+ */
149
+ function isSeparatorRow(line) {
150
+ if (!line || !line.startsWith('|')) return false;
151
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
152
+ return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c));
153
+ }
154
+
142
155
  function rows(body) {
143
156
  const out = [];
144
157
  let templates = 0;
145
158
  let fenced = false;
146
- for (const raw of body.split('\n')) {
147
- const line = raw.trim();
159
+ const lines = body.split('\n').map((raw) => raw.trim());
160
+ for (let i = 0; i < lines.length; i += 1) {
161
+ const line = lines[i];
148
162
  if (/^(```|~~~)/.test(line)) { fenced = !fenced; continue; }
149
163
  if (fenced) continue;
150
164
  if (!line.startsWith('|')) continue;
@@ -152,7 +166,8 @@ function rows(body) {
152
166
  if (cells.length < 7) continue; // '' + 5 columns + ''
153
167
  const capability = cells[1];
154
168
  if (!capability || /^:?-+:?$/.test(capability)) continue;
155
- if (/^capability/i.test(capability)) continue; // header row
169
+ if (isSeparatorRow(lines[i + 1])) continue; // header row, by POSITION (any language)
170
+ if (/^capability/i.test(capability)) continue; // header of a table WITHOUT a separator row (belt)
156
171
  if (/^\[.*\]$/.test(capability) || capability === '...') { templates += 1; continue; }
157
172
  out.push({
158
173
  capability,
@@ -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: 25, // 4 event hooks + statusline + state-update + writer + 15 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 (25 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 (21):* `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) ·
@@ -236,7 +236,7 @@ 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
237
  `check-model-cost.cjs` (does every external model call name a binding spend ceiling, exits 0/1/2) ·
238
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` (before a WRITING fan-out: is the shared canon named and pinned, 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)
240
240
  `check-file-ownership.cjs` (one writer per file, and a split-born file owned at creation, exits 0/1/2)
241
241
  `check-source-version.cjs` (does every edit and verdict declare the source version it was built on, exits 0/1/2)
242
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
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Backlog 1b4857a6. Several cases here need a REAL `dz` on PATH — they delete or read the `.dz`
5
+ * state that only dz creates. The insight writer already reports the two ways that can go wrong
6
+ * with DIFFERENT words: `absent` is ENOENT on the spawn (no dz anywhere), `failed` is dz running
7
+ * and erroring. Conflating them is what made these cases green on a developer machine, where a
8
+ * global dz happens to be installed, and red on a CI runner, where nothing installs one:
9
+ * MEASURED 2026-09-19 (runs 35433442559, 35434913155, 35444026540) — `'absent' !== 'ok'`.
10
+ *
11
+ * So a MISSING TOOL is an audible skip and a BROKEN TOOL stays red. The skip is printed as well as
12
+ * registered, because a silent skip is how a test stops proving anything without anyone noticing —
13
+ * the same contract `announceNpmSkip` keeps in `tests/npm-cli-resolver.js`.
14
+ *
15
+ * Deliberately self-contained: `tests/` is published with this package (see `files` in
16
+ * package.json), so nothing here may reach outside it.
17
+ */
18
+
19
+ /**
20
+ * @param {{skip:(reason:string)=>void}} t — the node:test context
21
+ * @param {string} teachState — the writer's `teach.state`
22
+ * @param {string} caseNote — what this case needs dz FOR, in one clause
23
+ * @returns {boolean} true when the case must stop (dz is absent and the skip is registered)
24
+ */
25
+ function skipWhenDzAbsent(t, teachState, caseNote) {
26
+ if (teachState !== 'absent') return false;
27
+ const reason = `SKIPPED — this case needs a real \`dz\` on PATH (${caseNote}); the writer `
28
+ + 'reported teach.state=absent, which is ENOENT on the spawn — no dz was found, not dz failing.';
29
+ process.stdout.write(`${reason}\n`);
30
+ t.skip(reason);
31
+ return true;
32
+ }
33
+
34
+ module.exports = { skipWhenDzAbsent };
@@ -7,6 +7,7 @@ const crypto = require('node:crypto');
7
7
  const fs = require('node:fs');
8
8
  const os = require('node:os');
9
9
  const path = require('node:path');
10
+ const { skipWhenDzAbsent } = require('../dz-availability.js');
10
11
 
11
12
  const PKG = path.resolve(__dirname, '..', '..');
12
13
  const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
@@ -220,12 +221,15 @@ describe('PR-022 exact packed artifact', () => {
220
221
  } finally { fs.rmSync(consumer, { recursive: true, force: true }); }
221
222
  });
222
223
 
223
- test('P17 - deleting .dz retains the packed Markdown record and local delivery', () => {
224
+ test('P17 - deleting .dz retains the packed Markdown record and local delivery', (t) => {
224
225
  const { consumer } = installConsumer();
225
226
  try {
226
227
  const record = insight({ title: LOCAL_PACKED });
227
228
  const written = parseReceipt(runPackedWriter(consumer, record, { path: process.env.PATH }));
228
229
  assert.equal(written.status, 'created');
230
+ // Backlog 1b4857a6: a MISSING dz is an audible skip, a BROKEN dz stays red. The rule and
231
+ // its reasoning live once, in tests/dz-availability.js.
232
+ if (skipWhenDzAbsent(t, written.teach.state, 'it deletes the .dz state that only dz creates')) return;
229
233
  assert.equal(written.teach.state, 'ok');
230
234
 
231
235
  const query = run('dz', ['recall', '--all', '--json', '--project', consumer], {