@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
|
@@ -0,0 +1,492 @@
|
|
|
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 ROOT = path.join(PKG, 'templates', '.claude');
|
|
13
|
+
const CLI = path.join(PKG, 'bin', 'cli.js');
|
|
14
|
+
const RULES = ['honest-configuration', 'swarm-file-evidence'];
|
|
15
|
+
const FRESH_BEFORE = { files: 28, bytes: 317195, estimatedTokens: 80896.1 };
|
|
16
|
+
const ESTIMATED_TRIGGER = 4037.75;
|
|
17
|
+
const SEAMS = [
|
|
18
|
+
['commands/feature.md', 'both'],
|
|
19
|
+
['rules/feature-lifecycle.md', 'both'],
|
|
20
|
+
['commands/harvest.md', 'both'],
|
|
21
|
+
['commands/replicate.md', 'both'],
|
|
22
|
+
['commands/go.md', 'both'],
|
|
23
|
+
['commands/start.md', 'both'],
|
|
24
|
+
['skills/knowledge-extractor/modules/01-agent-review.md', 'both'],
|
|
25
|
+
['agents/harvest-coordinator.md', 'both'],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const read = (rel) => fs.readFileSync(path.join(PKG, rel), 'utf8');
|
|
29
|
+
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
30
|
+
const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
|
|
32
|
+
function swarmContractProblems(text, profile = 'both') {
|
|
33
|
+
const problems = [];
|
|
34
|
+
const dispatch = profile === 'both' || profile === 'dispatch';
|
|
35
|
+
const receipt = profile === 'both' || profile === 'receipt';
|
|
36
|
+
if (dispatch) {
|
|
37
|
+
if (!/WORK_UNIT_ID/.test(text)) problems.push('dispatch does not name WORK_UNIT_ID');
|
|
38
|
+
if (!/TRACE_PATH/.test(text)) problems.push('dispatch does not name TRACE_PATH');
|
|
39
|
+
if (!/absolute/i.test(text)) problems.push('TRACE_PATH is not resolved as absolute');
|
|
40
|
+
if (!/(?:unique|different|distinct)[\s\S]{0,100}(?:WORK_UNIT_ID|TRACE_PATH)|(?:WORK_UNIT_ID|TRACE_PATH)[\s\S]{0,100}(?:unique|different|distinct)/i.test(text)) {
|
|
41
|
+
problems.push('trace path is not unique per work unit');
|
|
42
|
+
}
|
|
43
|
+
const write = text.search(/write[\s\S]{0,100}TRACE_PATH/i);
|
|
44
|
+
const reportAfterWrite = write < 0 ? -1 : text.slice(write)
|
|
45
|
+
.search(/one-line[\s\S]{0,40}(?:pointer|report)|(?:pointer|report)[\s\S]{0,40}one-line/i);
|
|
46
|
+
if (write < 0 || reportAfterWrite < 0) problems.push('worker does not write before report');
|
|
47
|
+
}
|
|
48
|
+
if (receipt) {
|
|
49
|
+
const required = [
|
|
50
|
+
[/regular/i, 'regular-file check missing'],
|
|
51
|
+
[/non-symlink|not a symlink/i, 'symlink refusal missing'],
|
|
52
|
+
[/non-(?:empty|whitespace)|substantive/i, 'substantive-body check missing'],
|
|
53
|
+
[/post-launch|after[\s\S]{0,40}launch|pre-dispatch/i, 'freshness boundary missing'],
|
|
54
|
+
[/Status: completed/, 'completed terminal marker missing'],
|
|
55
|
+
[/Status: failed/, 'failed terminal marker missing'],
|
|
56
|
+
[/(?:narrative|chat)[\s\S]{0,80}(?:not|never)[\s\S]{0,40}(?:receipt|result)|silence[\s\S]{0,80}(?:not|never)/i,
|
|
57
|
+
'narrative or silence can still act as a receipt'],
|
|
58
|
+
[/(?:refuse|block|must not)[\s\S]{0,100}(?:merge|synthesis|aggregation|completion)/i,
|
|
59
|
+
'invalid receipt does not block aggregation'],
|
|
60
|
+
];
|
|
61
|
+
for (const [pattern, message] of required) if (!pattern.test(text)) problems.push(message);
|
|
62
|
+
}
|
|
63
|
+
return problems;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function inspectReceipt({ workUnitId, tracePath, launchMs, liveness = 'unknown', probeError = false,
|
|
67
|
+
forceReadError = false }) {
|
|
68
|
+
if (!workUnitId || !path.isAbsolute(tracePath)) return { state: 'undelivered', reason: 'assignment' };
|
|
69
|
+
if (probeError) return { state: 'inconclusive', reason: 'liveness-probe-error' };
|
|
70
|
+
let stat;
|
|
71
|
+
try { stat = fs.lstatSync(tracePath); } catch (error) {
|
|
72
|
+
if (error.code !== 'ENOENT') return { state: 'inconclusive', reason: 'unreadable' };
|
|
73
|
+
if (liveness === 'live') return { state: 'waiting', reason: 'positive-liveness-only' };
|
|
74
|
+
return { state: 'undelivered', reason: liveness === 'dead' ? 'dead-worker' : 'missing' };
|
|
75
|
+
}
|
|
76
|
+
if (stat.isSymbolicLink() || !stat.isFile()) return { state: 'undelivered', reason: 'not-regular' };
|
|
77
|
+
if (stat.mtimeMs <= launchMs) return { state: 'undelivered', reason: 'stale' };
|
|
78
|
+
let body;
|
|
79
|
+
try {
|
|
80
|
+
if (forceReadError) throw new Error('fixture read failure');
|
|
81
|
+
body = fs.readFileSync(tracePath, 'utf8');
|
|
82
|
+
} catch { return { state: 'inconclusive', reason: 'unreadable' }; }
|
|
83
|
+
if (!body.trim()) return { state: 'undelivered', reason: 'empty' };
|
|
84
|
+
const lines = body.trimEnd().split(/\r?\n/);
|
|
85
|
+
const terminal = lines.at(-1);
|
|
86
|
+
if (!/^Status: (completed|failed)$/.test(terminal)) return { state: 'undelivered', reason: 'non-terminal' };
|
|
87
|
+
if (!lines.slice(0, -1).join('\n').trim()) return { state: 'undelivered', reason: 'empty-payload' };
|
|
88
|
+
return terminal === 'Status: completed'
|
|
89
|
+
? { state: 'completed', reason: 'terminal-receipt' }
|
|
90
|
+
: { state: 'failed', reason: 'delivered-failure' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function decideConfiguration(fixture) {
|
|
94
|
+
if (fixture.optionalDependency && fixture.fallbackCannotAlterExternalOutput) return 'OPTIONAL_FALLBACK';
|
|
95
|
+
if (fixture.phase === 'build' && fixture.cannotEmitExternalOutput) return 'BOUNDED_BUILD_SUBSTITUTE';
|
|
96
|
+
if (fixture.declared && !fixture.readByDecision) return 'REFUSE';
|
|
97
|
+
if (fixture.authority === 'unreachable') return 'UNKNOWN';
|
|
98
|
+
if (fixture.ratio && fixture.ratio.denominator === 0) return 'UNAVAILABLE';
|
|
99
|
+
if (fixture.required && fixture.value === undefined) return 'REFUSE';
|
|
100
|
+
if (fixture.required && fixture.value === '') return 'REFUSE';
|
|
101
|
+
if (fixture.value === '/' && fixture.kind === 'base-url') return 'REFUSE';
|
|
102
|
+
if (fixture.kind === 'cidr' && fixture.value === '/0' && fixture.derivedFromEmpty) return 'REFUSE';
|
|
103
|
+
if (fixture.kind === 'allowlist' && Array.isArray(fixture.value) && fixture.value.length === 0) return 'REFUSE';
|
|
104
|
+
if (fixture.recognized === false) return 'REFUSE';
|
|
105
|
+
return 'ACCEPT';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseDecisionRows(text) {
|
|
109
|
+
const rows = new Map();
|
|
110
|
+
for (const line of text.split(/\r?\n/)) {
|
|
111
|
+
const match = line.match(/^\|\s*(CFG-[SI]\d+)\s*\|\s*([^|]+)\|\s*([^|]+)\|/);
|
|
112
|
+
if (match) rows.set(match[1], { signal: match[2].trim(), response: match[3].trim() });
|
|
113
|
+
}
|
|
114
|
+
return rows;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function configurationContractProblems(text) {
|
|
118
|
+
const problems = [];
|
|
119
|
+
if (!/^### Substitution axis$/m.test(text)) problems.push('substitution axis missing');
|
|
120
|
+
if (!/^### Interpretation axis$/m.test(text)) problems.push('interpretation axis missing');
|
|
121
|
+
const rows = parseDecisionRows(text);
|
|
122
|
+
const required = {
|
|
123
|
+
'CFG-S1': /REFUSE/,
|
|
124
|
+
'CFG-S2': /REFUSE/,
|
|
125
|
+
'CFG-I1': /REFUSE|UNKNOWN/,
|
|
126
|
+
'CFG-I2': /REFUSE/,
|
|
127
|
+
'CFG-I3': /REFUSE/,
|
|
128
|
+
'CFG-I4': /REFUSE|UNKNOWN/,
|
|
129
|
+
'CFG-I5': /REFUSE/,
|
|
130
|
+
'CFG-I6': /REFUSE/,
|
|
131
|
+
'CFG-I7': /UNAVAILABLE|UNKNOWN/,
|
|
132
|
+
'CFG-I8': /CODE-OWNED/,
|
|
133
|
+
};
|
|
134
|
+
for (const [id, allowed] of Object.entries(required)) {
|
|
135
|
+
const row = rows.get(id);
|
|
136
|
+
if (!row) problems.push(`${id} missing`);
|
|
137
|
+
else if (!allowed.test(row.response)) problems.push(`${id} is fail-open: ${row.response}`);
|
|
138
|
+
}
|
|
139
|
+
const collapsed = rows.get('CFG-I1')?.signal === rows.get('CFG-I2')?.signal;
|
|
140
|
+
if (collapsed) problems.push('undefined and empty string are collapsed');
|
|
141
|
+
return problems;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function ruleShapeProblems(text) {
|
|
145
|
+
const problems = [];
|
|
146
|
+
for (const heading of ['Rule', 'Mechanics', 'Bounded exception', 'Observable violation → replacement', 'Self-check']) {
|
|
147
|
+
if (!new RegExp(`^## ${heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm').test(text)) {
|
|
148
|
+
problems.push(`${heading} section missing`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (/be careful|будьте внимательны/i.test(text)) problems.push('non-actionable admonition');
|
|
152
|
+
if (/\/home\/|dz brain|dz-harness-hub/.test(text)) problems.push('private runtime dependency');
|
|
153
|
+
return problems;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function walkSkills(dir, out) {
|
|
157
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
158
|
+
const file = path.join(dir, entry.name);
|
|
159
|
+
if (entry.isDirectory()) walkSkills(file, out);
|
|
160
|
+
else if (entry.name === 'SKILL.md') out.push(file);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function measureCorpus(root) {
|
|
165
|
+
const files = [];
|
|
166
|
+
for (const dir of ['rules', 'commands']) {
|
|
167
|
+
for (const name of fs.readdirSync(path.join(root, dir)).sort()) {
|
|
168
|
+
if (name.endsWith('.md')) files.push(path.join(root, dir, name));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
walkSkills(path.join(root, 'skills'), files);
|
|
172
|
+
const rows = files.map((file) => {
|
|
173
|
+
const bytes = fs.readFileSync(file);
|
|
174
|
+
const cyrillic = (bytes.toString('utf8').match(/[\u0400-\u04ff]/g) || []).length;
|
|
175
|
+
const divisor = (cyrillic * 2) / bytes.length > 0.5 ? 2.5 : 4;
|
|
176
|
+
return { file, bytes: bytes.length, estimatedTokens: bytes.length / divisor };
|
|
177
|
+
});
|
|
178
|
+
return rows.reduce((total, row) => ({
|
|
179
|
+
files: total.files + 1,
|
|
180
|
+
bytes: total.bytes + row.bytes,
|
|
181
|
+
estimatedTokens: total.estimatedTokens + row.estimatedTokens,
|
|
182
|
+
}), { files: 0, bytes: 0, estimatedTokens: 0 });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function deltaProblems({ before, after, labels, complete = true }) {
|
|
186
|
+
const problems = [];
|
|
187
|
+
if (!complete) problems.push('measurement input unreadable');
|
|
188
|
+
if (!labels.includes('unconditional rules+commands')) problems.push('unconditional load surface unlabeled');
|
|
189
|
+
if (!labels.includes('invocation-dependent skills')) problems.push('skills mislabeled as unconditional');
|
|
190
|
+
if (after.estimatedTokens - before.estimatedTokens > ESTIMATED_TRIGGER) problems.push('compression required');
|
|
191
|
+
return problems;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function boundaryProblems(slugs) {
|
|
195
|
+
const actual = [...slugs].sort();
|
|
196
|
+
return actual.length === 2 && RULES.every((slug) => actual.includes(slug))
|
|
197
|
+
? [] : [`expected exactly ${RULES.join(' + ')}, got ${actual.join(' + ')}`];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function seamInventoryProblems(paths) {
|
|
201
|
+
const expected = SEAMS.map(([file]) => file).sort();
|
|
202
|
+
const actual = [...new Set(paths)].sort();
|
|
203
|
+
return actual.length === expected.length && expected.every((file) => actual.includes(file))
|
|
204
|
+
? [] : [`owned seam inventory mismatch: ${actual.join(', ')}`];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function countClaimProblems(text, expected) {
|
|
208
|
+
const claim = /(\d+)\s+rules\b|(\d+)\s+правил|Rules[ \t]+(?:●|\()?(\d+)|\*\*(\d+) rules\*\*/g;
|
|
209
|
+
return [...text.matchAll(claim)]
|
|
210
|
+
.map((match) => Number(match[1] || match[2] || match[3] || match[4]))
|
|
211
|
+
.filter((number) => number === expected || number === 7)
|
|
212
|
+
.filter((number) => number !== expected)
|
|
213
|
+
.map((number) => `current rule count says ${number}, expected ${expected}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function packageExportProblems(files) {
|
|
217
|
+
const required = ['templates', 'tests', '.dz-manifest.json', 'sbom.json'];
|
|
218
|
+
const normalized = files.map((entry) => entry.replace(/\/$/, ''));
|
|
219
|
+
return required.filter((entry) => !normalized.includes(entry)).map((entry) => `package export omits ${entry}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function assignmentProblems(assignments) {
|
|
223
|
+
const problems = [];
|
|
224
|
+
const paths = assignments.map((entry) => entry.tracePath);
|
|
225
|
+
if (paths.some((tracePath) => !path.isAbsolute(tracePath))) problems.push('TRACE_PATH is not absolute');
|
|
226
|
+
if (new Set(paths).size !== paths.length) problems.push('duplicate TRACE_PATH');
|
|
227
|
+
if (new Set(assignments.map((entry) => entry.workUnitId)).size !== assignments.length) {
|
|
228
|
+
problems.push('duplicate WORK_UNIT_ID');
|
|
229
|
+
}
|
|
230
|
+
return problems;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function runCli(dir, command, extra = []) {
|
|
234
|
+
const result = spawnSync(process.execPath, [CLI, command, ...extra], {
|
|
235
|
+
cwd: dir, encoding: 'utf8', timeout: 30000,
|
|
236
|
+
});
|
|
237
|
+
return { code: result.status, out: `${result.stdout || ''}${result.stderr || ''}` };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
test('P0 - ADR boundary is one feature with exactly two non-duplicated rules', () => {
|
|
241
|
+
assert.deepEqual(boundaryProblems(RULES), []);
|
|
242
|
+
assert.equal(boundaryProblems(['honest-failure']).length, 1,
|
|
243
|
+
'one catch-all rule must not erase the two reader decisions');
|
|
244
|
+
assert.equal(boundaryProblems(['swarm-file-evidence', 'silent-substitution', 'fail-closed']).length, 1,
|
|
245
|
+
'three intake labels must not become three always-loaded files');
|
|
246
|
+
for (const slug of RULES) {
|
|
247
|
+
assert.ok(fs.existsSync(path.join(ROOT, 'rules', `${slug}.md`)), `missing chosen rule ${slug}`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('P1 - registry and rule filesystem agree on the nine-rule contract', () => {
|
|
252
|
+
const registry = JSON.parse(read('src/rule-components.json'));
|
|
253
|
+
const files = fs.readdirSync(path.join(ROOT, 'rules'))
|
|
254
|
+
.filter((name) => name.endsWith('.md')).map((name) => name.slice(0, -3)).sort();
|
|
255
|
+
assert.equal(Object.keys(registry).length, 9, 'the canonical registry must carry nine rules');
|
|
256
|
+
assert.deepEqual(Object.keys(registry).sort(), files, 'registry and canonical rule files diverged');
|
|
257
|
+
for (const slug of RULES) assert.ok(registry[slug], `registry missing ${slug}`);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('P2 - receipt matrix refuses missing empty stale partial and non-terminal traces', () => {
|
|
261
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-receipt-')));
|
|
262
|
+
const launchMs = Date.now() - 1000;
|
|
263
|
+
const trace = (name) => path.join(dir, `${name}.md`);
|
|
264
|
+
try {
|
|
265
|
+
assert.deepEqual(inspectReceipt({ workUnitId: 'missing', tracePath: trace('missing'), launchMs }),
|
|
266
|
+
{ state: 'undelivered', reason: 'missing' });
|
|
267
|
+
assert.equal(inspectReceipt({ workUnitId: 'live', tracePath: trace('live'), launchMs,
|
|
268
|
+
liveness: 'live' }).state, 'waiting', 'a live PID may extend waiting but is not delivery');
|
|
269
|
+
assert.deepEqual(inspectReceipt({ workUnitId: 'dead', tracePath: trace('dead'), launchMs,
|
|
270
|
+
liveness: 'dead' }), { state: 'undelivered', reason: 'dead-worker' });
|
|
271
|
+
assert.equal(inspectReceipt({ workUnitId: 'probe', tracePath: trace('probe'), launchMs,
|
|
272
|
+
probeError: true }).state, 'inconclusive');
|
|
273
|
+
|
|
274
|
+
fs.writeFileSync(trace('empty'), ' \n');
|
|
275
|
+
assert.equal(inspectReceipt({ workUnitId: 'empty', tracePath: trace('empty'), launchMs }).reason, 'empty');
|
|
276
|
+
fs.writeFileSync(trace('stale'), 'payload\nStatus: completed\n');
|
|
277
|
+
fs.utimesSync(trace('stale'), new Date(launchMs - 5000), new Date(launchMs - 5000));
|
|
278
|
+
assert.equal(inspectReceipt({ workUnitId: 'stale', tracePath: trace('stale'), launchMs }).reason, 'stale');
|
|
279
|
+
fs.writeFileSync(trace('partial'), 'payload without terminal marker\n');
|
|
280
|
+
assert.equal(inspectReceipt({ workUnitId: 'partial', tracePath: trace('partial'), launchMs }).reason,
|
|
281
|
+
'non-terminal');
|
|
282
|
+
fs.writeFileSync(trace('failed'), 'diagnostic\nStatus: failed\n');
|
|
283
|
+
assert.equal(inspectReceipt({ workUnitId: 'failed', tracePath: trace('failed'), launchMs }).state, 'failed');
|
|
284
|
+
fs.writeFileSync(trace('complete'), 'substantive result\nStatus: completed\n');
|
|
285
|
+
assert.equal(inspectReceipt({ workUnitId: 'complete', tracePath: trace('complete'), launchMs }).state,
|
|
286
|
+
'completed');
|
|
287
|
+
assert.equal(inspectReceipt({ workUnitId: 'unreadable', tracePath: trace('complete'), launchMs,
|
|
288
|
+
forceReadError: true }).state, 'inconclusive');
|
|
289
|
+
fs.symlinkSync(trace('complete'), trace('link'));
|
|
290
|
+
assert.equal(inspectReceipt({ workUnitId: 'link', tracePath: trace('link'), launchMs }).reason,
|
|
291
|
+
'not-regular');
|
|
292
|
+
assert.deepEqual(assignmentProblems([
|
|
293
|
+
{ workUnitId: 'a', tracePath: trace('shared') },
|
|
294
|
+
{ workUnitId: 'b', tracePath: trace('shared') },
|
|
295
|
+
]), ['duplicate TRACE_PATH']);
|
|
296
|
+
assert.deepEqual(assignmentProblems([
|
|
297
|
+
{ workUnitId: 'a', tracePath: trace('a') },
|
|
298
|
+
{ workUnitId: 'b', tracePath: trace('b') },
|
|
299
|
+
]), []);
|
|
300
|
+
} finally { cleanup(dir); }
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('P3 - absent malformed and unreachable external inputs refuse instead of becoming plausible output', () => {
|
|
304
|
+
const cases = [
|
|
305
|
+
[{ required: true, value: undefined }, 'REFUSE', 'absent required value'],
|
|
306
|
+
[{ required: true, value: '' }, 'REFUSE', 'empty differs from undefined'],
|
|
307
|
+
[{ required: true, value: '/', kind: 'base-url' }, 'REFUSE', "BASE_URL='/'"],
|
|
308
|
+
[{ required: true, value: '/0', kind: 'cidr', derivedFromEmpty: true }, 'REFUSE', 'empty CIDR'],
|
|
309
|
+
[{ required: true, value: [], kind: 'allowlist' }, 'REFUSE', 'empty allowlist'],
|
|
310
|
+
[{ required: true, value: 'tyop', recognized: false }, 'REFUSE', 'unknown tariff'],
|
|
311
|
+
[{ required: true, value: 'cached', authority: 'unreachable' }, 'UNKNOWN', 'unreachable authority'],
|
|
312
|
+
[{ required: true, value: 'declared', declared: true, readByDecision: false }, 'REFUSE',
|
|
313
|
+
'declared-but-unread input'],
|
|
314
|
+
[{ ratio: { numerator: 0, denominator: 0 } }, 'UNAVAILABLE', '0/0'],
|
|
315
|
+
[{ required: true, value: '/', kind: 'base-url', failureSentinel: false }, 'REFUSE',
|
|
316
|
+
'invalid obtained value bypassed the failure sentinel'],
|
|
317
|
+
[{ required: true, value: 'https://example.test', recognized: true }, 'ACCEPT', 'explicit valid value'],
|
|
318
|
+
[{ optionalDependency: true, fallbackCannotAlterExternalOutput: true }, 'OPTIONAL_FALLBACK',
|
|
319
|
+
'legitimate optional dependency'],
|
|
320
|
+
[{ phase: 'build', cannotEmitExternalOutput: true }, 'BOUNDED_BUILD_SUBSTITUTE',
|
|
321
|
+
'named non-emitting build phase'],
|
|
322
|
+
];
|
|
323
|
+
for (const [fixture, expected, label] of cases) {
|
|
324
|
+
assert.equal(decideConfiguration(fixture), expected, label);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const source = read('templates/.claude/rules/honest-configuration.md');
|
|
328
|
+
assert.deepEqual(configurationContractProblems(source), []);
|
|
329
|
+
const substitutionMutant = source.replace(/(\|\s*CFG-S1\s*\|[^|]+\|)\s*REFUSE/, '$1 DEFAULT');
|
|
330
|
+
assert.ok(configurationContractProblems(substitutionMutant).some((p) => p.includes('CFG-S1')),
|
|
331
|
+
'weakening substitution must fire independently');
|
|
332
|
+
const interpretationMutant = source.replace(/(\|\s*CFG-I2\s*\|[^|]+\|)\s*REFUSE/, '$1 ALLOW');
|
|
333
|
+
assert.ok(configurationContractProblems(interpretationMutant).some((p) => p.includes('CFG-I2')),
|
|
334
|
+
'weakening interpretation must fire independently');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('P4 - both rules use actionable rule mechanics exception and self-check sections', () => {
|
|
338
|
+
const clean = [
|
|
339
|
+
'# Fixture',
|
|
340
|
+
'## Rule',
|
|
341
|
+
'Refuse the operation.',
|
|
342
|
+
'## Mechanics',
|
|
343
|
+
'Inspect the value.',
|
|
344
|
+
'## Bounded exception',
|
|
345
|
+
'None.',
|
|
346
|
+
'## Observable violation → replacement',
|
|
347
|
+
'DEFAULT is the violation; REFUSE instead.',
|
|
348
|
+
'## Self-check',
|
|
349
|
+
'Run the bad and good fixtures.',
|
|
350
|
+
].join('\n');
|
|
351
|
+
assert.deepEqual(ruleShapeProblems(clean), []);
|
|
352
|
+
for (const heading of ['Rule', 'Mechanics', 'Bounded exception', 'Observable violation → replacement',
|
|
353
|
+
'Self-check']) {
|
|
354
|
+
const malformed = clean.replace(`## ${heading}`, `## Missing ${heading}`);
|
|
355
|
+
assert.ok(ruleShapeProblems(malformed).some((problem) => problem.includes(heading)),
|
|
356
|
+
`malformed fixture did not detect missing ${heading}`);
|
|
357
|
+
}
|
|
358
|
+
for (const slug of RULES) {
|
|
359
|
+
assert.deepEqual(ruleShapeProblems(read(`templates/.claude/rules/${slug}.md`)), [], slug);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('P5 - context delta uses fresh measurement and honest load-surface labels', () => {
|
|
364
|
+
const live = measureCorpus(ROOT);
|
|
365
|
+
assert.ok(live.files >= FRESH_BEFORE.files, 'adding rules cannot reduce the measured same-set file count');
|
|
366
|
+
assert.ok(live.bytes >= FRESH_BEFORE.bytes, 'fresh preimage must remain the lower attribution boundary');
|
|
367
|
+
assert.deepEqual(deltaProblems({
|
|
368
|
+
before: FRESH_BEFORE,
|
|
369
|
+
after: live,
|
|
370
|
+
labels: ['unconditional rules+commands', 'invocation-dependent skills'],
|
|
371
|
+
}), []);
|
|
372
|
+
assert.deepEqual(deltaProblems({
|
|
373
|
+
before: FRESH_BEFORE,
|
|
374
|
+
after: { ...FRESH_BEFORE, estimatedTokens: FRESH_BEFORE.estimatedTokens + ESTIMATED_TRIGGER + 1 },
|
|
375
|
+
labels: ['unconditional rules+commands', 'invocation-dependent skills'],
|
|
376
|
+
}), ['compression required']);
|
|
377
|
+
assert.ok(deltaProblems({ before: FRESH_BEFORE, after: FRESH_BEFORE,
|
|
378
|
+
labels: ['unconditional rules+commands'], complete: false }).length >= 2,
|
|
379
|
+
'unreadable or mislabeled measurement must not become a zero delta');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('P6 - the owned inventory is eight first-party seams and does not imply generator coverage', () => {
|
|
383
|
+
const files = SEAMS.map(([file]) => file);
|
|
384
|
+
assert.deepEqual(seamInventoryProblems(files), []);
|
|
385
|
+
assert.equal(seamInventoryProblems(files.slice(0, -1)).length, 1,
|
|
386
|
+
'omitting an owned seam must be observable');
|
|
387
|
+
assert.equal(seamInventoryProblems([...files, 'skills/generator/example.md']).length, 1,
|
|
388
|
+
'a deferred generator must not be relabeled as implemented');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('P7 - current package inventories derive the nine-rule count', () => {
|
|
392
|
+
assert.deepEqual(countClaimProblems('Pre-shipped: 9 rules. Rules ●9+2. **9 rules**', 9), []);
|
|
393
|
+
assert.equal(countClaimProblems('Pre-shipped: 7 rules.', 9).length, 1,
|
|
394
|
+
'the deliberately stale count fixture must fire');
|
|
395
|
+
const docs = [
|
|
396
|
+
'README.md',
|
|
397
|
+
'MULTIPLATFORM_ROADMAP.md',
|
|
398
|
+
'README/eng/01_quickstart.md',
|
|
399
|
+
'README/eng/02_user_guide.md',
|
|
400
|
+
'README/eng/03_admin_guide.md',
|
|
401
|
+
'README/eng/README.md',
|
|
402
|
+
'README/ru/01_quickstart.md',
|
|
403
|
+
'README/ru/02_user_guide.md',
|
|
404
|
+
'README/ru/03_admin_guide.md',
|
|
405
|
+
'README/ru/README.md',
|
|
406
|
+
'README/ru/html/index.html',
|
|
407
|
+
'templates/.claude/commands/replicate.md',
|
|
408
|
+
'templates/.claude/rules/replicate-pipeline.md',
|
|
409
|
+
];
|
|
410
|
+
const wrong = docs.flatMap((file) => countClaimProblems(read(file), 9).map((problem) => `${file}: ${problem}`));
|
|
411
|
+
assert.deepEqual(wrong, []);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('P8 - every owned group-A swarm seam requires a named trace before synthesis', () => {
|
|
415
|
+
for (const [file, profile] of SEAMS) {
|
|
416
|
+
const source = fs.readFileSync(path.join(ROOT, file), 'utf8');
|
|
417
|
+
assert.deepEqual(swarmContractProblems(source, profile), [], file);
|
|
418
|
+
assert.ok(swarmContractProblems(source.replace(/WORK_UNIT_ID/g, 'UNIT'), profile)
|
|
419
|
+
.some((problem) => problem.includes('WORK_UNIT_ID')), `${file}: dispatch mutant survived`);
|
|
420
|
+
assert.ok(swarmContractProblems(source.replace(/Status: completed/g, 'Status: done'), profile)
|
|
421
|
+
.some((problem) => problem.includes('completed terminal')), `${file}: receipt mutant survived`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-honest-init-')));
|
|
425
|
+
try {
|
|
426
|
+
const init = runCli(project, 'init');
|
|
427
|
+
assert.equal(init.code, 0, init.out);
|
|
428
|
+
for (const [file, profile] of SEAMS) {
|
|
429
|
+
const installed = fs.readFileSync(path.join(project, '.claude', file), 'utf8');
|
|
430
|
+
assert.deepEqual(swarmContractProblems(installed, profile), [], `fresh init: ${file}`);
|
|
431
|
+
}
|
|
432
|
+
} finally { cleanup(project); }
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('P9 - targeted honest-failure mutation and runner wiring are discriminating', () => {
|
|
436
|
+
const packageJson = JSON.parse(read('package.json'));
|
|
437
|
+
for (const runner of ['test', 'test:unit']) {
|
|
438
|
+
assert.match(packageJson.scripts[runner], /tests\/unit\/honest-failure-rules\.test\.js/,
|
|
439
|
+
`${runner} omits the focused oracle`);
|
|
440
|
+
}
|
|
441
|
+
const registry = JSON.parse(read('test/mutation-registry.json'));
|
|
442
|
+
const entry = registry.entries.find((candidate) =>
|
|
443
|
+
candidate.id === 'honest-failure-swarm-receipt-required');
|
|
444
|
+
assert.ok(entry, 'focused mutation id is not registered');
|
|
445
|
+
assert.equal(entry.file, 'templates/.claude/commands/feature.md');
|
|
446
|
+
assert.ok(entry.mutation.find.includes('\n'), 'the production anchor must be multiline');
|
|
447
|
+
assert.ok(entry.mutation.find.length > 100, 'the anchor must be specific enough to stay unique');
|
|
448
|
+
assert.ok(/P2/.test(entry.property) && /P8/.test(entry.property) && /P9/.test(entry.property),
|
|
449
|
+
'the property must name the real receipt, live-seam, and wiring oracles');
|
|
450
|
+
assert.ok(Number.isInteger(entry.minFailing) && entry.minFailing >= 1);
|
|
451
|
+
const target = read(entry.file);
|
|
452
|
+
assert.equal(target.split(entry.mutation.find).length - 1, 1, 'mutation anchor must occur exactly once');
|
|
453
|
+
const mutant = target.replace(entry.mutation.find, entry.mutation.replace);
|
|
454
|
+
assert.ok(swarmContractProblems(mutant).length > 0, 'registered source mutation does not weaken P8');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('P10 - fresh init pack registry manifest and SBOM carry both rules', () => {
|
|
458
|
+
const project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-honest-dist-')));
|
|
459
|
+
try {
|
|
460
|
+
const init = runCli(project, 'init');
|
|
461
|
+
assert.equal(init.code, 0, init.out);
|
|
462
|
+
for (const slug of RULES) {
|
|
463
|
+
const installed = path.join(project, '.claude', 'rules', `${slug}.md`);
|
|
464
|
+
assert.ok(fs.readFileSync(installed, 'utf8').trim(), `fresh init omitted ${slug}`);
|
|
465
|
+
}
|
|
466
|
+
const verify = runCli(project, 'verify');
|
|
467
|
+
assert.equal(verify.code, 0, verify.out);
|
|
468
|
+
const doctor = runCli(project, 'doctor');
|
|
469
|
+
assert.equal(doctor.code, 0, doctor.out);
|
|
470
|
+
fs.rmSync(path.join(project, '.claude', 'rules', `${RULES[0]}.md`));
|
|
471
|
+
const update = runCli(project, 'update');
|
|
472
|
+
assert.equal(update.code, 0, update.out);
|
|
473
|
+
assert.ok(fs.readFileSync(path.join(project, '.claude', 'rules', `${RULES[0]}.md`), 'utf8').trim(),
|
|
474
|
+
'update did not restore a registered canonical rule');
|
|
475
|
+
} finally { cleanup(project); }
|
|
476
|
+
|
|
477
|
+
const packageJson = JSON.parse(read('package.json'));
|
|
478
|
+
assert.deepEqual(packageExportProblems(packageJson.files), []);
|
|
479
|
+
assert.deepEqual(packageExportProblems(packageJson.files.filter((entry) => entry !== 'templates/')),
|
|
480
|
+
['package export omits templates'], 'the deliberately incomplete package allowlist must fire');
|
|
481
|
+
const manifest = JSON.parse(read('.dz-manifest.json'));
|
|
482
|
+
const sbom = JSON.parse(read('sbom.json'));
|
|
483
|
+
for (const slug of RULES) {
|
|
484
|
+
const relative = `templates/.claude/rules/${slug}.md`;
|
|
485
|
+
const digest = hash(path.join(PKG, relative));
|
|
486
|
+
const signed = manifest.manifest.files.find((entry) => entry.path === relative);
|
|
487
|
+
assert.equal(signed?.sha256, digest, `manifest missing or stale for ${relative}`);
|
|
488
|
+
const component = sbom.components.find((entry) => entry.name === relative);
|
|
489
|
+
assert.equal(component?.hashes.find((entry) => entry.alg === 'SHA-256')?.content, digest,
|
|
490
|
+
`SBOM missing or stale for ${relative}`);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
@@ -65,14 +65,15 @@ function project() {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Run one hook FROM THE SUBDIRECTORY — the only place this defect is visible. */
|
|
68
|
-
function runFromSub(p, hook, { withEnv = true, args = [] } = {}) {
|
|
68
|
+
function runFromSub(p, hook, { withEnv = true, args = [], input, envOverrides = {} } = {}) {
|
|
69
69
|
const env = { ...process.env };
|
|
70
70
|
if (withEnv) env.CLAUDE_PROJECT_DIR = p.root;
|
|
71
71
|
else delete env.CLAUDE_PROJECT_DIR;
|
|
72
|
+
Object.assign(env, envOverrides);
|
|
72
73
|
try {
|
|
73
74
|
const stdout = execFileSync(process.execPath,
|
|
74
75
|
[path.join(p.root, '.claude', 'hooks', hook + '.cjs'), ...args],
|
|
75
|
-
{ cwd: p.sub, env, encoding: 'utf8', stdio: 'pipe' });
|
|
76
|
+
{ cwd: p.sub, env, encoding: 'utf8', stdio: 'pipe', input });
|
|
76
77
|
return { code: 0, stdout: stdout || '' };
|
|
77
78
|
} catch (err) {
|
|
78
79
|
return { code: err.status ?? 1, stdout: err.stdout?.toString() ?? '' };
|
|
@@ -138,13 +139,76 @@ describe('hooks resolve against the project, not the drifting cwd (PR-013)', ()
|
|
|
138
139
|
fs.mkdirSync(path.join(p.root, '.claude', 'insights'), { recursive: true });
|
|
139
140
|
fs.writeFileSync(path.join(p.root, '.claude', 'insights', 'index.md'),
|
|
140
141
|
'## Insight one\n\nbody one\n');
|
|
141
|
-
const r = runFromSub(p, 'session-insights', {
|
|
142
|
+
const r = runFromSub(p, 'session-insights', {
|
|
143
|
+
withEnv: false,
|
|
144
|
+
envOverrides: { PATH: '' },
|
|
145
|
+
input: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'anchor' }),
|
|
146
|
+
});
|
|
142
147
|
assert.equal(r.code, 0, 'the hook must stay non-blocking');
|
|
143
148
|
assert.match(r.stdout, /Insight one/,
|
|
144
149
|
'with no env var and cwd in a subdirectory, the hook must still read the ROOT index');
|
|
145
150
|
} finally { fs.rmSync(p.root, { recursive: true, force: true }); }
|
|
146
151
|
});
|
|
147
152
|
|
|
153
|
+
test('P8 - missing store triggers exactly one no-insights hint without creating the carrier', () => {
|
|
154
|
+
const p = project();
|
|
155
|
+
const hint = 'инсайтов пока нет; /myinsights создаст первую запись\n';
|
|
156
|
+
const index = path.join(p.root, '.claude', 'insights', 'index.md');
|
|
157
|
+
try {
|
|
158
|
+
const missing = runFromSub(p, 'session-insights');
|
|
159
|
+
assert.equal(missing.code, 0, 'the missing state stays advisory');
|
|
160
|
+
assert.equal(missing.stdout, hint, 'the genuine missing branch prints exactly one line');
|
|
161
|
+
assert.equal(fs.existsSync(path.dirname(index)), false,
|
|
162
|
+
'the reader must not blur missing into empty by creating the carrier');
|
|
163
|
+
|
|
164
|
+
fs.mkdirSync(path.dirname(index), { recursive: true });
|
|
165
|
+
fs.writeFileSync(index, '');
|
|
166
|
+
const empty = runFromSub(p, 'session-insights');
|
|
167
|
+
assert.equal(empty.code, 0);
|
|
168
|
+
assert.equal(empty.stdout, '', 'an existing empty carrier is not the missing branch');
|
|
169
|
+
|
|
170
|
+
fs.writeFileSync(index, '## 2026-08-30 — existing\n\nBody.\n');
|
|
171
|
+
const populated = runFromSub(p, 'session-insights');
|
|
172
|
+
assert.equal(populated.code, 0);
|
|
173
|
+
assert.equal(populated.stdout, '',
|
|
174
|
+
'SessionStart must not inject populated insights before a prompt exists');
|
|
175
|
+
assert.doesNotMatch(populated.stdout, /инсайтов пока нет/);
|
|
176
|
+
|
|
177
|
+
fs.rmSync(index);
|
|
178
|
+
fs.mkdirSync(index);
|
|
179
|
+
const unreadable = runFromSub(p, 'session-insights');
|
|
180
|
+
assert.equal(unreadable.code, 0, 'read errors remain never-blocking');
|
|
181
|
+
assert.equal(unreadable.stdout, '',
|
|
182
|
+
'an unrelated read failure must not be laundered into the ordinary missing state');
|
|
183
|
+
} finally { fs.rmSync(p.root, { recursive: true, force: true }); }
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('P9 - SessionStart preserves only missing-carrier visibility', () => {
|
|
187
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-start-state-')));
|
|
188
|
+
const hook = path.join(TPL, 'hooks', 'session-insights.cjs');
|
|
189
|
+
delete require.cache[require.resolve(hook)];
|
|
190
|
+
const { emitInsights } = require(hook);
|
|
191
|
+
const run = () => {
|
|
192
|
+
const output = [];
|
|
193
|
+
emitInsights(root, { write: (chunk) => output.push(chunk) }, {
|
|
194
|
+
rawEvent: JSON.stringify({ hook_event_name: 'SessionStart' }),
|
|
195
|
+
});
|
|
196
|
+
return output;
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
assert.deepEqual(run(), ['инсайтов пока нет; /myinsights создаст первую запись\n']);
|
|
200
|
+
assert.equal(fs.existsSync(path.join(root, '.claude', 'insights')), false,
|
|
201
|
+
'the callable production branch must remain read-only on an absent root');
|
|
202
|
+
|
|
203
|
+
const index = path.join(root, '.claude', 'insights', 'index.md');
|
|
204
|
+
fs.mkdirSync(path.dirname(index), { recursive: true });
|
|
205
|
+
fs.writeFileSync(index, '');
|
|
206
|
+
assert.deepEqual(run(), [], 'an empty carrier is not a missing-carrier warning');
|
|
207
|
+
fs.writeFileSync(index, '## 2026-08-30 — populated\n\nBody.\n');
|
|
208
|
+
assert.deepEqual(run(), [], 'populated delivery waits for UserPromptSubmit');
|
|
209
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
210
|
+
});
|
|
211
|
+
|
|
148
212
|
test('P5 — EFFECT from a subdirectory: the roadmap is actually committed', () => {
|
|
149
213
|
// The property the whole feature exists for. An exit-code check passes on the broken hook;
|
|
150
214
|
// this does not. Fails if EITHER half of the fix is missing.
|