@dzhechkov/p-replicator 1.10.4 → 1.12.0
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 +119 -47
- package/CHANGELOG.md +85 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +3 -3
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/04_api_reference.md +11 -5
- package/README/eng/README.md +1 -1
- package/README/ru/01_quickstart.md +3 -3
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/04_api_reference.md +11 -5
- package/README/ru/README.md +1 -1
- package/README/ru/html/index.html +7 -7
- package/README.md +154 -38
- package/bin/cli.js +0 -0
- package/package.json +12 -10
- package/sbom.json +226 -46
- package/scripts/check-pipeline-gaps.sh +413 -0
- package/src/commands/doctor.js +94 -4
- package/src/rule-components.json +11 -0
- package/src/utils.js +3 -8
- package/templates/.claude/agents/harvest-coordinator.md +10 -1
- package/templates/.claude/commands/feature.md +57 -9
- package/templates/.claude/commands/go.md +11 -0
- package/templates/.claude/commands/harvest.md +41 -3
- package/templates/.claude/commands/myinsights.md +21 -26
- package/templates/.claude/commands/replicate.md +10 -1
- package/templates/.claude/commands/start.md +8 -0
- package/templates/.claude/hooks/check-ports.cjs +409 -20
- package/templates/.claude/hooks/session-insights.cjs +158 -25
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/hooks/write-insight.cjs +253 -0
- package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
- package/templates/.claude/rules/docker-ports.md +41 -19
- package/templates/.claude/rules/feature-lifecycle.md +14 -3
- package/templates/.claude/rules/honest-configuration.md +54 -0
- package/templates/.claude/rules/insights-capture.md +10 -5
- package/templates/.claude/rules/replicate-pipeline.md +4 -2
- package/templates/.claude/rules/skill-interface-protocol.md +1 -0
- package/templates/.claude/rules/swarm-file-evidence.md +46 -0
- package/templates/.claude/settings.json +13 -1
- package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +86 -16
- package/tests/e2e/lifecycle.test.js +55 -9
- package/tests/e2e/packed-insights-writer.test.js +308 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
- package/tests/snapshot/baseline.json +24 -20
- package/tests/snapshot/templates.test.js +47 -0
- package/tests/unit/absence-is-not-emptiness.test.js +15 -1
- package/tests/unit/check-pipeline-gaps.test.js +94 -0
- package/tests/unit/check-ports.test.js +729 -2
- package/tests/unit/db-port-rule.test.js +36 -5
- package/tests/unit/detection-ladder-contract.test.js +302 -0
- package/tests/unit/detection-ladder-registry.test.js +52 -0
- package/tests/unit/doctor-insight-flow.test.js +315 -0
- package/tests/unit/external-dependency-check.test.js +19 -19
- package/tests/unit/honest-failure-rules.test.js +492 -0
- package/tests/unit/hooks-project-anchored.test.js +67 -3
- package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
- package/tests/unit/insights-dz-delegation.test.js +197 -0
- package/tests/unit/insights-writer.test.js +285 -0
- package/tests/unit/shipped-suite-context.test.js +3 -1
- package/tests/unit/traceability-machine-ids.test.js +413 -0
- package/tests/unit/traceability-negative-fixture.test.js +322 -0
- package/tests/unit/utils.test.js +3 -2
- package/LICENSE +0 -21
|
@@ -100,8 +100,10 @@ describe('a database port is not published to the internet (PR-012)', () => {
|
|
|
100
100
|
'the limit needs its own heading');
|
|
101
101
|
assert.match(src, /не привязана ни к какому событию — её надо позвать/,
|
|
102
102
|
'a check nobody invokes guarantees nothing, and the rule must say so');
|
|
103
|
-
assert.match(src,
|
|
104
|
-
'and name
|
|
103
|
+
assert.match(src, /соседн(?:ий|его) проект[\s\S]{0,180}интернет/i,
|
|
104
|
+
'and name the catalog exposure blind spot, so nobody infers a machine-wide guarantee');
|
|
105
|
+
assert.match(src, /check-ports\.cjs --machine/,
|
|
106
|
+
'and provide the deliberate machine-snapshot check that answers the wider question');
|
|
105
107
|
assert.match(src, /проверка НЕ\s*\n?ВЫПОЛНЕНА/,
|
|
106
108
|
'and the third exit code must be documented where a reader will look for it');
|
|
107
109
|
});
|
|
@@ -138,7 +140,10 @@ describe('a database port is not published to the internet (PR-012)', () => {
|
|
|
138
140
|
// Historical documents are exempt BY NAME: a changelog entry describing what 1.4 shipped is a
|
|
139
141
|
// true statement about the past, and rewriting it would be falsifying a record.
|
|
140
142
|
const HISTORICAL = /changelog/i;
|
|
141
|
-
const COUNT_CLAIM = /(\d+)\s+rules\b|(\d+)\s+правил|Rules\
|
|
143
|
+
const COUNT_CLAIM = /(\d+)\s+rules\b|(\d+)\s+правил|Rules[ \t]+(?:●|\()?(\d+)\)?|\*\*(\d+) rules\*\*/g;
|
|
144
|
+
const headingThenList = '## Rules\n\n1. First workflow rule';
|
|
145
|
+
assert.deepEqual([...headingThenList.matchAll(COUNT_CLAIM)], [],
|
|
146
|
+
'`Rules` heading followed by item 1 is not a claim that the package ships one rule');
|
|
142
147
|
const scanned = [];
|
|
143
148
|
const walk = (dir) => {
|
|
144
149
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -169,8 +174,13 @@ describe('a database port is not published to the internet (PR-012)', () => {
|
|
|
169
174
|
const before = text.slice(0, hit.index);
|
|
170
175
|
const headings = before.match(/^#{1,3} .*$|^<h[1-3][^>]*>.*$/gm) || [];
|
|
171
176
|
const nearest = headings.length ? headings[headings.length - 1] : '';
|
|
172
|
-
const
|
|
173
|
-
|
|
177
|
+
const headingText = nearest
|
|
178
|
+
.replace(/^#{1,3}\s*/, '')
|
|
179
|
+
.replace(/^<h[1-3][^>]*>/i, '')
|
|
180
|
+
.replace(/<\/h[1-3]>$/i, '')
|
|
181
|
+
.trim();
|
|
182
|
+
const historicalSection = /^(?:changelog|release notes|version history|история версий|\[?v?\d+\.\d+)/i;
|
|
183
|
+
if (historicalSection.test(headingText)) continue;
|
|
174
184
|
// and an explicit past-tense marker on the line itself
|
|
175
185
|
const lineStart = before.lastIndexOf('\n') + 1;
|
|
176
186
|
const line = text.slice(lineStart, text.indexOf('\n', hit.index));
|
|
@@ -213,4 +223,25 @@ describe('a database port is not published to the internet (PR-012)', () => {
|
|
|
213
223
|
assert.ok(files.filter((f) => f.endsWith('.cjs')).length >= 6,
|
|
214
224
|
'and the six shipped hooks must still be .cjs: ' + JSON.stringify(files));
|
|
215
225
|
});
|
|
226
|
+
|
|
227
|
+
test('P26 — docker-ports disclaimer names exposure and publish-address semantics', () => {
|
|
228
|
+
const src = read(RULE);
|
|
229
|
+
assert.match(src, /ЭКСПОЗИЦ/i,
|
|
230
|
+
'the consequence is security exposure, not an availability-only port conflict');
|
|
231
|
+
assert.match(src, /соседн(?:ий|его) проект/i,
|
|
232
|
+
'catalog scope must disclose the neighboring-project blind spot');
|
|
233
|
+
assert.match(src, /интернет/i, 'the consequence must name internet exposure');
|
|
234
|
+
assert.match(src, /check-ports\.cjs --machine/,
|
|
235
|
+
'the rule must give the deliberate machine-snapshot invocation');
|
|
236
|
+
assert.match(src, /HOST_IP:HOST_PORT:CONTAINER_PORT/,
|
|
237
|
+
'the three-part syntax is the address-bearing form');
|
|
238
|
+
assert.match(src, /\$\{DB_PORT:-11432\}:5432/,
|
|
239
|
+
'the common two-part form is explained directly');
|
|
240
|
+
assert.match(src, /по умолчанию[\s\S]{0,100}0\.0\.0[\s\S]{0,100}все интерфейсы/i,
|
|
241
|
+
'omitting HOST_IP defaults publication to all interfaces');
|
|
242
|
+
assert.match(src, /Redis\/Memcached[\s\S]{0,240}отсутствие[\s\S]{0,160}`1`/i,
|
|
243
|
+
'the locked catalog authentication consequence is documented');
|
|
244
|
+
assert.ok(!/конфликты с портами, уже занятыми другими контейнерами, она не ищет/.test(src),
|
|
245
|
+
'the former availability consequence must not survive as the catalog-scope disclaimer');
|
|
246
|
+
});
|
|
216
247
|
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { execFileSync } = require('node:child_process');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const Module = require('node:module');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const { Worker } = require('node:worker_threads');
|
|
11
|
+
|
|
12
|
+
const PKG_DIR = path.resolve(__dirname, '..', '..');
|
|
13
|
+
const RULE_DIR = path.join(PKG_DIR, 'templates', '.claude', 'rules');
|
|
14
|
+
const RULE_FILE = 'cost-of-detection-ladder.md';
|
|
15
|
+
const RULE_PATH = path.join(RULE_DIR, RULE_FILE);
|
|
16
|
+
const TEST_PATH = 'tests/unit/detection-ladder-contract.test.js';
|
|
17
|
+
const CLI = path.join(PKG_DIR, 'bin', 'cli.js');
|
|
18
|
+
|
|
19
|
+
const read = (file) => fs.readFileSync(file, 'utf8');
|
|
20
|
+
|
|
21
|
+
function npmPackDryRunFiles() {
|
|
22
|
+
const npmCli = process.env.npm_execpath && fs.existsSync(process.env.npm_execpath)
|
|
23
|
+
? process.env.npm_execpath
|
|
24
|
+
: path.join(
|
|
25
|
+
path.dirname(require.resolve('npm/package.json', { paths: Module.globalPaths })),
|
|
26
|
+
'bin',
|
|
27
|
+
'npm-cli.js');
|
|
28
|
+
const cache = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-npm-pack-cache-'));
|
|
29
|
+
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
let stdout = '';
|
|
32
|
+
let stderr = '';
|
|
33
|
+
const worker = new Worker(`
|
|
34
|
+
const { workerData } = require('node:worker_threads');
|
|
35
|
+
process.argv = ['node', 'npm', 'pack', workerData.pkgDir, '--dry-run', '--json'];
|
|
36
|
+
process.env.npm_config_cache = workerData.cache;
|
|
37
|
+
require(workerData.npmCli);
|
|
38
|
+
`, {
|
|
39
|
+
eval: true,
|
|
40
|
+
workerData: { npmCli, pkgDir: PKG_DIR, cache },
|
|
41
|
+
stdout: true,
|
|
42
|
+
stderr: true,
|
|
43
|
+
});
|
|
44
|
+
worker.stdout.setEncoding('utf8');
|
|
45
|
+
worker.stderr.setEncoding('utf8');
|
|
46
|
+
worker.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
47
|
+
worker.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
48
|
+
worker.once('error', (error) => {
|
|
49
|
+
fs.rmSync(cache, { recursive: true, force: true });
|
|
50
|
+
reject(error);
|
|
51
|
+
});
|
|
52
|
+
worker.once('exit', (code) => {
|
|
53
|
+
fs.rmSync(cache, { recursive: true, force: true });
|
|
54
|
+
if (code !== 0) {
|
|
55
|
+
reject(new Error(`npm pack --dry-run --json exited ${code}: ${stderr}`));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const result = JSON.parse(stdout);
|
|
60
|
+
resolve(result[0].files.map((entry) => entry.path));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
reject(new Error(`npm pack --dry-run --json returned invalid JSON: ${error.message}`));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ladderProblems(text) {
|
|
69
|
+
const problems = [];
|
|
70
|
+
const layers = [
|
|
71
|
+
/### Layer 1 — Deterministic test, CI check, or static guard/i,
|
|
72
|
+
/### Layer 2 — Always-loaded governance/i,
|
|
73
|
+
/### Layer 3 — Pipeline gate/i,
|
|
74
|
+
/### Layer 4 — Skill or reviewer judgment/i,
|
|
75
|
+
/### Layer 5 — Agent memory or informal recall/i,
|
|
76
|
+
];
|
|
77
|
+
let previous = -1;
|
|
78
|
+
for (const layer of layers) {
|
|
79
|
+
const index = text.search(layer);
|
|
80
|
+
if (index < 0) problems.push(`missing ${layer}`);
|
|
81
|
+
if (index >= 0 && index <= previous) problems.push(`out of order ${layer}`);
|
|
82
|
+
previous = Math.max(previous, index);
|
|
83
|
+
}
|
|
84
|
+
if (!/strongest layer that can reliably express the property/i.test(text)) {
|
|
85
|
+
problems.push('missing strongest-capable-layer rule');
|
|
86
|
+
}
|
|
87
|
+
if (!/critic\/reviewer will catch it/i.test(text)) {
|
|
88
|
+
problems.push('missing critic/reviewer anti-pattern');
|
|
89
|
+
}
|
|
90
|
+
if (!/deterministic propert(?:y|ies)[\s\S]{0,180}(?:must not|never)[\s\S]{0,120}probabilistic review/i.test(text)) {
|
|
91
|
+
problems.push('anti-pattern does not protect deterministic properties');
|
|
92
|
+
}
|
|
93
|
+
return problems;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function bookReinforcementProblems(text) {
|
|
97
|
+
const problems = [];
|
|
98
|
+
const mappings = [
|
|
99
|
+
/Static structure or format\s*\|\s*Static check or deterministic test/i,
|
|
100
|
+
/Local behavior\s*\|\s*Unit test/i,
|
|
101
|
+
/Component interaction\s*\|\s*Integration or contract test/i,
|
|
102
|
+
/Behavior over time\s*\|\s*Monitor with a defined threshold or invariant/i,
|
|
103
|
+
/Runtime quantity\s*\|\s*Metric and threshold alert/i,
|
|
104
|
+
/Discrete transition\s*\|\s*Event or audit check/i,
|
|
105
|
+
/Failure resilience\s*\|\s*Controlled fault injection/i,
|
|
106
|
+
/Semantic or adversarial property\s*\|\s*Independent model-backed review gate/i,
|
|
107
|
+
];
|
|
108
|
+
if (!/check kind and enforcement layer are separate axes/i.test(text)) {
|
|
109
|
+
problems.push('missing two-axis decision');
|
|
110
|
+
}
|
|
111
|
+
for (const mapping of mappings) {
|
|
112
|
+
if (!mapping.test(text)) problems.push(`missing signal mapping ${mapping}`);
|
|
113
|
+
}
|
|
114
|
+
if (!/\| Cause \/ property \| Observable signal \| Check kind \| Layer \| Trigger \/ cadence \| Reaction \| Owner \|/i.test(text)) {
|
|
115
|
+
problems.push('missing closed-loop Reaction column');
|
|
116
|
+
}
|
|
117
|
+
if (!/Reaction must name a concrete action/i.test(text)) {
|
|
118
|
+
problems.push('Reaction is not mandatory and concrete');
|
|
119
|
+
}
|
|
120
|
+
if (!/block or return the change|repair the practice|revisit the decision/i.test(text)) {
|
|
121
|
+
problems.push('missing concrete reaction choices');
|
|
122
|
+
}
|
|
123
|
+
return problems;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function privateMarkerHits(text) {
|
|
127
|
+
const markers = [
|
|
128
|
+
/dz-harness-hub/i,
|
|
129
|
+
/\/home\/dz-projects-2026/i,
|
|
130
|
+
/packages\/@dzhechkov/i,
|
|
131
|
+
/\.claude\/rules\/feature-adr-conventions\.md/i,
|
|
132
|
+
/harness-core|harness-cli|health-advisor/i,
|
|
133
|
+
/\bdz guard\b/i,
|
|
134
|
+
/\bfeatures\//i,
|
|
135
|
+
/claim-check|discrimination-check|amendment gate|fa-improvements/i,
|
|
136
|
+
];
|
|
137
|
+
return markers.filter((marker) => marker.test(text)).map(String);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function backlinkProblems(ruleDir) {
|
|
141
|
+
const allusion = /cost.of.detection|strongest.*layer|layer [1-5]|слой [1-5]|лестниц/i;
|
|
142
|
+
const linked = /\[[^\]]+\]\((?:\.\/)?cost-of-detection-ladder\.md\)/i;
|
|
143
|
+
const linkLine = /\[[^\]]+\]\((?:\.\/)?cost-of-detection-ladder\.md\)/i;
|
|
144
|
+
const problems = [];
|
|
145
|
+
for (const name of fs.readdirSync(ruleDir).filter((entry) => entry.endsWith('.md'))) {
|
|
146
|
+
if (name === RULE_FILE) continue;
|
|
147
|
+
const text = read(path.join(ruleDir, name));
|
|
148
|
+
const allusionText = text
|
|
149
|
+
.split(/\r?\n/)
|
|
150
|
+
.filter((line) => !linkLine.test(line))
|
|
151
|
+
.join('\n');
|
|
152
|
+
if (allusion.test(allusionText) && !linked.test(text)) problems.push(name);
|
|
153
|
+
}
|
|
154
|
+
return problems;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function registryProblems(ruleDir, registryKeys) {
|
|
158
|
+
const files = fs.readdirSync(ruleDir)
|
|
159
|
+
.filter((entry) => entry.endsWith('.md'))
|
|
160
|
+
.map((entry) => path.basename(entry, '.md'))
|
|
161
|
+
.sort();
|
|
162
|
+
const keys = [...registryKeys].sort();
|
|
163
|
+
return files.length === keys.length && files.every((name, index) => name === keys[index])
|
|
164
|
+
? []
|
|
165
|
+
: [`template rules ${JSON.stringify(files)} != registry ${JSON.stringify(keys)}`];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function runnerProblems(pkg) {
|
|
169
|
+
return ['test', 'test:unit'].filter((script) => !pkg.scripts?.[script]?.includes(TEST_PATH));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function packagingProblems(pkg) {
|
|
173
|
+
return pkg.files?.includes('templates/') ? [] : ['package files[] omits templates/'];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function scopeProblems(files) {
|
|
177
|
+
const packagePrefix = 'packages/@dzhechkov/p-replicator/';
|
|
178
|
+
const manifest = 'features/ship-detection-ladder/07_code_changes/change_manifest.md';
|
|
179
|
+
return files.filter((file) => !file.startsWith(packagePrefix) && file !== manifest);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
test('P1 — consumer ladder preserves the ordered contract and rejects an unlinked real mention', () => {
|
|
183
|
+
const canonical = read(RULE_PATH);
|
|
184
|
+
assert.deepEqual(ladderProblems(canonical), []);
|
|
185
|
+
assert.deepEqual(backlinkProblems(RULE_DIR), []);
|
|
186
|
+
|
|
187
|
+
const withoutLayerThree = canonical.replace(
|
|
188
|
+
/\n### Layer 3 — Pipeline gate[\s\S]*?(?=\n### Layer 4)/, '');
|
|
189
|
+
assert.ok(ladderProblems(withoutLayerThree).length > 0,
|
|
190
|
+
'removing a load-bearing layer must fire the ordered-contract safeguard');
|
|
191
|
+
|
|
192
|
+
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-ladder-link-'));
|
|
193
|
+
try {
|
|
194
|
+
fs.writeFileSync(path.join(fixture, RULE_FILE), canonical);
|
|
195
|
+
fs.writeFileSync(path.join(fixture, 'unlinked-rule.md'), 'Put this safeguard on layer 3.\n');
|
|
196
|
+
assert.deepEqual(backlinkProblems(fixture), ['unlinked-rule.md'],
|
|
197
|
+
'a real unlinked allusion must fire the backlink safeguard');
|
|
198
|
+
} finally {
|
|
199
|
+
fs.rmSync(fixture, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const requiredRule of ['skill-interface-protocol.md', 'feature-lifecycle.md']) {
|
|
203
|
+
const withoutLink = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-ladder-required-link-'));
|
|
204
|
+
try {
|
|
205
|
+
fs.cpSync(RULE_DIR, withoutLink, { recursive: true });
|
|
206
|
+
const requiredPath = path.join(withoutLink, requiredRule);
|
|
207
|
+
fs.writeFileSync(requiredPath, read(requiredPath).replace(
|
|
208
|
+
/\[([^\]]+)\]\((?:\.\/)?cost-of-detection-ladder\.md\)/i,
|
|
209
|
+
'$1'));
|
|
210
|
+
assert.deepEqual(backlinkProblems(withoutLink), [requiredRule],
|
|
211
|
+
`removing the ADR-required link from ${requiredRule} must fire the backlink safeguard`);
|
|
212
|
+
} finally {
|
|
213
|
+
fs.rmSync(withoutLink, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('P2 — self-containment gate rejects a real internal-path leak fixture', () => {
|
|
219
|
+
const canonical = read(RULE_PATH);
|
|
220
|
+
assert.deepEqual(privateMarkerHits(canonical), []);
|
|
221
|
+
const leaked = canonical + '\nSee .claude/rules/feature-adr-conventions.md for details.\n';
|
|
222
|
+
assert.ok(privateMarkerHits(leaked).length > 0,
|
|
223
|
+
'the literal private conventions path must fire the self-containment safeguard');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('P3 — both book-grounded decisions are recorded and reflected in the template', () => {
|
|
227
|
+
const canonical = read(RULE_PATH);
|
|
228
|
+
assert.deepEqual(bookReinforcementProblems(canonical), []);
|
|
229
|
+
|
|
230
|
+
const withoutKind = canonical.replace(
|
|
231
|
+
/\n## Choose the check kind from the signal[\s\S]*?(?=\n## )/, '');
|
|
232
|
+
assert.ok(bookReinforcementProblems(withoutKind).length > 0,
|
|
233
|
+
'removing check-kind-by-signal must fire the decision guard');
|
|
234
|
+
|
|
235
|
+
const withoutReaction = canonical.replace(
|
|
236
|
+
/\n## Close the loop with a reaction[\s\S]*?(?=\n## )/, '');
|
|
237
|
+
assert.ok(bookReinforcementProblems(withoutReaction).length > 0,
|
|
238
|
+
'removing the mandatory Reaction contract must fire the decision guard');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('P4 — rule registry rejects a real orphan and fresh init ships the ladder', () => {
|
|
242
|
+
const { COMPONENTS } = require(path.join(PKG_DIR, 'src', 'utils.js'));
|
|
243
|
+
const keys = Object.keys(COMPONENTS.rules.items);
|
|
244
|
+
assert.deepEqual(registryProblems(RULE_DIR, keys), []);
|
|
245
|
+
|
|
246
|
+
const orphanDir = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-ladder-orphan-'));
|
|
247
|
+
try {
|
|
248
|
+
fs.cpSync(RULE_DIR, orphanDir, { recursive: true });
|
|
249
|
+
fs.writeFileSync(path.join(orphanDir, 'orphan.md'), '# Orphan\n');
|
|
250
|
+
assert.ok(registryProblems(orphanDir, keys).length > 0,
|
|
251
|
+
'a real orphan Markdown rule must fire the registry safeguard');
|
|
252
|
+
} finally {
|
|
253
|
+
fs.rmSync(orphanDir, { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
assert.ok(registryProblems(RULE_DIR, keys.filter((key) => key !== 'cost-of-detection-ladder')).length > 0,
|
|
257
|
+
'a cloned registry missing the ladder slug must fire the registry safeguard');
|
|
258
|
+
|
|
259
|
+
const consumer = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-ladder-consumer-'));
|
|
260
|
+
try {
|
|
261
|
+
execFileSync(process.execPath, [CLI, 'init'], { cwd: consumer, stdio: 'pipe' });
|
|
262
|
+
const installed = read(path.join(consumer, '.claude', 'rules', RULE_FILE));
|
|
263
|
+
assert.deepEqual(ladderProblems(installed), []);
|
|
264
|
+
} finally {
|
|
265
|
+
fs.rmSync(consumer, { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('P5 — package runner and scope guards reject omitted and out-of-scope inputs', () => {
|
|
270
|
+
const pkg = JSON.parse(read(path.join(PKG_DIR, 'package.json')));
|
|
271
|
+
assert.deepEqual(runnerProblems(pkg), []);
|
|
272
|
+
|
|
273
|
+
for (const script of ['test', 'test:unit']) {
|
|
274
|
+
const omitted = JSON.parse(JSON.stringify(pkg));
|
|
275
|
+
omitted.scripts[script] = omitted.scripts[script].replace(TEST_PATH, '');
|
|
276
|
+
assert.deepEqual(runnerProblems(omitted), [script],
|
|
277
|
+
`removing the focused file from the ${script} runner must fire the safeguard`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const allowed = [
|
|
281
|
+
'packages/@dzhechkov/p-replicator/templates/.claude/rules/cost-of-detection-ladder.md',
|
|
282
|
+
'features/ship-detection-ladder/07_code_changes/change_manifest.md',
|
|
283
|
+
];
|
|
284
|
+
assert.deepEqual(scopeProblems(allowed), []);
|
|
285
|
+
assert.deepEqual(
|
|
286
|
+
scopeProblems([...allowed, 'packages/@dzhechkov/harness-cli/src/forbidden.js']),
|
|
287
|
+
['packages/@dzhechkov/harness-cli/src/forbidden.js'],
|
|
288
|
+
'a real out-of-scope package path must fire the scope safeguard');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('A2 — package files include templates and the actual npm pack listing contains the ladder', async () => {
|
|
292
|
+
const pkg = JSON.parse(read(path.join(PKG_DIR, 'package.json')));
|
|
293
|
+
assert.deepEqual(packagingProblems(pkg), []);
|
|
294
|
+
const omitted = JSON.parse(JSON.stringify(pkg));
|
|
295
|
+
omitted.files = omitted.files.filter((entry) => entry !== 'templates/');
|
|
296
|
+
assert.deepEqual(packagingProblems(omitted), ['package files[] omits templates/'],
|
|
297
|
+
'a cloned files[] without templates/ must fire the distribution safeguard');
|
|
298
|
+
const packedFiles = await npmPackDryRunFiles();
|
|
299
|
+
assert.ok(
|
|
300
|
+
packedFiles.includes(`templates/.claude/rules/${RULE_FILE}`),
|
|
301
|
+
`actual npm pack listing omits templates/.claude/rules/${RULE_FILE}`);
|
|
302
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const PKG_DIR = path.resolve(__dirname, '..', '..');
|
|
9
|
+
|
|
10
|
+
test('the nine-rule contract retains the detection ladder', () => {
|
|
11
|
+
const { COMPONENTS } = require(path.join(PKG_DIR, 'src', 'utils.js'));
|
|
12
|
+
const rules = Object.keys(COMPONENTS.rules.items);
|
|
13
|
+
assert.equal(rules.length, 9, 'the pre-shipped contract must require nine rules');
|
|
14
|
+
assert.ok(rules.includes('cost-of-detection-ladder'),
|
|
15
|
+
'doctor and verify must require cost-of-detection-ladder');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('P13 - PR-022 writer guards are registered for targeted mutation proof', () => {
|
|
19
|
+
const registry = JSON.parse(fs.readFileSync(
|
|
20
|
+
path.join(PKG_DIR, 'test', 'mutation-registry.json'), 'utf8'));
|
|
21
|
+
const expected = {
|
|
22
|
+
'harvest-required-writer-receipt': {
|
|
23
|
+
file: 'templates/.claude/commands/harvest.md', test: 'P1 - quick and full reject',
|
|
24
|
+
},
|
|
25
|
+
'harvest-first-write-creates-missing-carrier': {
|
|
26
|
+
file: 'templates/.claude/hooks/write-insight.cjs', test: 'P2 - writer creates',
|
|
27
|
+
},
|
|
28
|
+
'harvest-exact-repeat-is-idempotent': {
|
|
29
|
+
file: 'templates/.claude/hooks/write-insight.cjs', test: 'P2 - writer creates',
|
|
30
|
+
},
|
|
31
|
+
'session-insights-missing-is-visible': {
|
|
32
|
+
file: 'templates/.claude/hooks/session-insights.cjs', test: 'P8 - missing store triggers',
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
for (const [id, contract] of Object.entries(expected)) {
|
|
37
|
+
const entry = registry.entries.find((candidate) => candidate.id === id);
|
|
38
|
+
assert.ok(entry, `missing targeted mutation id ${id}`);
|
|
39
|
+
assert.equal(entry.file, contract.file, `${id} mutates the wrong production surface`);
|
|
40
|
+
assert.ok(entry.property && entry.property.length > 40,
|
|
41
|
+
`${id} needs a behavioral property, not a cosmetic label`);
|
|
42
|
+
assert.ok(entry.mutation?.find && entry.mutation?.replace,
|
|
43
|
+
`${id} needs a real source mutation selected by --only`);
|
|
44
|
+
assert.ok(Number.isInteger(entry.minFailing) && entry.minFailing >= 1,
|
|
45
|
+
`${id} must require at least one failing behavioral test`);
|
|
46
|
+
assert.match(entry.property, new RegExp(contract.test.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
|
47
|
+
`${id} must name the real-input confirmation test that kills it`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
assert.equal(registry.entries.filter((entry) => expected[entry.id]).length, 4,
|
|
51
|
+
'all four and only the planned PR-022 guards must resolve by exact id');
|
|
52
|
+
});
|