@dzhechkov/p-replicator 1.13.1 → 1.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.dz-manifest.json +93 -25
  2. package/README.md +19 -1
  3. package/package.json +5 -4
  4. package/sbom.json +194 -24
  5. package/scripts/check-pipeline-gaps.sh +510 -21
  6. package/src/utils.js +2 -0
  7. package/templates/.claude/commands/feature.md +43 -1
  8. package/templates/.claude/commands/replicate.md +9 -1
  9. package/templates/.claude/hooks/check-dangling-refs.cjs +89 -0
  10. package/templates/.claude/hooks/check-docs-complete.cjs +7 -0
  11. package/templates/.claude/hooks/check-review-contract.cjs +205 -0
  12. package/templates/.claude/hooks/statusline.cjs +1 -1
  13. package/templates/.claude/rules/cost-of-detection-ladder.md +37 -4
  14. package/templates/.claude/rules/docker-ports.md +28 -0
  15. package/templates/.claude/rules/feature-lifecycle.md +21 -0
  16. package/templates/.claude/rules/replicate-pipeline.md +5 -4
  17. package/templates/.claude/skills/brutal-honesty-review/resources/assessment-rubrics.md +12 -2
  18. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/security-patterns-library.md +45 -0
  19. package/templates/.claude/skills/requirements-validator/SKILL.md +18 -5
  20. package/templates/.claude/skills/requirements-validator/references/feature-report-contracts.md +67 -0
  21. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +15 -6
  22. package/tests/e2e/feature-contour.test.js +176 -0
  23. package/tests/fixtures/feature-contour/docs/features/demo-gate/01_specification.md +15 -0
  24. package/tests/fixtures/feature-contour/docs/features/demo-gate/02_pseudocode.md +19 -0
  25. package/tests/fixtures/feature-contour/docs/features/demo-gate/03_architecture.md +3 -0
  26. package/tests/fixtures/feature-contour/docs/features/demo-gate/04_refinement.md +3 -0
  27. package/tests/fixtures/feature-contour/docs/features/demo-gate/05_completion.md +7 -0
  28. package/tests/fixtures/feature-contour/docs/features/demo-gate/review-report.md +11 -0
  29. package/tests/fixtures/feature-contour/docs/features/demo-gate/validation-report.md +10 -0
  30. package/tests/fixtures/feature-contour/tests/demo.test.js +16 -0
  31. package/tests/snapshot/baseline.json +17 -14
  32. package/tests/unit/capture-source-path.test.js +73 -24
  33. package/tests/unit/check-dangling-refs.test.js +91 -0
  34. package/tests/unit/check-review-contract.test.js +181 -0
  35. package/tests/unit/guard-honest-input-meta.test.js +49 -0
  36. package/tests/unit/honest-failure-rules.test.js +40 -3
  37. package/tests/unit/negative-conclusion-gate.test.js +3 -3
  38. package/tests/unit/optional-doc-idiom.test.js +83 -0
  39. package/tests/unit/quote-provenance.test.js +4 -0
  40. package/tests/unit/sync-templates-guard.test.js +46 -3
  41. package/tests/unit/traceability-completion-gate.test.js +267 -0
  42. package/tests/unit/traceability-negative-fixture.test.js +19 -5
  43. package/tests/unit/verdict-vocabulary.test.js +72 -0
@@ -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
+ });
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { spawnSync } = require('node:child_process');
6
+ const crypto = require('node:crypto');
7
+ const fs = require('node:fs');
8
+ const os = require('node:os');
9
+ const path = require('node:path');
10
+
11
+ const PKG = path.resolve(__dirname, '..', '..');
12
+ const CHECK = path.join(PKG, 'templates', '.claude', 'hooks', 'check-review-contract.cjs');
13
+ const SLUG = 'traceability-floor';
14
+ const SPEC = [
15
+ '# Specification',
16
+ '',
17
+ '### AC-trace-1 First criterion',
18
+ '',
19
+ 'The first contour is observable.',
20
+ '',
21
+ '```markdown',
22
+ '### AC-ignored-9 Example only',
23
+ '```',
24
+ '',
25
+ '### AC-trace-2 Second criterion',
26
+ '',
27
+ 'The second contour is observable.',
28
+ '',
29
+ ].join('\n');
30
+
31
+ function digest(body) {
32
+ return crypto.createHash('sha256').update(body).digest('hex');
33
+ }
34
+
35
+ function report(options) {
36
+ const o = options || {};
37
+ const rows = o.rows || [
38
+ '| AC-trace-1 | met | tests/trace.test.js — proves the first contour |',
39
+ '| AC-trace-2 | unverifiable | |',
40
+ ];
41
+ return [
42
+ '# Review report',
43
+ ...(o.family === null ? [] : ['Reviewer family: ' + (o.family || 'claude')]),
44
+ 'Spec revision: sha256:' + (o.revision || digest(SPEC)),
45
+ '',
46
+ ...(o.section === false ? [] : [
47
+ '## Spec conformance',
48
+ ...(o.table === false ? [] : [
49
+ '| Criterion | Verdict | Evidence |',
50
+ '|-----------|---------|----------|',
51
+ ...rows,
52
+ ]),
53
+ ]),
54
+ '',
55
+ ].join('\n');
56
+ }
57
+
58
+ function check(options) {
59
+ const o = options || {};
60
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-review-')));
61
+ const feature = path.join(root, 'docs', 'features', SLUG);
62
+ fs.mkdirSync(feature, { recursive: true });
63
+ try {
64
+ const specPath = path.join(feature, '01_specification.md');
65
+ if (o.symlinkSpec) {
66
+ const target = path.join(root, 'spec-target.md');
67
+ fs.writeFileSync(target, SPEC);
68
+ fs.symlinkSync(target, specPath);
69
+ } else {
70
+ fs.writeFileSync(specPath, SPEC);
71
+ }
72
+ if (!o.missingReport) {
73
+ const reportPath = path.join(feature, 'review-report.md');
74
+ fs.writeFileSync(reportPath, o.body === undefined ? report() : o.body);
75
+ if (o.unreadableReport) fs.chmodSync(reportPath, 0o000);
76
+ }
77
+ const result = spawnSync(process.execPath, [CHECK, root, SLUG], { encoding: 'utf8' });
78
+ return { code: result.status, out: (result.stdout || '') + (result.stderr || '') };
79
+ } finally {
80
+ fs.rmSync(root, { recursive: true, force: true });
81
+ }
82
+ }
83
+
84
+ function namedGap(options, pattern) {
85
+ const result = check(options);
86
+ assert.equal(result.code, 1, result.out);
87
+ assert.match(result.out, pattern, result.out);
88
+ }
89
+
90
+ test('honest input exits 0 and prints the measured counts', () => {
91
+ const result = check();
92
+ assert.equal(result.code, 0, result.out);
93
+ assert.match(result.out,
94
+ /PASS review-contract feature=traceability-floor AC-ids=2 rows=2/,
95
+ result.out);
96
+ });
97
+
98
+ test('missing reviewer family is a named gap', () => {
99
+ namedGap({ body: report({ family: null }) }, /GAP Reviewer family line missing/);
100
+ });
101
+
102
+ test('reviewer family outside the vocabulary is a named gap', () => {
103
+ namedGap({ body: report({ family: 'gpt' }) }, /GAP Reviewer family invalid value=gpt/);
104
+ });
105
+
106
+ test('revision mismatch names both 12-character prefixes', () => {
107
+ const wrong = '0'.repeat(64);
108
+ const actual = digest(SPEC).slice(0, 12);
109
+ namedGap(
110
+ { body: report({ revision: wrong }) },
111
+ new RegExp('GAP Spec revision mismatch report=000000000000 specification=' + actual),
112
+ );
113
+ });
114
+
115
+ test('missing Spec conformance section is a named gap', () => {
116
+ namedGap({ body: report({ section: false }) }, /GAP Spec conformance section missing/);
117
+ });
118
+
119
+ test('missing Spec conformance table is a named gap', () => {
120
+ namedGap({ body: report({ table: false }) }, /GAP Spec conformance table missing or malformed/);
121
+ });
122
+
123
+ test('an AC id with no row is named', () => {
124
+ namedGap({ body: report({ rows: [
125
+ '| AC-trace-1 | met | tests/trace.test.js — proves the first contour |',
126
+ ] }) }, /GAP AC-trace-2 has no Spec conformance row/);
127
+ });
128
+
129
+ test('a row id absent from the specification is named', () => {
130
+ namedGap({ body: report({ rows: [
131
+ '| AC-trace-1 | met | tests/trace.test.js — proves the first contour |',
132
+ '| AC-trace-2 | unverifiable | |',
133
+ '| AC-invented-3 | met | tests/trace.test.js — invented contour |',
134
+ ] }) }, /GAP AC-invented-3 row id is not in the specification/);
135
+ });
136
+
137
+ test('a duplicate row is named', () => {
138
+ namedGap({ body: report({ rows: [
139
+ '| AC-trace-1 | met | tests/trace.test.js — first proof |',
140
+ '| AC-trace-1 | met | tests/trace.test.js — duplicate proof |',
141
+ '| AC-trace-2 | unverifiable | |',
142
+ ] }) }, /GAP AC-trace-1 duplicate Spec conformance row/);
143
+ });
144
+
145
+ test('a verdict outside the vocabulary is named', () => {
146
+ namedGap({ body: report({ rows: [
147
+ '| AC-trace-1 | passed | tests/trace.test.js — first proof |',
148
+ '| AC-trace-2 | unverifiable | |',
149
+ ] }) }, /GAP AC-trace-1 verdict invalid value=passed/);
150
+ });
151
+
152
+ test('empty evidence on met and not met rows is named', () => {
153
+ for (const verdict of ['met', 'not met']) {
154
+ namedGap({ body: report({ rows: [
155
+ '| AC-trace-1 | ' + verdict + ' | |',
156
+ '| AC-trace-2 | unverifiable | |',
157
+ ] }) }, new RegExp('GAP AC-trace-1 evidence empty for verdict=' + verdict));
158
+ }
159
+ });
160
+
161
+ test('unreadable or missing report → exit 2', () => {
162
+ for (const options of [{ missingReport: true }, { unreadableReport: true }]) {
163
+ const result = check(options);
164
+ assert.equal(result.code, 2, result.out);
165
+ assert.match(result.out, /NOT-ESTABLISHED review contract: review report/, result.out);
166
+ }
167
+ });
168
+
169
+ test('symlinked spec → exit 2', () => {
170
+ const result = check({ symlinkSpec: true });
171
+ assert.equal(result.code, 2, result.out);
172
+ assert.match(result.out, /NOT-ESTABLISHED review contract: specification is a symlink/, result.out);
173
+ });
174
+
175
+ test('the family line is disclosure only: every vocabulary value passes, gpt fails', () => {
176
+ for (const family of ['claude', 'codex', 'human', 'unknown']) {
177
+ const result = check({ body: report({ family }) });
178
+ assert.equal(result.code, 0, family + ': ' + result.out);
179
+ }
180
+ namedGap({ body: report({ family: 'gpt' }) }, /GAP Reviewer family invalid value=gpt/);
181
+ });
@@ -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
+
@@ -41,13 +41,50 @@ const RULES = ['honest-configuration', 'swarm-file-evidence'];
41
41
  * {files:34, bytes:356321, estimatedTokens:95940.35}, a measured delta of 3988.50 below the
42
42
  * unchanged 4037.75 trigger.
43
43
  * Reproducer: measureCorpus() over templates/.claude/{rules,commands}/*.md plus every SKILL.md.
44
- * `ESTIMATED_TRIGGER` was not touched.
44
+ * `ESTIMATED_TRIGGER` was not touched. *
45
+ * Re-pinned 2026-09-02 for feature `traceability-floor-and-contract` (three /feature gates + the
46
+ * Traceability floor). Preimage = the tree at its parent commit d126adec^ (HEAD~1 of the feature),
47
+ * measured {files:34, bytes:372385, estimatedTokens:99956.35}. Against the PREVIOUS pin that state
48
+ * already sat at a delta of 4016.00 — the four night features between the two pins (check-ports
49
+ * receipts, design-analysis-hypothesis, quote-provenance, heal-bhr-drift) each fitted the trigger
50
+ * and none re-pinned, so 21.75 tokens of headroom remained and any new gate line would have tripped
51
+ * P5: the "ceiling nobody chose" this comment warns about, observed live. The feature's OWN delta
52
+ * against its true preimage is 715.75 tokens (feature.md gate mandates + validator report contract +
53
+ * the hook's inventory line), after its byte-level formats were moved to
54
+ * requirements-validator/references/feature-report-contracts.md. `ESTIMATED_TRIGGER` unchanged.
45
55
  */
46
- const FRESH_BEFORE = { files: 34, bytes: 356321, estimatedTokens: 95940.35 };
56
+ const FRESH_BEFORE = { files: 34, bytes: 372385, estimatedTokens: 99956.35 };
47
57
  const ESTIMATED_TRIGGER = 4037.75;
48
58
  // Measured 2026-09-01 before corpus-budget-compression. Modules and references are outside
49
59
  // measureCorpus(); a 0-byte allowance makes any relocation growth visible instead of budget-free.
50
- const UNMEASURED_SKILL_SURFACES_BEFORE = { files: 59, bytes: 638309 };
60
+ // Re-measured 2026-09-02 (features/traceability-floor-and-contract): +1 file, +4265 bytes
61
+ // requirements-validator/references/feature-report-contracts.md (the byte-level formats of the
62
+ // three /feature report gates; invocation-time material, the gates are the machine half) and the
63
+ // Traceability floor row + FR-009 worked case in references/scoring-system.md. Not a relocation:
64
+ // the always-loaded corpus measured by P5 GREW in the same change (feature.md gates), it did not shrink.
65
+ // ПЕРЕЗАКРЕПЛЕНО 2026-09-03: 642574 → 645401 байт при неизменных 60 файлах.
66
+ //
67
+ // ЧТО ЭТОТ СТОРОЖ ЛОВИТ, и почему перезакрепление здесь законно, а поднятие допуска — нет.
68
+ // Он ловит ПЕРЕЕЗД всегда-загружаемого текста из измеряемого корпуса в неизмеряемые поверхности
69
+ // навыков: так «сжатие корпуса» выглядит достигнутым, а контекст сессии не уменьшается ни на байт.
70
+ // Признак переезда — рост здесь ПРИ УМЕНЬШЕНИИ там.
71
+ //
72
+ // ПРОВЕРЕНО ПЕРЕД ПЕРЕЗАКРЕПЛЕНИЕМ: `git diff --numstat` по правкам этого захода даёт в измеряемом
73
+ // корпусе (`rules/`, `commands/`) 28 вставок и НОЛЬ удалений. Текст ниоткуда не уехал — это новое
74
+ // содержание: правило S-08 о типе изображения по содержимому и шесть строк к S-01/S-02 о молчаливых
75
+ // способах обойти изоляцию арендаторов, обе вставки в `references/security-patterns-library.md`.
76
+ //
77
+ // ТОЧНОСТЬ УЧЁТА, исправлена после проверки дискриминацией: сторож считает ТОЛЬКО `modules/` и
78
+ // `references/` внутри навыков (строка 242), а `resources/` не считает вовсе. Первая редакция этого
79
+ // комментария приписывала часть роста правкам в `resources/assessment-rubrics.md` — неверно, они в
80
+ // базу не входят. Прирост 2827 байт объясняется одним файлом: 2483 байта дала правка
81
+ // `security-patterns-library.md`, остальное — перенос строк внутри неё. Проверено пробой: приписка
82
+ // к считаемому файлу красит тест, приписка к `resources/` — нет.
83
+ //
84
+ // ДОПУСК ОСТАЁТСЯ НУЛЁМ И НЕ ПОДНИМАЕТСЯ НИ ПРИ КАКОМ РЕЗУЛЬТАТЕ. Разница между двумя числами
85
+ // принципиальная: база — это ИЗМЕРЕНИЕ, и переизмерить её с названной причиной честно; допуск —
86
+ // это РАЗРЕШЕНИЕ расти молча, и любое его значение выше нуля превращает сторожа в квоту.
87
+ const UNMEASURED_SKILL_SURFACES_BEFORE = { files: 60, bytes: 645401 };
51
88
  const UNMEASURED_SKILL_SURFACES_GROWTH_ALLOWANCE = 0;
52
89
  const SEAMS = [
53
90
  ['commands/feature.md', 'both'],
@@ -38,7 +38,7 @@ const read = (rel) => fs.readFileSync(path.join(PKG, rel), 'utf8');
38
38
  /** Is a usable python3 present? Reported honestly rather than silently skipped past. */
39
39
  function python() {
40
40
  for (const bin of ['python3', 'python']) {
41
- const probe = spawnSync(bin, ['--version'], { encoding: 'utf8' });
41
+ const probe = spawnSync(bin, ['--version'], { encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
42
42
  if (probe.status === 0) return bin;
43
43
  }
44
44
  return null;
@@ -61,7 +61,7 @@ function gate(reportText) {
61
61
  const facts = path.join(dir, 'facts.json');
62
62
  fs.writeFileSync(report, reportText);
63
63
  fs.writeFileSync(facts, ledger());
64
- const r = spawnSync(PY, [GATE, '--report', report, '--facts', facts, '--json'], { encoding: 'utf8' });
64
+ const r = spawnSync(PY, [GATE, '--report', report, '--facts', facts, '--json'], { encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
65
65
  const raw = (r.stdout || '').trim();
66
66
  let parsed = null;
67
67
  try { parsed = JSON.parse(raw); } catch { /* left null, asserted by the caller */ }
@@ -231,7 +231,7 @@ describe('отрицательный вывод — the second direction of the
231
231
  const report = path.join(dir, 'report.md');
232
232
  fs.writeFileSync(report, BASE + NEGATIVE);
233
233
  const r = spawnSync(PY, [GATE, '--report', report, '--facts', path.join(dir, 'absent.json'),
234
- '--json'], { encoding: 'utf8' });
234
+ '--json'], { encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
235
235
  assert.equal(r.status, 2, 'a gate that could not read its inputs has cleared nothing');
236
236
  } finally { fs.rmSync(dir, { recursive: true, force: true }); }
237
237
  });