@dzhechkov/p-replicator 1.13.2 → 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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
- "generatedAt": "2026-09-02T07:49:08.074Z",
3
- "totalFiles": 166,
2
+ "generatedAt": "2026-09-03T05:28:07.394Z",
3
+ "totalFiles": 167,
4
4
  "files": {
5
5
  ".claude/agents/doc-validator.md": "e57950758cd4764b2cb54b3f1905567e9dbc1ed20ac6ecd30998614894689f4f",
6
6
  ".claude/agents/harvest-coordinator.md": "71e524e8379df46bbf457dbc1d4480634e05c99b7d3a4f59a356b8d71a0a4166",
@@ -14,7 +14,7 @@
14
14
  ".claude/commands/myinsights.md": "f31c5e133b5027197359e4179cbba1ab535f990634a69ea7c5c73bd2bc329d68",
15
15
  ".claude/commands/next.md": "1e4984306c3aa80dfc8734dc1c975dfe5fdf2cf6396ddd759e4c7635646107b8",
16
16
  ".claude/commands/plan.md": "6fef889f51168981bf4a98b8392a0d595e3d199e1cfaa8e08c0a181ec6c440e4",
17
- ".claude/commands/replicate.md": "165d7b3c0606be6817893b53cbfc5ca55c7b81cbe35cbdd0dccf8679d702e81a",
17
+ ".claude/commands/replicate.md": "f176d8ec871459b5a8d2111b8527331f34340d6e169da8cc7792953e3e334774",
18
18
  ".claude/commands/run.md": "87144dfe8c56c15ba7c117daa544d8a08b84d7205f21dafcc48d27c6c3995059",
19
19
  ".claude/commands/start.md": "fb8a7762f4073b9e8689dec3830680907efb9ec68bb2faa39b8a547793d9a078",
20
20
  ".claude/hooks/autocommit-insights.cjs": "5e7cef87b24d457f25315d79fe15f0dfaf94d9269495d638817f0faee2593c6f",
@@ -22,7 +22,8 @@
22
22
  ".claude/hooks/autocommit-roadmap.cjs": "f4dcc9ffd9a3aed91dd56e011e699fcf8af903cc81594e4ab1ef61e5432a36ad",
23
23
  ".claude/hooks/capture-source-path.cjs": "bed92548c3313d69110b6cc06e6b2b858d9d548b8c18c82750ae90d514212bf2",
24
24
  ".claude/hooks/check-canon.cjs": "99e6bff9b0078eb2ec23f7df98b7ac06c894f9018eba4a8c358e8f92e5ec7b06",
25
- ".claude/hooks/check-docs-complete.cjs": "27c05c2adcedc806e395b30b455243193a4fbd0968ce96e415679e1f78db951a",
25
+ ".claude/hooks/check-dangling-refs.cjs": "8f012876b1af110a1093319d36a2f84208a15780dbb8abb5a43eb4aeb8f76865",
26
+ ".claude/hooks/check-docs-complete.cjs": "0f516df0882c9da53d166a1b4e30ff1fc37bd5a2027c7e5fbdab6cba116ca828",
26
27
  ".claude/hooks/check-embed-contract.cjs": "9e963a07c12350b760264133021e75fb699439b1f060332e1ccdd962b645b4a0",
27
28
  ".claude/hooks/check-external-deps.cjs": "7bce49a744170a3193edf4b2238bcc5a6f1b0efa29333760a52e4fd145739d18",
28
29
  ".claude/hooks/check-file-ownership.cjs": "c6273e7b0817d15e375586131924a08095722aff7d0e6a6a04c857a480788e1f",
@@ -40,25 +41,25 @@
40
41
  ".claude/hooks/check-webhook-contract.cjs": "dfa27bd24cee8868fd60be5d24749c206f303fe4afcc1e72024d526324562a39",
41
42
  ".claude/hooks/session-insights.cjs": "31fbe90d81b01dfb56557ed5d68dbde2211a14d5469c5d3761f45fb63c07c3a7",
42
43
  ".claude/hooks/state-update.cjs": "47146a76b768ac2273abe8f2f017856a0e28b45afaf90048148da417dd19604e",
43
- ".claude/hooks/statusline.cjs": "50c08decec3e80ed325f50d7b062fbe0add47e8594002fcc165248492242ab56",
44
+ ".claude/hooks/statusline.cjs": "bac385da01dd9f1548d0db48a2acdada5e89a7822a7f0ecfb61cb74ff4cda138",
44
45
  ".claude/hooks/write-insight.cjs": "743f2d85b037cf522dfaf4775947b0cd331e52f2f304e88360c160e0e8e9a589",
45
- ".claude/rules/cost-of-detection-ladder.md": "f15309ee8f1494478ff7678380dbe8fc8eecd154be36d57c9090cd17126a07da",
46
- ".claude/rules/docker-ports.md": "59eb430f29dd762e8b76ad6d1177fe8b5b250de007856c743387cb88ee157942",
46
+ ".claude/rules/cost-of-detection-ladder.md": "d2e7da3d56c8d4c6d1aa58e793fa0d5eecd65a5c01be38dfa0dc434273306838",
47
+ ".claude/rules/docker-ports.md": "a9e5bb9a797ea4ef4c0671cee2cbec95f2557f8b9e1d20b84bbcee7ce4c21162",
47
48
  ".claude/rules/embeddable-widget.md": "0159224f8e9599b67a860738bb42e3e40adf598176c48ea6c86a01c891f0f18c",
48
- ".claude/rules/feature-lifecycle.md": "4e926f84f244feb6824114a0624733738669c93e0508693fa2afa4e9a35c2aa3",
49
+ ".claude/rules/feature-lifecycle.md": "a7d0adf6bc63b942d3dec339b3ae68972c3dedc0b48a91d27653309c7da7f11f",
49
50
  ".claude/rules/git-workflow.md": "7d8d2ded47fcc0fb95da3dc226efa6cb3c80d6adf7296977588c181dfce20192",
50
51
  ".claude/rules/honest-configuration.md": "851bc28e9e2baf1043bc09b982f3df3431bc7af1e076f8ff9df3d6729835bad5",
51
52
  ".claude/rules/incoming-webhooks.md": "92f3de02814ed0610df644acf554f4b41b2bae2f9d281695e2e217a1d6558e84",
52
53
  ".claude/rules/insights-capture.md": "d529e6be79178636ca362d83366b1dd7a843371e8ba4f2b09d65c009fab52dc4",
53
54
  ".claude/rules/long-running-job.md": "e6bd0efff89338998d34440a94dc9288cf7bcf790b27bcaa51b83d43f57942ed",
54
55
  ".claude/rules/model-call-cost.md": "26c3b20ac3ed7f9cf6deb5e94302ade87f9e3e51fffca7ac2377ff31bc144f34",
55
- ".claude/rules/replicate-pipeline.md": "13bf073273820addeb87167402495b2f683cefac70a9f6cb07b6e054b1851e0f",
56
+ ".claude/rules/replicate-pipeline.md": "9c4bbaa9ac628ec5d4e44d3ace6eec0b5bd602b5dccc5d36f123d73be448f159",
56
57
  ".claude/rules/skill-interface-protocol.md": "c3f624d0a8a4c7e0f5744e529eb4486cc0589fdb732b9799bccc75091a6d63ca",
57
58
  ".claude/rules/swarm-file-evidence.md": "c71a216dbc3fd633c30949823ceac354f8439b96279a1a8dd606afb1b3ef5c45",
58
59
  ".claude/settings.json": "18db09827dce410667b93e2b1a41ef8e46d4431114733e4304a5fc2192b2fc66",
59
60
  ".claude/skills/brutal-honesty-review/evals/brutal-honesty-review.yaml": "855c155d55575e12aab23fb3a5fb2fada74d93974ff755dfedac6f2d5bd41f13",
60
61
  ".claude/skills/brutal-honesty-review/README.md": "2a026b4af50b21456e50424fbfa089cb983fbbaa0c68024c8c17c695bea25a57",
61
- ".claude/skills/brutal-honesty-review/resources/assessment-rubrics.md": "dac09c91c765e2211799cc230738fb72a966f47511edb872306a906e49640cbe",
62
+ ".claude/skills/brutal-honesty-review/resources/assessment-rubrics.md": "b64894e1ddf8b9da7f2f146c737d35215e7281e3d8be10891d4a9b8aa2bd6d07",
62
63
  ".claude/skills/brutal-honesty-review/resources/review-template.md": "95182c8605b1d821360257e1c5dbac4300bda333d861785de417cbb84090b25b",
63
64
  ".claude/skills/brutal-honesty-review/schemas/output.json": "2e252a3497b86322e1a6e6bd1aca8708b44389db3a2d81a6f893d467b6351210",
64
65
  ".claude/skills/brutal-honesty-review/scripts/assess-code.sh": "2b10d2972b1379adde9a68ddf8bcdb399b7d65075e458a54523ffd8d99ddc746",
@@ -79,7 +80,7 @@
79
80
  ".claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md": "657e35db0cb77d3a9d1a4d01215a9b8d50441dd863593bee3a84f62aad8b00bc",
80
81
  ".claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md": "5e87c91956eabc70b4a1084b47b2c9a20ede18bb245735812ead956eafabf3af",
81
82
  ".claude/skills/cc-toolkit-generator-enhanced/references/maturity-integration.md": "aa4e743bcd0281525336a298d0489c31481f69ff1a534e1ed1d262d888937deb",
82
- ".claude/skills/cc-toolkit-generator-enhanced/references/security-patterns-library.md": "5f1d650c426cbd2c9536b385306605ea62a5a520ee4566d23ca7254409c672a5",
83
+ ".claude/skills/cc-toolkit-generator-enhanced/references/security-patterns-library.md": "4a7fae9ea1870c30b71c923fd5d3626a8926c5e960209fad709f4b8e0534fc80",
83
84
  ".claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md": "9ce3db4c3c5b9696a312152067c1869b1f421ce77fbcfdca83edaa797c5caf3f",
84
85
  ".claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md": "c91f81da197a072b57378fd0018eea26c35d909a558bdca6f070eb60153c97b3",
85
86
  ".claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-hooks-commands.md": "8398f2493434db101476300e514a34bf8124a51eb732cf81923f9a5b5a3d5db4",
@@ -104,19 +104,55 @@ function site({ robots = null, forbid = [] } = {}) {
104
104
  if (!body) { res.writeHead(404); return res.end('nf'); }
105
105
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(body);
106
106
  });
107
- return { srv, hits, listen: () => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port))) };
107
+ // `close()` НЕ закрывает сервер, пока жив хоть один keep-alive сокет: он ждёт их естественной
108
+ // смерти. ИЗМЕРЕНО 2026-09-03 — тест P20 после этого не завершался вовсе, и полоса вместе с ним
109
+ // висела дольше десятиминутного потолка вызова. `closeAllConnections()` рвёт живые сокеты, то
110
+ // есть закрывает сервер ДЕТЕРМИНИРОВАННО, а не при удаче. `shutdown()` возвращает обещание,
111
+ // поэтому вызывающий может дождаться закрытия вместо того, чтобы надеяться на него.
112
+ const shutdown = () => new Promise((r) => {
113
+ if (typeof srv.closeAllConnections === 'function') srv.closeAllConnections();
114
+ srv.close(() => r());
115
+ });
116
+ return {
117
+ srv, hits, shutdown,
118
+ listen: () => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port))),
119
+ };
108
120
  }
109
121
 
110
122
  /** АСИНХРОННЫЙ запуск — см. измерение в шапке файла. */
123
+ /**
124
+ * Потолок времени на один запуск инструмента.
125
+ *
126
+ * ИЗМЕРЕНО 2026-09-03: без него полоса `npm run test:browser` не завершалась ВООБЩЕ — убита по
127
+ * внешнему таймауту дважды, на 400 с и на 560 с. Ждущий вечно тест не отличим от идущего долго, и
128
+ * снаружи оба выглядят как «ещё работает». Потолок превращает зависание в ИМЕНОВАННЫЙ отказ, то
129
+ * есть в результат, который можно прочитать.
130
+ *
131
+ * Значение выбрано не на глаз: браузерные сценарии этого файла на живой машине укладываются в
132
+ * единицы секунд, а сценарии ОТСУТСТВИЯ браузера обязаны отвечать почти мгновенно — им нечего
133
+ * запускать. 20 секунд оставляют десятикратный запас медленному сценарию и всё равно ловят
134
+ * зависание за время, которое человек готов ждать.
135
+ */
136
+ const RUN_TIMEOUT_MS = 20_000;
137
+
111
138
  function run(args, env) {
112
139
  return new Promise((resolve) => {
113
140
  const child = spawn(process.execPath, [TOOL, ...args], {
114
141
  env: { ...process.env, ...(PW ? { PLAYWRIGHT_MODULE: PW } : {}), ...env },
115
142
  });
116
143
  let out = '';
144
+ let settled = false;
145
+ const finish = (code) => { if (settled) return; settled = true; clearTimeout(timer); resolve({ code, out }); };
146
+ const timer = setTimeout(() => {
147
+ out += `\n[ТАЙМАУТ ТЕСТА] инструмент не завершился за ${RUN_TIMEOUT_MS} мс и был убит; ` +
148
+ 'это НЕ «не удалось проверить», а именованный отказ: зависание есть дефект.\n';
149
+ child.kill('SIGKILL');
150
+ finish('timeout');
151
+ }, RUN_TIMEOUT_MS);
117
152
  child.stdout.on('data', (d) => { out += d; });
118
153
  child.stderr.on('data', (d) => { out += d; });
119
- child.on('close', (code) => resolve({ code, out }));
154
+ child.on('close', (code) => finish(code));
155
+ child.on('error', (e) => { out += String(e && e.message); finish('spawn-error'); });
120
156
  });
121
157
  }
122
158
 
@@ -340,7 +376,7 @@ describe('ось «путь»: детерминированная половин
340
376
 
341
377
  describe('ось «путь»: живые прогоны браузером по локальной оснастке', { skip: LIVE_SKIP }, () => {
342
378
  test('P14 - СНЯТ: путь прокликан, строки выданы, нумерация продолжена', async () => {
343
- const { srv, listen } = site();
379
+ const { srv, listen, shutdown } = site();
344
380
  const port = await listen();
345
381
  const dir = tmp();
346
382
  try {
@@ -358,11 +394,11 @@ describe('ось «путь»: живые прогоны браузером по
358
394
  // И измеренные закономерности стартового экрана, а не значения чужого оформления.
359
395
  assert.match(r.out, /шаг сетки отступов: 4px/, 'сетка 4px обязана вывестись: ' + r.out);
360
396
  assert.match(r.out, /брейкпоинты из настоящих @media: 768, 1024/, r.out);
361
- } finally { srv.close(); fs.rmSync(dir, { recursive: true, force: true }); }
397
+ } finally { await shutdown(); fs.rmSync(dir, { recursive: true, force: true }); }
362
398
  });
363
399
 
364
400
  test('P15 - доказательства помечены чужим материалом и закрыты своим .gitignore', async () => {
365
- const { srv, listen } = site();
401
+ const { srv, listen, shutdown } = site();
366
402
  const port = await listen();
367
403
  const dir = tmp();
368
404
  try {
@@ -379,11 +415,11 @@ describe('ось «путь»: живые прогоны браузером по
379
415
  'DOM по умолчанию не сохраняется: ' + files.join(', '));
380
416
  assert.ok(files.some((f) => f.endsWith('.aria.txt')), 'семантический слепок — наш вывод');
381
417
  assert.ok(files.includes('capture.json'), 'измерения сохраняются');
382
- } finally { srv.close(); fs.rmSync(dir, { recursive: true, force: true }); }
418
+ } finally { await shutdown(); fs.rmSync(dir, { recursive: true, force: true }); }
383
419
  });
384
420
 
385
421
  test('P16 - ВЕЖЛИВОСТЬ: один поток, пауза выдержана, потолок страниц соблюдён', async () => {
386
- const { srv, hits, listen } = site();
422
+ const { srv, hits, listen, shutdown } = site();
387
423
  const port = await listen();
388
424
  const dir = tmp();
389
425
  try {
@@ -397,13 +433,17 @@ describe('ось «путь»: живые прогоны браузером по
397
433
  'пауза между экранами обязана выдерживаться, измерено ' + (pages[1].t - pages[0].t) + ' мс');
398
434
  assert.match(pages[0].ua, new RegExp(lib.ROBOTS_AGENT),
399
435
  'User-Agent честный: маскировка под обычный браузер была бы обходом защиты');
400
- } finally { srv.close(); fs.rmSync(dir, { recursive: true, force: true }); }
436
+ } finally { await shutdown(); fs.rmSync(dir, { recursive: true, force: true }); }
401
437
  });
402
438
 
403
439
  test('P17 - robots.txt спрашивается ПЕРЕД обходом, и запрет — это исход', async () => {
440
+ // Закрытие серверов — в `finally`: упавшая проверка не должна превращаться в зависание
441
+ // (см. измерение в P18 ниже; там это стоило полосе всего её бюджета).
404
442
  const dir = tmp();
443
+ const deny = site({ robots: 'User-agent: *\nDisallow: /\n' });
444
+ const broken = site({ robots: 500 });
445
+ const one = site({ robots: 'User-agent: *\nDisallow: /\n' });
405
446
  try {
406
- const deny = site({ robots: 'User-agent: *\nDisallow: /\n' });
407
447
  const p1 = await deny.listen();
408
448
  const r1 = await run([`http://127.0.0.1:${p1}/`, '--project', dir, '--out', 'e',
409
449
  '--max-pages', '3']);
@@ -411,53 +451,62 @@ describe('ось «путь»: живые прогоны браузером по
411
451
  assert.match(r1.out, /НЕ ИЗМЕРЕНО: robots-disallowed/, r1.out);
412
452
  assert.equal(deny.hits.filter((h) => h.url !== '/robots.txt').length, 0,
413
453
  'ни одной страницы не тронуто: robots.txt читается ДО обхода, а не после');
414
- deny.srv.close();
415
454
 
416
455
  // Нечитаемый robots.txt — запрет, а не разрешение: «не смогли спросить» ≠ «нам разрешили».
417
- const broken = site({ robots: 500 });
418
456
  const p2 = await broken.listen();
419
457
  const r2 = await run([`http://127.0.0.1:${p2}/`, '--project', dir, '--out', 'e',
420
458
  '--max-pages', '3']);
421
459
  assert.equal(r2.code, 2, r2.out);
422
460
  assert.match(r2.out, /robots-disallowed/, r2.out);
423
461
  assert.match(r2.out, /трактуется как запрет/, r2.out);
424
- broken.srv.close();
425
462
 
426
463
  // ОДНА страница — не обход, robots.txt не запрашивается вовсе.
427
- const one = site({ robots: 'User-agent: *\nDisallow: /\n' });
428
464
  const p3 = await one.listen();
429
465
  const r3 = await run([`http://127.0.0.1:${p3}/lonely`, '--project', dir, '--out', 'e',
430
466
  '--max-pages', '1']);
431
467
  assert.equal(one.hits.filter((h) => h.url === '/robots.txt').length, 0,
432
468
  'обхода нет — robots.txt не спрашивается: ' + r3.out);
433
- one.srv.close();
434
- } finally { fs.rmSync(dir, { recursive: true, force: true }); }
469
+ } finally {
470
+ await deny.shutdown();
471
+ await broken.shutdown();
472
+ await one.shutdown();
473
+ fs.rmSync(dir, { recursive: true, force: true });
474
+ }
435
475
  });
436
476
 
437
477
  test('P18 - 403 и экран входа — исходы с НАЗВАННОЙ причиной, никогда не обход', async () => {
478
+ // ЗАКРЫТИЕ СЕРВЕРОВ СТОИТ В `finally`, И ЭТО НЕ СТИЛЬ, А ПОЧИНКА ЗАВИСАНИЯ.
479
+ // ИЗМЕРЕНО 2026-09-03: закрытие стояло ПОСЛЕ проверок, поэтому первая же упавшая проверка
480
+ // выбрасывала исключение до него, сервер оставался слушать, и `node --test` ждал опустошения
481
+ // цикла событий ВЕЧНО. Снаружи это выглядело как «полоса идёт долго»: она не шла, она висела —
482
+ // убита по внешнему потолку на 400 с и на 560 с. Сам P18 в одиночку отрабатывает за секунды.
483
+ // Общая форма: ресурс, удерживающий цикл событий, закрывается в `finally`, иначе падение
484
+ // проверки превращается в зависание, а зависание неотличимо от долгой работы.
438
485
  const dir = tmp();
486
+ const blocked = site({ forbid: ['/'] });
487
+ const walled = site();
439
488
  try {
440
- const blocked = site({ forbid: ['/'] });
441
489
  const p1 = await blocked.listen();
442
490
  const r1 = await run([`http://127.0.0.1:${p1}/`, '--project', dir, '--out', 'e',
443
491
  '--max-pages', '1']);
444
492
  assert.equal(r1.code, 2, r1.out);
445
493
  assert.match(r1.out, /НЕ ИЗМЕРЕНО: bot-protected/, r1.out);
446
494
  assert.match(r1.out, /ЗАПРЕЩЕНО/, 'и запрет на обход защиты обязан быть сказан: ' + r1.out);
447
- blocked.srv.close();
448
495
 
449
- const walled = site();
450
496
  const p2 = await walled.listen();
451
497
  const r2 = await run([`http://127.0.0.1:${p2}/signup`, '--project', dir, '--out', 'e',
452
498
  '--max-pages', '2']);
453
499
  assert.equal(r2.code, 2, 'стартовый экран за входом — не «снято»: ' + r2.out);
454
500
  assert.match(r2.out, /НЕ ИЗМЕРЕНО: auth-required/, r2.out);
455
- walled.srv.close();
456
- } finally { fs.rmSync(dir, { recursive: true, force: true }); }
501
+ } finally {
502
+ await blocked.shutdown();
503
+ await walled.shutdown();
504
+ fs.rmSync(dir, { recursive: true, force: true });
505
+ }
457
506
  });
458
507
 
459
508
  test('P19 - ИСТОЧНИКА НЕТ: экран открылся, перехода нет — доказанный отрицательный ответ', async () => {
460
- const { srv, listen } = site();
509
+ const { srv, listen, shutdown } = site();
461
510
  const port = await listen();
462
511
  const dir = tmp();
463
512
  try {
@@ -468,13 +517,13 @@ describe('ось «путь»: живые прогоны браузером по
468
517
  assert.match(r.out, /\*\*Статус съёмки \(путь\):\*\* ИСТОЧНИКА НЕТ/,
469
518
  'и печатает готовую строку профиля: ' + r.out);
470
519
  assert.ok(!/FR-LOOK-\d{3} \|/.test(r.out), 'строк не выпускается — записывать нечего: ' + r.out);
471
- } finally { srv.close(); fs.rmSync(dir, { recursive: true, force: true }); }
520
+ } finally { await shutdown(); fs.rmSync(dir, { recursive: true, force: true }); }
472
521
  });
473
522
 
474
523
  test('P20 - один двоичный файл выдаёт все три исхода в одном прогоне', async () => {
475
524
  // Каждый случай выше утверждает ОДНО направление, поэтому реализация с постоянным ответом
476
525
  // прошла бы подмножество. Тот же исполняемый файл обязан выдать 0, 1 и 2.
477
- const { srv, listen } = site();
526
+ const { srv, listen, shutdown } = site();
478
527
  const port = await listen();
479
528
  const dir = tmp();
480
529
  try {
@@ -487,6 +536,6 @@ describe('ось «путь»: живые прогоны браузером по
487
536
  '--max-pages', '1'])).code,
488
537
  ];
489
538
  assert.deepEqual(seen, [0, 1, 2], 'ожидались снято/пути-нет/не-измерено: ' + JSON.stringify(seen));
490
- } finally { srv.close(); fs.rmSync(dir, { recursive: true, force: true }); }
539
+ } finally { await shutdown(); fs.rmSync(dir, { recursive: true, force: true }); }
491
540
  });
492
541
  });
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Текст, ссылающийся на несуществующий файл, обязан быть замечен машиной.
5
+ *
6
+ * ПОЧЕМУ ЭТО ЕСТЬ. Этот класс дефекта ловился вручную минимум четыре раза: список хуков называл
7
+ * 4 из 8; таблица говорила «Rules 5» при шести; справка обещала 18 видов отказа при 17 в массиве;
8
+ * путь вывода называл несуществующий каталог. `verify` его не ловит ПО ПОСТРОЕНИЮ: он идёт от
9
+ * зарегистрированного объекта к его наличию, а этот дефект живёт в обратном направлении — от
10
+ * текста к объекту, которого нет.
11
+ *
12
+ * ПОЧЕМУ БАЗА, А НЕ НОЛЬ. Измерено 2026-09-03: 58 висячих ссылок в 23 файлах, и они не появляются
13
+ * после `init` (проверено на пустом проекте). Требовать ноль значило бы отказать каждому проекту в
14
+ * первый же день, то есть выключить заставу. База закреплена и может только уменьшаться.
15
+ */
16
+
17
+ const { test, describe } = require('node:test');
18
+ const assert = require('node:assert/strict');
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+
23
+ const { spawnSync } = require('node:child_process');
24
+ const HOOK = path.join(__dirname, '..', '..', 'templates', '.claude', 'hooks', 'check-dangling-refs.cjs');
25
+ const { danglingRefs, BASELINE } = require('../../templates/.claude/hooks/check-dangling-refs.cjs');
26
+ const TEMPLATES = path.join(__dirname, '..', '..', 'templates');
27
+
28
+ describe('висячие ссылки не могут стать хуже базы', () => {
29
+ test('текущее дерево не превышает закреплённую базу', () => {
30
+ const found = danglingRefs(TEMPLATES);
31
+ assert.notEqual(found, null, 'templates/.claude должен существовать');
32
+ const unique = new Set(found.map((f) => `${f.from} → ${f.to}`));
33
+ assert.ok(unique.size <= BASELINE,
34
+ `висячих ссылок ${unique.size} при базе ${BASELINE} — новая ссылка на несуществующий файл`);
35
+ });
36
+
37
+ test('база не завышена: она равна измеренному, а не взята с запасом', () => {
38
+ // Запас в базе — это тихое разрешение добавить ещё столько же. База обязана совпадать с
39
+ // измерением, иначе она перестаёт быть границей и становится квотой.
40
+ const found = danglingRefs(TEMPLATES);
41
+ const unique = new Set(found.map((f) => `${f.from} → ${f.to}`));
42
+ assert.equal(unique.size, BASELINE,
43
+ `база ${BASELINE} разошлась с измеренным ${unique.size}: если починили — опустите базу, если добавили — верните ссылку`);
44
+ });
45
+
46
+ test('сторож видит внедрённую висячую ссылку', () => {
47
+ // Дискриминация: застава, которая ничего не нашла, неотличима от заставы, которая ничего не
48
+ // умеет. Подкладываем ровно ту форму, которую она обязана поймать.
49
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dangl-'));
50
+ try {
51
+ fs.mkdirSync(path.join(dir, '.claude', 'rules'), { recursive: true });
52
+ fs.writeFileSync(path.join(dir, '.claude', 'rules', 'a.md'),
53
+ 'Смотри `.claude/hooks/nonexistent-guard.cjs` — его нет.\n');
54
+ const found = danglingRefs(dir);
55
+ assert.equal(found.length, 1);
56
+ assert.equal(found[0].to, '.claude/hooks/nonexistent-guard.cjs');
57
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
58
+ });
59
+
60
+ test('существующая цель висячей не считается', () => {
61
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dangl-ok-'));
62
+ try {
63
+ fs.mkdirSync(path.join(dir, '.claude', 'hooks'), { recursive: true });
64
+ fs.writeFileSync(path.join(dir, '.claude', 'hooks', 'real.cjs'), '// real\n');
65
+ fs.writeFileSync(path.join(dir, '.claude', 'a.md'), 'Смотри `.claude/hooks/real.cjs`.\n');
66
+ assert.deepEqual(danglingRefs(dir), []);
67
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
68
+ });
69
+
70
+ test('честный вход: дерево без висячих ссылок даёт exits 0 и печатает измеренное', () => {
71
+ // Требование каталога хуков: у каждого check-*.cjs должен быть тест ЧЕСТНОГО ВХОДА — прогон на
72
+ // заведомо правильных данных. Без него застава, которая всегда красная, выглядела бы рабочей.
73
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dangl-honest-'));
74
+ try {
75
+ fs.mkdirSync(path.join(dir, '.claude', 'hooks'), { recursive: true });
76
+ fs.writeFileSync(path.join(dir, '.claude', 'hooks', 'real.cjs'), '// real\n');
77
+ fs.writeFileSync(path.join(dir, '.claude', 'ok.md'), 'Ссылка на `.claude/hooks/real.cjs` — она есть.\n');
78
+ const r = spawnSync(process.execPath, [HOOK, dir], { encoding: 'utf8' });
79
+ assert.equal(r.status, 0, r.stderr || r.stdout);
80
+ assert.match(r.stdout, /висячих ссылок 0/);
81
+ assert.match(r.stdout, /Проверено файлов: 1/);
82
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
83
+ });
84
+
85
+ test('отсутствие .claude — это НЕ УСТАНОВЛЕНО, а не «чисто»', () => {
86
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dangl-none-'));
87
+ try {
88
+ assert.equal(danglingRefs(dir), null, 'пустое дерево обязано дать null, а не пустой список');
89
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
90
+ });
91
+ });
@@ -62,3 +62,52 @@ test('honest-input catalog probe reports a fake check with no same-named test',
62
62
  }
63
63
  });
64
64
 
65
+ /**
66
+ * ВТОРОЙ УКУС — на ветку, которую первый не трогал.
67
+ *
68
+ * ИЗМЕРЕНО 2026-09-03: удаление проверки `missing-honest-input-title` из аудита оставляло ОБА
69
+ * метатеста зелёными. Первый укус подкладывает хук БЕЗ теста и потому доказывает только ветку
70
+ * `missing-test`; вторая ветка не проверялась ничем и могла быть удалена незаметно. Страж,
71
+ * половина которого удаляется без единого красного теста, охраняет ровно ту половину, что осталась.
72
+ */
73
+ test('honest-input catalog probe reports a test file with no honest-input title', () => {
74
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-honest-meta2-')));
75
+ const hooksCopy = path.join(root, 'hooks');
76
+ const testsCopy = path.join(root, 'unit');
77
+ try {
78
+ fs.cpSync(HOOKS, hooksCopy, { recursive: true });
79
+ fs.cpSync(TESTS, testsCopy, { recursive: true });
80
+ fs.writeFileSync(path.join(hooksCopy, 'check-yyy.cjs'), "'use strict';\n");
81
+ // Тест ЕСТЬ — и в нём нет ни одного заголовка честного входа. Это ровно та форма, которую
82
+ // первый укус пропускает: файл на месте, обязанность не выполнена.
83
+ fs.writeFileSync(path.join(testsCopy, 'check-yyy.test.js'),
84
+ "'use strict';\nconst { test } = require('node:test');\n" +
85
+ "test('refuses on a bad input', () => {});\n");
86
+
87
+ const result = auditHonestInputs(hooksCopy, testsCopy);
88
+ assert.deepEqual(result.issues.filter((issue) => issue.hook === 'check-yyy.cjs'), [{
89
+ hook: 'check-yyy.cjs', testFile: 'check-yyy.test.js', reason: 'missing-honest-input-title',
90
+ }], 'ветка отсутствующего заголовка обязана иметь собственный укус, иначе её удалят молча');
91
+ } finally {
92
+ fs.rmSync(root, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ /**
97
+ * ЧЕСТНАЯ ГРАНИЦА ЭТОГО ИНВАРИАНТА, названная здесь, чтобы её не пришлось выводить.
98
+ *
99
+ * Проверяется ЗАГОЛОВОК теста, а не его содержание. Заголовок «honest input exits 0» над пустым
100
+ * телом инвариант удовлетворяет. То есть страж ловит ЗАБЫВЧИВОСТЬ (никто не написал такого теста)
101
+ * и не ловит ОБМАН (написали заголовок, не написав проверки).
102
+ *
103
+ * Это осознанная граница, а не недосмотр: доказать по телу теста, что он действительно прогоняет
104
+ * честный вход, значит разбирать чужой код, а разбор кода регулярным выражением — тот самый
105
+ * механизм, который в этом пакете уже дважды давал ложные вердикты. Забывчивость при этом и есть
106
+ * массовый случай: заголовок пишут те, кто про обязанность помнит.
107
+ */
108
+ test('the invariant is a TITLE check, and that boundary is stated rather than implied', () => {
109
+ const source = fs.readFileSync(__filename, 'utf8');
110
+ assert.match(source, /Проверяется ЗАГОЛОВОК теста, а не его содержание/,
111
+ 'граница инварианта обязана быть написана в файле, а не подразумеваться');
112
+ });
113
+