@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.
- package/.dz-manifest.json +56 -24
- package/bin/cli.js +0 -0
- package/package.json +11 -10
- package/sbom.json +103 -23
- package/scripts/check-pipeline-gaps.sh +0 -0
- package/src/utils.js +1 -0
- package/templates/.claude/commands/replicate.md +9 -1
- package/templates/.claude/hooks/check-dangling-refs.cjs +89 -0
- package/templates/.claude/hooks/check-docs-complete.cjs +7 -0
- package/templates/.claude/hooks/check-external-deps.cjs +18 -3
- package/templates/.claude/hooks/statusline.cjs +1 -1
- package/templates/.claude/rules/cost-of-detection-ladder.md +37 -4
- package/templates/.claude/rules/docker-ports.md +28 -0
- package/templates/.claude/rules/feature-lifecycle.md +21 -0
- package/templates/.claude/rules/replicate-pipeline.md +3 -3
- package/templates/.claude/skills/brutal-honesty-review/resources/assessment-rubrics.md +12 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/security-patterns-library.md +45 -0
- package/tests/dz-availability.js +34 -0
- package/tests/e2e/packed-insights-writer.test.js +5 -1
- package/tests/npm-cli-resolver.js +366 -0
- package/tests/npm-resolver-cases.json +128 -0
- package/tests/snapshot/baseline.json +13 -12
- package/tests/unit/capture-source-path.test.js +73 -24
- package/tests/unit/check-dangling-refs.test.js +91 -0
- package/tests/unit/check-external-deps.test.js +26 -2
- package/tests/unit/detection-ladder-contract.test.js +20 -10
- package/tests/unit/guard-honest-input-meta.test.js +49 -0
- package/tests/unit/honest-failure-rules.test.js +23 -1
- package/tests/unit/insights-writer.test.js +5 -1
- package/tests/unit/negative-conclusion-gate.test.js +3 -3
- package/tests/unit/npm-cli-resolver.test.js +314 -0
- package/tests/unit/optional-doc-idiom.test.js +83 -0
- package/tests/unit/quote-provenance.test.js +4 -0
- package/tests/unit/traceability-negative-fixture.test.js +19 -5
- package/tests/unit/verdict-vocabulary.test.js +72 -0
- package/LICENSE +0 -21
|
@@ -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
|
-
|
|
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) =>
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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 {
|
|
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 {
|
|
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
|
+
});
|
|
@@ -52,8 +52,12 @@ const check = (files) => run(CHECK, files);
|
|
|
52
52
|
const GOOD_EVIDENCE = 'https://docs.example.test/email · checked 2026-08-30 · '
|
|
53
53
|
+ '«The API reports hard and soft bounces via webhook»';
|
|
54
54
|
|
|
55
|
+
const EN_HEADER = '| Capability needed | Provider / API | Evidence | Verdict | Requirements relying on it |';
|
|
56
|
+
/** The package writes its documents in Russian, so a Russian header is the TYPICAL case, not a corner. */
|
|
57
|
+
const RU_HEADER = '| Возможность | Провайдер | Свидетельство | Вердикт | Требования |';
|
|
58
|
+
|
|
55
59
|
/** An Architecture.md whose inventory says what the case needs. */
|
|
56
|
-
function arch({ rows, before = '## Technology Stack\n\n| Layer | Technology | Rationale |\n|---|---|---|\n| Backend | Node | привычен команде |\n', section = true, prose = '' } = {}) {
|
|
60
|
+
function arch({ rows, header = EN_HEADER, before = '## Technology Stack\n\n| Layer | Technology | Rationale |\n|---|---|---|\n| Backend | Node | привычен команде |\n', section = true, prose = '' } = {}) {
|
|
57
61
|
const body = rows === undefined
|
|
58
62
|
? [['отправка писем', 'Postmark', GOOD_EVIDENCE, 'CONFIRMED', 'FR-010']]
|
|
59
63
|
: rows;
|
|
@@ -61,7 +65,7 @@ function arch({ rows, before = '## Technology Stack\n\n| Layer | Technology | Ra
|
|
|
61
65
|
if (section) {
|
|
62
66
|
out += '## External Dependencies\n\n' + (prose ? prose + '\n\n' : '');
|
|
63
67
|
if (body.length) {
|
|
64
|
-
out +=
|
|
68
|
+
out += header + '\n'
|
|
65
69
|
+ '|---|---|---|---|---|\n'
|
|
66
70
|
+ body.map((r) => '| ' + r.join(' | ') + ' |').join('\n') + '\n';
|
|
67
71
|
}
|
|
@@ -154,6 +158,26 @@ describe('инвентарь внешних зависимостей — the det
|
|
|
154
158
|
assert.match(r.out, /FR-011/, 'the dependent requirements must be named, not counted');
|
|
155
159
|
});
|
|
156
160
|
|
|
161
|
+
test('P9b - RU/EN twin: the header is recognised by POSITION, so a Russian header is never a data row', () => {
|
|
162
|
+
// MEASURED 2026-09-02 (backlog b8a7669d): the same table body under a Russian header produced a
|
|
163
|
+
// FALSE finding «строка инвентаря без вердикта • Возможность» — the header itself was read as a
|
|
164
|
+
// row — and that false finding fired FIRST, masking the real one. One variable differs between
|
|
165
|
+
// the twins: the header line. Every outcome below must be identical for both.
|
|
166
|
+
const clean = [['отправка писем', 'Postmark', GOOD_EVIDENCE, 'CONFIRMED', 'FR-010']];
|
|
167
|
+
for (const header of [EN_HEADER, RU_HEADER]) {
|
|
168
|
+
const r = check({ [ARCH]: arch({ rows: clean, header }) });
|
|
169
|
+
assert.equal(r.code, 0, header + '\n' + r.out);
|
|
170
|
+
assert.doesNotMatch(r.out, /Возможность|Capability needed/, 'the header must not surface as a row: ' + r.out);
|
|
171
|
+
}
|
|
172
|
+
const noVerdict = [['отправка писем', 'Postmark', GOOD_EVIDENCE, '', 'FR-010']];
|
|
173
|
+
const twins = [EN_HEADER, RU_HEADER].map((header) => check({ [ARCH]: arch({ rows: noVerdict, header }) }));
|
|
174
|
+
for (const r of twins) {
|
|
175
|
+
assert.equal(r.code, 1, r.out);
|
|
176
|
+
assert.match(r.out, /отправка писем/, 'the REAL defective row must be the one named: ' + r.out);
|
|
177
|
+
assert.doesNotMatch(r.out, /Возможность|Capability needed/, 'the header must not be the finding: ' + r.out);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
157
181
|
test('P10 - THE second fixture: CONFIRMED without a verbatim quote fails, WITH THE ROW NAMED', () => {
|
|
158
182
|
const r = check({ [ARCH]: arch({ rows: [
|
|
159
183
|
['отправка писем', 'Postmark', 'https://docs.example.test/email · checked 2026-08-30', 'CONFIRMED', 'FR-010'],
|
|
@@ -4,11 +4,12 @@ const { test } = require('node:test');
|
|
|
4
4
|
const assert = require('node:assert/strict');
|
|
5
5
|
const { execFileSync } = require('node:child_process');
|
|
6
6
|
const fs = require('node:fs');
|
|
7
|
-
const Module = require('node:module');
|
|
8
7
|
const os = require('node:os');
|
|
9
8
|
const path = require('node:path');
|
|
10
9
|
const { Worker } = require('node:worker_threads');
|
|
11
10
|
|
|
11
|
+
const { announceNpmSkip, resolveNpmCli } = require('../npm-cli-resolver.js');
|
|
12
|
+
|
|
12
13
|
const PKG_DIR = path.resolve(__dirname, '..', '..');
|
|
13
14
|
const RULE_DIR = path.join(PKG_DIR, 'templates', '.claude', 'rules');
|
|
14
15
|
const RULE_FILE = 'cost-of-detection-ladder.md';
|
|
@@ -18,13 +19,12 @@ const CLI = path.join(PKG_DIR, 'bin', 'cli.js');
|
|
|
18
19
|
|
|
19
20
|
const read = (file) => fs.readFileSync(file, 'utf8');
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
'npm-cli.js');
|
|
22
|
+
// FR-3 / ADR-001. The previous line here asked whether `npm_execpath` EXISTS. Under `pnpm test` it
|
|
23
|
+
// points at pnpm.cjs, that file exists, so the fallback never fired in the one case it was written
|
|
24
|
+
// for — and pnpm was then loaded as the npm CLI: `Unknown option: 'dry-run'` (MEASURED 2026-09-18).
|
|
25
|
+
// The resolver asks instead whether the thing at that path IS npm, and falls through to the
|
|
26
|
+
// installed npm when it is not.
|
|
27
|
+
function npmPackDryRunFiles(npmCli) {
|
|
28
28
|
const cache = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-npm-pack-cache-'));
|
|
29
29
|
|
|
30
30
|
return new Promise((resolve, reject) => {
|
|
@@ -288,14 +288,24 @@ test('P5 — package runner and scope guards reject omitted and out-of-scope inp
|
|
|
288
288
|
'a real out-of-scope package path must fire the scope safeguard');
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
-
test('A2 — package files include templates and the actual npm pack listing contains the ladder', async () => {
|
|
291
|
+
test('A2 — package files include templates and the actual npm pack listing contains the ladder', async (t) => {
|
|
292
292
|
const pkg = JSON.parse(read(path.join(PKG_DIR, 'package.json')));
|
|
293
293
|
assert.deepEqual(packagingProblems(pkg), []);
|
|
294
294
|
const omitted = JSON.parse(JSON.stringify(pkg));
|
|
295
295
|
omitted.files = omitted.files.filter((entry) => entry !== 'templates/');
|
|
296
296
|
assert.deepEqual(packagingProblems(omitted), ['package files[] omits templates/'],
|
|
297
297
|
'a cloned files[] without templates/ must fire the distribution safeguard');
|
|
298
|
-
|
|
298
|
+
|
|
299
|
+
// FR-4: no npm anywhere is a NAMED skip printed into the suite output, never a silent pass and
|
|
300
|
+
// never a crash. This test exists to exercise the REAL packer; a faked one would prove nothing.
|
|
301
|
+
const npmCli = resolveNpmCli(process.env);
|
|
302
|
+
if (npmCli.kind !== 'npm-cli') {
|
|
303
|
+
announceNpmSkip(npmCli);
|
|
304
|
+
t.skip(npmCli.reason);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const packedFiles = await npmPackDryRunFiles(npmCli.path);
|
|
299
309
|
assert.ok(
|
|
300
310
|
packedFiles.includes(`templates/.claude/rules/${RULE_FILE}`),
|
|
301
311
|
`actual npm pack listing omits templates/.claude/rules/${RULE_FILE}`);
|
|
@@ -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
|
+
|
|
@@ -62,7 +62,29 @@ const ESTIMATED_TRIGGER = 4037.75;
|
|
|
62
62
|
// three /feature report gates; invocation-time material, the gates are the machine half) and the
|
|
63
63
|
// Traceability floor row + FR-009 worked case in references/scoring-system.md. Not a relocation:
|
|
64
64
|
// the always-loaded corpus measured by P5 GREW in the same change (feature.md gates), it did not shrink.
|
|
65
|
-
|
|
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 };
|
|
66
88
|
const UNMEASURED_SKILL_SURFACES_GROWTH_ALLOWANCE = 0;
|
|
67
89
|
const SEAMS = [
|
|
68
90
|
['commands/feature.md', 'both'],
|
|
@@ -6,6 +6,7 @@ const { spawnSync } = require('node:child_process');
|
|
|
6
6
|
const fs = require('node:fs');
|
|
7
7
|
const os = require('node:os');
|
|
8
8
|
const path = require('node:path');
|
|
9
|
+
const { skipWhenDzAbsent } = require('../dz-availability.js');
|
|
9
10
|
|
|
10
11
|
const PKG = path.resolve(__dirname, '..', '..');
|
|
11
12
|
const TPL = path.join(PKG, 'templates', '.claude');
|
|
@@ -258,7 +259,7 @@ describe('PR-022 harvest insight persistence', () => {
|
|
|
258
259
|
} finally { cleanup(broken); }
|
|
259
260
|
});
|
|
260
261
|
|
|
261
|
-
test('P16b - duplicate replay imports one learned row', () => {
|
|
262
|
+
test('P16b - duplicate replay imports one learned row', (t) => {
|
|
262
263
|
const { writeInsight, stableTeachText, normalizePayload } = requireWriter();
|
|
263
264
|
const root = tempProject('p-rep-real-dz-teach-');
|
|
264
265
|
try {
|
|
@@ -266,6 +267,9 @@ describe('PR-022 harvest insight persistence', () => {
|
|
|
266
267
|
const first = writeInsight(root, record);
|
|
267
268
|
const replay = writeInsight(root, { ...record, date: '2026-08-31' });
|
|
268
269
|
assert.equal(first.status, 'created');
|
|
270
|
+
// Backlog 1b4857a6: this case queries the learned rows dz itself stores, so a missing dz is
|
|
271
|
+
// an audible skip and a broken dz stays red — the rule lives once, in tests/dz-availability.js.
|
|
272
|
+
if (skipWhenDzAbsent(t, first.teach.state, 'it reads back the learned rows dz stores')) return;
|
|
269
273
|
assert.equal(first.teach.state, 'ok');
|
|
270
274
|
assert.equal(replay.status, 'duplicate');
|
|
271
275
|
assert.equal(replay.teach.state, 'ok');
|
|
@@ -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
|
});
|