@dzhechkov/p-replicator 1.9.0 → 1.10.1
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 +43 -19
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/bin/cli.js +0 -0
- package/package.json +10 -11
- package/sbom.json +83 -19
- package/src/commands/doctor.js +43 -31
- package/src/commands/verify.js +27 -2
- package/src/utils.js +25 -0
- package/templates/.claude/commands/myinsights.md +22 -5
- package/templates/.claude/hooks/check-docs-complete.cjs +34 -6
- package/templates/.claude/hooks/check-ports.cjs +36 -4
- package/templates/.claude/hooks/statusline.cjs +15 -4
- package/templates/.claude/skills/brutal-honesty-review/scripts/assess-code.sh +40 -7
- package/templates/.claude/skills/brutal-honesty-review/scripts/assess-tests.sh +38 -11
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-hooks-commands.md +40 -4
- package/tests/snapshot/baseline.json +8 -8
- package/tests/unit/absence-is-not-emptiness.test.js +255 -0
- package/tests/unit/assess-scripts.test.js +150 -0
- package/tests/unit/check-docs-complete.test.js +43 -0
- package/tests/unit/check-ports.test.js +99 -0
- package/tests/unit/generated-guard-templates.test.js +134 -0
- package/tests/unit/guard-forms.test.js +302 -0
- package/tests/unit/insights-docs-tell-the-truth.test.js +84 -0
- package/tests/unit/module-copy-identity.test.js +31 -1
- package/tests/unit/shipped-suite-context.test.js +142 -0
- package/tests/unit/sync-templates-guard.test.js +31 -1
|
@@ -130,6 +130,49 @@ describe('the cheap question is asked before the swarm is spent', () => {
|
|
|
130
130
|
}
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
test('P16 - a markdown task-list checkbox is NOT a placeholder', () => {
|
|
134
|
+
// MEASURED against a real completed /replicate project: 17 lines like `- [ ] AC покрыты
|
|
135
|
+
// автотестами` were reported as "possibly unfilled". Worse, that project's own Completion.md
|
|
136
|
+
// TEACHES the notation — "флажки [ ] при каждом FR-GROWTH-00N" — so this warning fired on the
|
|
137
|
+
// convention the pipeline itself prescribes. Noise on a legitimate convention trains people to
|
|
138
|
+
// ignore warnings, which is the failure this checker exists to prevent, one level down.
|
|
139
|
+
const withBoxes = '# Specification\n\n'
|
|
140
|
+
+ '- [ ] AC покрыты автотестами\n'
|
|
141
|
+
+ '- [x] События пишутся в аналитику\n'
|
|
142
|
+
+ '* [ ] Anti-fraud сценарий задокументирован\n'
|
|
143
|
+
+ '| `invite_shown` | [ ] | Share-CTA показан в момент первого рендера |\n\n' + REAL;
|
|
144
|
+
const r = check(complete({ 'Specification.md': withBoxes }));
|
|
145
|
+
assert.equal(r.code, 0, 'checkboxes must not block: ' + r.out);
|
|
146
|
+
assert.ok(!/Specification\.md: возможно незаполнено/.test(r.out),
|
|
147
|
+
'and must not even WARN — a warning on the pipeline own convention is noise: ' + r.out);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('P17 - a real bracketed placeholder on a checkbox LINE is still seen', () => {
|
|
151
|
+
// The narrowing must not become an escape hatch: skipping the whole line would let a genuine
|
|
152
|
+
// placeholder hide behind a checkbox prefix.
|
|
153
|
+
const r = check(complete({
|
|
154
|
+
'Specification.md': '# Spec\n\n- [ ] [описание критерия приёмки]\n\n' + REAL,
|
|
155
|
+
}));
|
|
156
|
+
assert.match(r.out, /возможно незаполнено/,
|
|
157
|
+
'a bracketed placeholder AFTER a checkbox must still be reported: ' + r.out);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('P18 - Final_Summary.md is reported when absent, and does not block', () => {
|
|
161
|
+
// MEASURED: the pipeline promises it in three places — replicate.md and two sites in
|
|
162
|
+
// sparc-prd-mini, including a whole SYNTHESIS phase — and a real completed project produced
|
|
163
|
+
// 8 of 9 promised documents WITHOUT it. One project is not enough evidence to decide whether
|
|
164
|
+
// the pipeline is broken or the document is optional in practice, and BLOCKING on it would
|
|
165
|
+
// have refused every project that ran like that one.
|
|
166
|
+
// The shared fixture DOES write it, so this case removes it deliberately — the state a real
|
|
167
|
+
// completed project was measured in.
|
|
168
|
+
const files = complete();
|
|
169
|
+
delete files['Final_Summary.md'];
|
|
170
|
+
const r = check(files);
|
|
171
|
+
assert.equal(r.code, 0, 'its absence must not block: ' + r.out);
|
|
172
|
+
assert.match(r.out, /Final_Summary\.md: отсутствует, хотя конвейер его обещает/,
|
|
173
|
+
'but the discrepancy must be NAMED, not silently tolerated: ' + r.out);
|
|
174
|
+
});
|
|
175
|
+
|
|
133
176
|
test('P7 - no docs directory exits 2, and an empty one does too', () => {
|
|
134
177
|
const none = check({}, { noDocs: true });
|
|
135
178
|
assert.equal(none.code, 2, 'nothing to check is not "complete": ' + none.out);
|
|
@@ -176,6 +176,105 @@ describe('the storage-port rule finally has a check that can fail', () => {
|
|
|
176
176
|
'a non-storage service with no proxy present is not this rule\'s business: ' + r.out);
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
test('P13 — every argument shape resolves in one frame', { skip: !DOCKER }, () => {
|
|
180
|
+
// The defect: `-f` stayed RELATIVE while the same spawnSync call overrode docker's cwd to the
|
|
181
|
+
// directory that relative path already named, so docker re-applied the directory component.
|
|
182
|
+
// MEASURED before the fix: `check-ports.cjs projects/01` from the parent reported
|
|
183
|
+
// "open /tmp/X/projects/01/projects/01/docker-compose.yml".
|
|
184
|
+
//
|
|
185
|
+
// PR-019 diagnosed a project-root join. There is no such join in the file — proven in pure shell:
|
|
186
|
+
// the SAME `-f projects/01/docker-compose.yml` succeeds from /tmp/X and doubles from
|
|
187
|
+
// /tmp/X/projects/01. The cwd override IS the mechanism.
|
|
188
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-frame-')));
|
|
189
|
+
try {
|
|
190
|
+
const proj = path.join(root, 'projects', '01');
|
|
191
|
+
fs.mkdirSync(proj, { recursive: true });
|
|
192
|
+
fs.writeFileSync(path.join(proj, 'docker-compose.yml'),
|
|
193
|
+
'services:\n db:\n image: postgres:16\n');
|
|
194
|
+
// Every shape in ONE run: a matrix asserted from one side only cannot be told from a constant.
|
|
195
|
+
const shapes = [
|
|
196
|
+
['projects/01', root, 'a bare relative directory — the shape that was broken'],
|
|
197
|
+
['./projects/01', root, 'the ./-prefixed form, which path.join normalises identically'],
|
|
198
|
+
['projects/01/docker-compose.yml', root, 'a relative path to the file itself'],
|
|
199
|
+
['.', proj, 'the documented invocation — worked before only because it is idempotent'],
|
|
200
|
+
[null, proj, 'no argument at all'],
|
|
201
|
+
['../01', proj, 'a sibling-relative path'],
|
|
202
|
+
[proj, root, 'absolute, from an unrelated cwd'],
|
|
203
|
+
[proj, proj, 'absolute, from inside'],
|
|
204
|
+
];
|
|
205
|
+
for (const [arg, cwd, why] of shapes) {
|
|
206
|
+
const argv = arg === null ? [CHECK] : [CHECK, arg];
|
|
207
|
+
const r = spawnSync(process.execPath, argv, { encoding: 'utf8', cwd });
|
|
208
|
+
assert.equal(r.status, 0,
|
|
209
|
+
'shape must resolve (' + why + '): arg=' + JSON.stringify(arg) + ' cwd=' + cwd
|
|
210
|
+
+ ' -> exit ' + r.status + ': ' + (r.stdout || '') + (r.stderr || ''));
|
|
211
|
+
}
|
|
212
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('P14 — run from a PARENT cwd with a relative argument', { skip: !DOCKER }, () => {
|
|
216
|
+
// The blind spot that let this ship green. Every other case here builds its fixture with
|
|
217
|
+
// realpathSync(mkdtempSync(...)) and passes it whole — ALWAYS ABSOLUTE — so all 12 tests passed
|
|
218
|
+
// against the live defect. A regression test that cannot reproduce the bug it guards is not a
|
|
219
|
+
// guard. This case is the one that could.
|
|
220
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-parent-')));
|
|
221
|
+
try {
|
|
222
|
+
const proj = path.join(root, 'projects', '01');
|
|
223
|
+
fs.mkdirSync(proj, { recursive: true });
|
|
224
|
+
fs.writeFileSync(path.join(proj, 'docker-compose.yml'),
|
|
225
|
+
'services:\n db:\n image: postgres:16\n ports: ["5432:5432"]\n');
|
|
226
|
+
const r = spawnSync(process.execPath, [CHECK, 'projects/01'], { encoding: 'utf8', cwd: root });
|
|
227
|
+
// A real violation must be REPORTED, not lost behind a path error.
|
|
228
|
+
assert.equal(r.status, 1, 'expected the published-storage violation: ' + r.stdout + r.stderr);
|
|
229
|
+
assert.match(r.stdout, /Правило №0/, r.stdout);
|
|
230
|
+
assert.ok(!/projects\/01\/projects\/01/.test(r.stdout + r.stderr),
|
|
231
|
+
'the doubled path must be gone: ' + r.stdout + r.stderr);
|
|
232
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('P15 — a required variable is reported as itself, not masked', { skip: !DOCKER }, () => {
|
|
236
|
+
// The defect fired BEFORE docker parsed the file, so a genuine config error came back as
|
|
237
|
+
// "no such file". Second harm, and the one a user would waste the most time on.
|
|
238
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-mask-')));
|
|
239
|
+
try {
|
|
240
|
+
const proj = path.join(root, 'projects', '04');
|
|
241
|
+
fs.mkdirSync(proj, { recursive: true });
|
|
242
|
+
fs.writeFileSync(path.join(proj, 'docker-compose.yml'),
|
|
243
|
+
'services:\n db:\n image: postgres:${PGTAG:?PGTAG must be set}\n');
|
|
244
|
+
const r = spawnSync(process.execPath, [CHECK, 'projects/04'], { encoding: 'utf8', cwd: root });
|
|
245
|
+
assert.equal(r.status, 2, 'an unreadable config is still exit 2: ' + r.stdout);
|
|
246
|
+
assert.match(r.stdout, /PGTAG/, 'the REAL cause must reach the user: ' + r.stdout);
|
|
247
|
+
assert.ok(!/no such file/i.test(r.stdout),
|
|
248
|
+
'the path error must not stand in for the config error: ' + r.stdout);
|
|
249
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('P16 — the spawn overrides no cwd, and the hint neither guesses nor refutes itself', () => {
|
|
253
|
+
// Structural, so the removed line cannot creep back. MEASURED on Compose v5.1.1: project name,
|
|
254
|
+
// relative build contexts, .env discovery and env_file are ALL derived from the first -f file's
|
|
255
|
+
// directory — a .env in the process cwd was not even used as a fallback. The override's only
|
|
256
|
+
// demonstrated effect in this file's history is the defect.
|
|
257
|
+
const src = fs.readFileSync(CHECK, 'utf-8');
|
|
258
|
+
const at = src.indexOf('spawnSync(');
|
|
259
|
+
const spawnCall = src.slice(at, at + 300);
|
|
260
|
+
assert.ok(!/cwd:/.test(spawnCall),
|
|
261
|
+
'the cwd override must be GONE, not neutralised — a provably-useless line still encodes the '
|
|
262
|
+
+ 'false premise that regrows the class: ' + spawnCall);
|
|
263
|
+
// The guessed cause named an UNREACHABLE case: a plain unset ${VAR} makes compose exit 0.
|
|
264
|
+
//
|
|
265
|
+
// Asserted on CODE, not on the file: the fix's own comment QUOTES the removed phrase to explain
|
|
266
|
+
// why it went, and a whole-file `includes` cannot tell a mention from a use. That is the same
|
|
267
|
+
// trap this repo has hit repeatedly, and it caught this test on its first run.
|
|
268
|
+
const code = src.split('\n')
|
|
269
|
+
.filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*') && !l.trim().startsWith('/*'))
|
|
270
|
+
.join('\n');
|
|
271
|
+
assert.ok(!code.includes('обычно это незаданная переменная'),
|
|
272
|
+
'the hint must not name a cause that cannot produce this exit');
|
|
273
|
+
assert.match(code, /повторить ровно то, что делали мы/,
|
|
274
|
+
'and the cure must be presented as a reproduction of OUR invocation');
|
|
275
|
+
assert.match(src, /path\.resolve\(process\.cwd\(\)/,
|
|
276
|
+
'the argument must be absolutised once, at the boundary');
|
|
277
|
+
});
|
|
179
278
|
test('P9 — a clean compose exits 0', { skip: !DOCKER }, () => {
|
|
180
279
|
const r = check('services:\n db:\n image: postgres:16\n'
|
|
181
280
|
+ ' web:\n image: node:22\n ports: ["3000:3000"]\n');
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The toolkit generator writes these guards into EVERY project it bootstraps, out of fenced blocks
|
|
4
|
+
// in a markdown reference. They did not inherit the three-code discipline the package's own
|
|
5
|
+
// checkers have, and the result was measured on 2026-08-27 with the threshold substituted to 2:
|
|
6
|
+
//
|
|
7
|
+
// four `class …Entity` on FOUR lines → ❌ VIOLATION, exit 1 correct
|
|
8
|
+
// the SAME four minified onto ONE line → ✅ Aggregate size OK, exit 0
|
|
9
|
+
// the file does not exist → ✅ Aggregate size OK, exit 0
|
|
10
|
+
// the placeholder never substituted → ✅ Aggregate size OK, exit 0 GREEN FOREVER
|
|
11
|
+
//
|
|
12
|
+
// The last is not in the field report and is the worst: `[ 4 -gt "{{MAX_ENTITIES_FROM_FITNESS}}" ]`
|
|
13
|
+
// is an invalid integer comparison, bash errors, the `if` is false, and the script falls through to
|
|
14
|
+
// success. A generator that ever fails to substitute produces a guard that can never refuse.
|
|
15
|
+
//
|
|
16
|
+
// These tests EXTRACT the blocks and RUN them. The defect passed every reading; only execution
|
|
17
|
+
// against real fixtures proves anything.
|
|
18
|
+
|
|
19
|
+
const { test, describe } = require('node:test');
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
const { spawnSync } = require('node:child_process');
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
27
|
+
const MD = path.join(PKG, 'templates', '.claude', 'skills', 'cc-toolkit-generator-enhanced',
|
|
28
|
+
'references', 'templates', 'ddd-hooks-commands.md');
|
|
29
|
+
|
|
30
|
+
/** Every fenced shell block, in order — the same text the generator copies out. */
|
|
31
|
+
function blocks() {
|
|
32
|
+
const md = fs.readFileSync(MD, 'utf-8');
|
|
33
|
+
const out = [];
|
|
34
|
+
const re = /^```(?:bash|sh)\s*\n([\s\S]*?)^```/gm;
|
|
35
|
+
for (let m = re.exec(md); m !== null; m = re.exec(md)) out.push(m[1]);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const named = (needle) => {
|
|
40
|
+
const b = blocks().find((x) => x.includes(needle));
|
|
41
|
+
assert.ok(b, 'no fenced block containing ' + needle + ' — the generator has nothing to write');
|
|
42
|
+
return b;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run a block the way the generator would: substitute the placeholders, write it out, execute it.
|
|
47
|
+
* `substitute: false` leaves the placeholders intact — the un-substituted case.
|
|
48
|
+
*/
|
|
49
|
+
function runBlock(code, args, opts) {
|
|
50
|
+
const o = opts || {};
|
|
51
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-gen-')));
|
|
52
|
+
try {
|
|
53
|
+
let script = code;
|
|
54
|
+
if (o.substitute !== false) {
|
|
55
|
+
script = script
|
|
56
|
+
.replace(/\{\{MAX_ENTITIES_FROM_FITNESS\}\}/g, String(o.maxEntities ?? 2))
|
|
57
|
+
.replace(/\{\{MAX_METHODS_FROM_FITNESS\}\}/g, String(o.maxMethods ?? 10));
|
|
58
|
+
}
|
|
59
|
+
const sh = path.join(dir, 'guard.sh');
|
|
60
|
+
fs.writeFileSync(sh, script);
|
|
61
|
+
for (const [name, body] of Object.entries(o.files || {})) {
|
|
62
|
+
const abs = path.join(dir, name);
|
|
63
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
64
|
+
fs.writeFileSync(abs, body);
|
|
65
|
+
}
|
|
66
|
+
const r = spawnSync('bash', [sh, ...(args || [])], { cwd: dir, encoding: 'utf8' });
|
|
67
|
+
return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
|
|
68
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const FOUR_LINES = 'class AEntity {}\nclass BEntity {}\nclass CEntity {}\nclass DEntity {}\n';
|
|
72
|
+
const FOUR_MINIFIED = 'class AEntity {} class BEntity {} class CEntity {} class DEntity {}\n';
|
|
73
|
+
|
|
74
|
+
describe('a generated guard inherits the discipline the shipped ones have', () => {
|
|
75
|
+
test('P1 - four declarations are four, on four lines or on one', () => {
|
|
76
|
+
// The measured false-green. grep -c counts LINES, so the minified form counted as 1 and the
|
|
77
|
+
// guard blessed four entities against a limit of two.
|
|
78
|
+
const g = named('Aggregate size');
|
|
79
|
+
for (const [label, body] of [['four lines', FOUR_LINES], ['one line', FOUR_MINIFIED]]) {
|
|
80
|
+
const r = runBlock(g, ['src/a.ts'], { files: { 'src/a.ts': body }, maxEntities: 2 });
|
|
81
|
+
assert.equal(r.code, 1, 'four entities over a limit of two must be a VIOLATION (' + label + '): ' + r.out);
|
|
82
|
+
assert.match(r.out, /4/, 'and the count must be reported (' + label + '): ' + r.out);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('P2 - a missing file is exit 2 and is NAMED', () => {
|
|
87
|
+
const g = named('Aggregate size');
|
|
88
|
+
const r = runBlock(g, ['src/nope.ts'], { maxEntities: 2 });
|
|
89
|
+
assert.equal(r.code, 2, 'could-not-read is not "within limits": ' + r.out);
|
|
90
|
+
assert.match(r.out, /nope\.ts/, 'the unreadable path must be named: ' + r.out);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('P3 - an unsubstituted placeholder is exit 2', () => {
|
|
94
|
+
// The mode the field report does not name, and the reason the discipline must hold BEFORE
|
|
95
|
+
// substitution: requiring the generator to always substitute correctly puts the property back
|
|
96
|
+
// on the generator's memory, which is layer 4.
|
|
97
|
+
const g = named('Aggregate size');
|
|
98
|
+
const r = runBlock(g, ['src/a.ts'], { files: { 'src/a.ts': FOUR_LINES }, substitute: false });
|
|
99
|
+
assert.equal(r.code, 2,
|
|
100
|
+
'an unsubstituted threshold made this guard green FOREVER: ' + r.out);
|
|
101
|
+
assert.match(r.out, /MAX_ENTITIES_FROM_FITNESS/,
|
|
102
|
+
'and the placeholder must be named so the generator failure is findable: ' + r.out);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('P4 - the guard can still PASS', () => {
|
|
106
|
+
// A guard that only fails is as useless as one that only passes, and this suite would not tell
|
|
107
|
+
// them apart without this case.
|
|
108
|
+
const g = named('Aggregate size');
|
|
109
|
+
const r = runBlock(g, ['src/a.ts'],
|
|
110
|
+
{ files: { 'src/a.ts': 'class AEntity {}\nclass BEntity {}\n' }, maxEntities: 2 });
|
|
111
|
+
assert.equal(r.code, 0, 'two entities under a limit of two is within limits: ' + r.out);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('P5 - the advisory sibling stays advisory and says so', () => {
|
|
115
|
+
// `exit 0 # Warnings only, don't block` is HONEST, and generated projects may rely on it not
|
|
116
|
+
// blocking. The defect was never the exit code; it was that nothing said so where a user looks.
|
|
117
|
+
const g = named('DDD pattern');
|
|
118
|
+
const r = runBlock(g, ['src/a.ts'], { files: { 'src/a.ts': 'class Thing {}\n' } });
|
|
119
|
+
assert.equal(r.code, 0, 'the advisory reporter must keep exit 0: ' + r.out);
|
|
120
|
+
assert.match(r.out, /advisory|не блокирует|never blocks/i,
|
|
121
|
+
'and must say in its OWN OUTPUT that it does not gate: ' + r.out);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('P6 - all three exit codes come from ONE block in one run', () => {
|
|
125
|
+
// Each case above asserts one direction; a constant-answering script could pass a subset.
|
|
126
|
+
const g = named('Aggregate size');
|
|
127
|
+
const seen = [
|
|
128
|
+
runBlock(g, ['src/a.ts'], { files: { 'src/a.ts': 'class AEntity {}\n' }, maxEntities: 2 }).code,
|
|
129
|
+
runBlock(g, ['src/a.ts'], { files: { 'src/a.ts': FOUR_MINIFIED }, maxEntities: 2 }).code,
|
|
130
|
+
runBlock(g, ['src/nope.ts'], { maxEntities: 2 }).code,
|
|
131
|
+
];
|
|
132
|
+
assert.deepEqual(seen, [0, 1, 2], 'expected within/over/could-not-check: ' + JSON.stringify(seen));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A guard is worth exactly what it can refuse. Four shell forms make a guard structurally unable to
|
|
4
|
+
// refuse anything, and all four are recorded from real defects — three of them REPRODUCED in this
|
|
5
|
+
// package on 2026-08-27.
|
|
6
|
+
//
|
|
7
|
+
// The measured one that matters most does NOT live in a .sh file. It is a fenced bash block inside
|
|
8
|
+
// references/templates/ddd-hooks-commands.md, which the toolkit generator writes into EVERY project
|
|
9
|
+
// it bootstraps. Measured there: four `class …Entity` declarations on four lines are caught, the
|
|
10
|
+
// SAME four minified onto one line report "✅ Aggregate size OK", and a missing file reports OK too.
|
|
11
|
+
// A scan limited to *.sh would have missed it entirely — so this test reads markdown as well.
|
|
12
|
+
//
|
|
13
|
+
// THE TRAP THIS TEST IS WRITTEN AROUND: a comment explaining why `grep -c` is wrong necessarily
|
|
14
|
+
// CONTAINS `grep -c`. So does a documentation table of forbidden forms. A mention is not a use —
|
|
15
|
+
// the class that has bitten three separate times in one day, including inside the fix written for
|
|
16
|
+
// it. Comments are stripped before scanning, and P3 is the guard on that.
|
|
17
|
+
|
|
18
|
+
const { test, describe } = require('node:test');
|
|
19
|
+
const assert = require('node:assert/strict');
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
24
|
+
const TPL = path.join(PKG, 'templates');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The four forbidden forms.
|
|
28
|
+
*
|
|
29
|
+
* Each `re` is applied to COMMENT-STRIPPED shell. `why` is what a reader needs in order to fix it,
|
|
30
|
+
* not a restatement of the pattern.
|
|
31
|
+
*/
|
|
32
|
+
const FORBIDDEN = [
|
|
33
|
+
{
|
|
34
|
+
id: 'grep-c-as-occurrence-count',
|
|
35
|
+
// Fires only on a NON-TRIVIAL pattern. `grep -c ""` counts every line and IS a line count by
|
|
36
|
+
// construction; `grep -c "class.*Entity"` treats matching lines as a count of declarations,
|
|
37
|
+
// which is the measured defect. There is no syntactic difference beyond the pattern itself, and
|
|
38
|
+
// saying so is more honest than pretending the check is complete: `grep -c "^func"` where one
|
|
39
|
+
// per line is guaranteed would false-fire, and needs the opt-out marker below.
|
|
40
|
+
re: /\$\(\s*grep\s+-[a-zA-Z]*c[a-zA-Z]*\s+(?!-)(?!""|''|"\^"|'\^')\S/,
|
|
41
|
+
why: 'grep -c counts matching LINES, not occurrences. Four declarations minified onto one line '
|
|
42
|
+
+ 'count as 1 — MEASURED in ddd-hooks-commands.md, where that exact form reports "OK" for four '
|
|
43
|
+
+ 'entities when the limit is two. Count with `grep -o … | wc -l` when occurrences are meant.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'uppercase-name-class',
|
|
47
|
+
re: /\[A-Z_\]\+/,
|
|
48
|
+
why: '[A-Z_]+ silently passes any name containing other characters. This package already learned '
|
|
49
|
+
+ 'it once: 06-package-deliver.md records "was {{[A-Z_]+}}, which silently passed {{feature-id}}".',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'bare-grep-substitution-without-not-found-branch',
|
|
53
|
+
// A substitution carrying its own fallback (`|| true`, `|| echo …`) has handled the zero-match
|
|
54
|
+
// exit, so it must not fire. Without this the pattern refused `$(grep … || true)` — the very
|
|
55
|
+
// form it exists to recommend, which is how an eager guard becomes a deleted guard.
|
|
56
|
+
// Two shapes are exempt because grep's exit code cannot reach the variable in either:
|
|
57
|
+
// - an explicit fallback: `$(grep … || true)`
|
|
58
|
+
// - a PIPELINE: `$(grep … | wc -l)` — the exit status belongs to the LAST stage
|
|
59
|
+
// The second was found by this guard firing on the fix written for a different finding in the
|
|
60
|
+
// same session. An eager guard is not a stricter guard; it is a guard people delete.
|
|
61
|
+
re: /^[^\n#]*=\$\(\s*[a-z]*grep(?:(?!\|)[^)])*\)\s*$/m,
|
|
62
|
+
why: 'grep exits 1 when it matches nothing, and a bare $( ) swallows that. Under `set -e` the '
|
|
63
|
+
+ 'script dies; without it the variable is empty and the comparison silently succeeds. Give it '
|
|
64
|
+
+ 'an explicit not-found branch, or `|| true` with the empty case handled.',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'echo-0-appended-to-grep-c',
|
|
68
|
+
re: /grep\s+-c[^\n]*\|\|\s*echo\s+0/,
|
|
69
|
+
why: '`grep -c … || echo 0` prints TWO values when there is no match, because grep -c already '
|
|
70
|
+
+ 'prints 0 and then exits 1. The arithmetic that follows fails and the guard falls through to '
|
|
71
|
+
+ 'success — MEASURED as one of the three false-green inputs in ddd-hooks-commands.md.',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'guard-shaped-script-that-cannot-refuse',
|
|
75
|
+
// Not a grep form — a WHOLE-SCRIPT shape, so it is applied to scripts only (see scan()).
|
|
76
|
+
// A script that prints verdicts and whose only `exit 1` is its own usage check cannot refuse
|
|
77
|
+
// anything it was written to judge. MEASURED 2026-08-27: assess-code.sh returns 0 on code with
|
|
78
|
+
// TODO/FIXME/BUG/HACK, nested infinite loops, an empty catch and eval; assess-tests.sh returns
|
|
79
|
+
// 0 on tests that do not pass — while BOTH return 1 for a nonexistent path. "Could not check"
|
|
80
|
+
// is louder than "found violations", which is the semantics exactly inverted.
|
|
81
|
+
//
|
|
82
|
+
// This is the form the four grep patterns could NOT see. My first pass reported these two
|
|
83
|
+
// scripts for a different reason and the reason was WRONG: their substitutions are pipelines,
|
|
84
|
+
// where grep's exit code never reaches the variable. A guard that finds the right file for the
|
|
85
|
+
// wrong reason will exonerate it the moment the wrong reason is fixed.
|
|
86
|
+
scriptOnly: true,
|
|
87
|
+
test(code) {
|
|
88
|
+
if (!/❌|🔴|VIOLATION|FAIL/.test(code)) return false; // not verdict-shaped
|
|
89
|
+
const exits = [...code.matchAll(/^\s*exit\s+([0-9]+)/gm)].map((m) => m[1]);
|
|
90
|
+
if (!exits.includes('1') && !exits.includes('2')) return true; // cannot refuse at all
|
|
91
|
+
// The principled condition: EVERY non-zero exit happens before the script starts judging.
|
|
92
|
+
// Counting them was wrong — assess-tests.sh has TWO, both setup checks, and slipped through a
|
|
93
|
+
// rule that demanded exactly one. What matters is WHERE the last refusal is: if the script
|
|
94
|
+
// can no longer say no by the time it begins assessing, it cannot refuse its subject.
|
|
95
|
+
const positions = [...code.matchAll(/^\s*exit\s+[1-9]/gm)].map((m) => m.index);
|
|
96
|
+
if (positions.length === 0) return true;
|
|
97
|
+
const lastRefusal = Math.max(...positions);
|
|
98
|
+
// A verdict-shaped line FOLLOWED by a non-zero exit is a REFUSAL, not a judgement — that is
|
|
99
|
+
// the script saying "I cannot check", and it must not count as evidence that the script can
|
|
100
|
+
// refuse its subject. assess-tests.sh prints "🔴 FAILING: Test directory doesn't exist" two
|
|
101
|
+
// lines before its `exit 1`; without this the first-verdict position lands on the setup
|
|
102
|
+
// failure and the whole rule misses.
|
|
103
|
+
const lines = code.split('\n');
|
|
104
|
+
let firstVerdict = -1;
|
|
105
|
+
for (let i = 0; i < lines.length; i++) {
|
|
106
|
+
if (!/❌|🔴|VIOLATION|FAIL/.test(lines[i])) continue;
|
|
107
|
+
const followedByRefusal = lines.slice(i, i + 4).some((l) => /^\s*exit\s+[1-9]/.test(l));
|
|
108
|
+
if (followedByRefusal) continue;
|
|
109
|
+
firstVerdict = lines.slice(0, i).join('\n').length;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
return firstVerdict > 0 && lastRefusal < firstVerdict;
|
|
113
|
+
},
|
|
114
|
+
why: 'this script prints verdicts but its only non-zero exit is the usage check, so it can never '
|
|
115
|
+
+ 'refuse what it judges. Give it the three-code contract: 0 clean, 1 violations found, 2 the '
|
|
116
|
+
+ 'check did not run.',
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
/** Shell with comments removed, so a MENTION of a forbidden form cannot be read as a USE. */
|
|
121
|
+
function stripShellComments(src) {
|
|
122
|
+
return src.split('\n')
|
|
123
|
+
.map((l) => (/^\s*#/.test(l) ? '' : l.replace(/(^|\s)#(?!\{).*$/, '$1')))
|
|
124
|
+
.join('\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Fenced blocks whose language tag is a shell, from a markdown file. */
|
|
128
|
+
function shellBlocks(md) {
|
|
129
|
+
const out = [];
|
|
130
|
+
const re = /^```(bash|sh|shell|zsh)\s*\n([\s\S]*?)^```/gm;
|
|
131
|
+
for (let m = re.exec(md); m !== null; m = re.exec(md)) out.push(m[2]);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function walk(dir, hit) {
|
|
136
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
137
|
+
const p = path.join(dir, e.name);
|
|
138
|
+
if (e.isDirectory()) walk(p, hit);
|
|
139
|
+
else hit(p);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every shell this package ships: real .sh files, plus fenced shell inside markdown. */
|
|
144
|
+
function shellSources() {
|
|
145
|
+
const out = [];
|
|
146
|
+
walk(TPL, (p) => {
|
|
147
|
+
if (p.endsWith('.sh')) {
|
|
148
|
+
out.push({ file: path.relative(PKG, p), code: fs.readFileSync(p, 'utf-8'), kind: 'script' });
|
|
149
|
+
} else if (p.endsWith('.md')) {
|
|
150
|
+
const md = fs.readFileSync(p, 'utf-8');
|
|
151
|
+
shellBlocks(md).forEach((code, i) => {
|
|
152
|
+
out.push({ file: path.relative(PKG, p) + ` (fenced block #${i + 1})`, code, kind: 'fenced' });
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* An explicit opt-out, on the line itself: `# guard-forms: ok — <reason>`.
|
|
161
|
+
*
|
|
162
|
+
* A guard with no exemption is a guard people delete wholesale the first time it is wrong. The
|
|
163
|
+
* reason is mandatory so the exemption stays reviewable — an unexplained opt-out is the silence this
|
|
164
|
+
* whole file exists to remove, one level down.
|
|
165
|
+
*/
|
|
166
|
+
const OPT_OUT = /#\s*guard-forms:\s*ok\s*[—-]\s*\S/;
|
|
167
|
+
|
|
168
|
+
const scan = (code, kind) => {
|
|
169
|
+
const kept = code.split('\n').filter((l) => !OPT_OUT.test(l)).join('\n');
|
|
170
|
+
const stripped = stripShellComments(kept);
|
|
171
|
+
return FORBIDDEN.filter((f) => {
|
|
172
|
+
// A whole-script shape cannot be judged from a fenced fragment: a block may legitimately show
|
|
173
|
+
// one function of a larger script. Applied to real .sh files only.
|
|
174
|
+
if (f.scriptOnly && kind !== 'script') return false;
|
|
175
|
+
return f.test ? f.test(stripped) : f.re.test(stripped);
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
describe('a guard-shaped script must be able to refuse', () => {
|
|
180
|
+
test('P1 - each forbidden form is DETECTED in a fixture', () => {
|
|
181
|
+
// Without this the whole file could be a set of patterns that match nothing, and every scan
|
|
182
|
+
// below would pass by construction.
|
|
183
|
+
const fixtures = {
|
|
184
|
+
'grep-c-as-occurrence-count': 'N=$(grep -c "class.*Entity" "$FILE")\n',
|
|
185
|
+
'uppercase-name-class': 'grep -oE "\\{\\{[A-Z_]+\\}\\}" "$FILE"\n',
|
|
186
|
+
'bare-grep-substitution-without-not-found-branch': 'HITS=$(grep -n TODO "$FILE")\n',
|
|
187
|
+
'echo-0-appended-to-grep-c': 'N=$(grep -c foo "$F" 2>/dev/null || echo 0)\n',
|
|
188
|
+
};
|
|
189
|
+
for (const [id, code] of Object.entries(fixtures)) {
|
|
190
|
+
const hits = scan(code, 'script').map((f) => f.id);
|
|
191
|
+
assert.ok(hits.includes(id),
|
|
192
|
+
'the pattern for ' + id + ' matched nothing in its own fixture — it guards nothing: '
|
|
193
|
+
+ JSON.stringify(hits));
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('P2 - legitimate shell is NOT refused', () => {
|
|
198
|
+
// An eager guard is not a stricter guard: it is a guard people delete.
|
|
199
|
+
const ok = [
|
|
200
|
+
['LINES=$(grep -c "" "$FILE" || true)\n',
|
|
201
|
+
'grep -c with an EMPTY pattern counts every line — a line count by construction — and the '
|
|
202
|
+
+ '|| true handles the zero-count exit. My first draft of this fixture omitted the || true '
|
|
203
|
+
+ 'and was NOT legitimate: grep -c exits 1 when the count is 0, so the bare substitution '
|
|
204
|
+
+ 'really did swallow it. The test caught my own example.'],
|
|
205
|
+
['if grep -q pattern "$FILE"; then echo found; fi\n', 'a quiet membership test'],
|
|
206
|
+
['HITS=$(grep -n TODO "$FILE" || true)\nif [ -z "$HITS" ]; then echo none; fi\n',
|
|
207
|
+
'a substitution WITH an explicit not-found branch'],
|
|
208
|
+
['grep -oE "[A-Za-z_][A-Za-z0-9_]*" "$FILE"\n', 'a name class that is not the narrow one'],
|
|
209
|
+
];
|
|
210
|
+
for (const [code, why] of ok) {
|
|
211
|
+
assert.deepEqual(scan(code, 'script').map((f) => f.id), [],
|
|
212
|
+
'legitimate shell refused (' + why + '): ' + code);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('P3 - the same forms inside comments PASS', () => {
|
|
217
|
+
// A mention is not a use. This class has bitten three separate times in one day, including
|
|
218
|
+
// inside a fix written for it — a whole-file `includes` could not tell the fix's own explanatory
|
|
219
|
+
// comment from the thing it removed.
|
|
220
|
+
const commented = [
|
|
221
|
+
'# never use $(grep -c ...) as an occurrence count\n echo ok\n',
|
|
222
|
+
'# {{[A-Z_]+}} silently passed {{feature-id}} — do not use it\n echo ok\n',
|
|
223
|
+
'echo ok # HITS=$(grep -n TODO "$FILE") would swallow the exit code\n',
|
|
224
|
+
'# grep -c foo "$F" || echo 0 prints TWO values\n echo ok\n',
|
|
225
|
+
];
|
|
226
|
+
for (const code of commented) {
|
|
227
|
+
assert.deepEqual(scan(code, 'script').map((f) => f.id), [],
|
|
228
|
+
'a comment EXPLAINING a forbidden form was read as using it: ' + code);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('P5 - fenced bash inside markdown is scanned', () => {
|
|
233
|
+
// The measured false-green guard is not a .sh file — it is a fenced block the generator writes
|
|
234
|
+
// into every project it bootstraps. A scan limited to *.sh misses it entirely.
|
|
235
|
+
const sources = shellSources();
|
|
236
|
+
const fenced = sources.filter((s) => s.kind === 'fenced');
|
|
237
|
+
assert.ok(fenced.length >= 5,
|
|
238
|
+
'fenced shell blocks must be reachable — found ' + fenced.length);
|
|
239
|
+
const scripts = sources.filter((s) => s.kind === 'script');
|
|
240
|
+
assert.ok(scripts.length >= 1, 'and real .sh files too: ' + scripts.length);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('P4 - a non-shell fenced block is NOT scanned as shell', () => {
|
|
244
|
+
// A ```js block containing `grep -c` in a string is not a shell guard.
|
|
245
|
+
const md = '```js\nconst cmd = \'grep -c foo bar\';\n```\n';
|
|
246
|
+
assert.deepEqual(shellBlocks(md), [], 'only shell-tagged fences are shell');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('P6 - the package is scanned, and every finding is NAMED with its file and reason', () => {
|
|
250
|
+
// The live inventory. Findings are REPORTED rather than asserted away: the known-bad shipped
|
|
251
|
+
// scripts are filed separately (backlog 5e99d823, 11e62b43) and fixing them inside this change
|
|
252
|
+
// would mix two changes and hide which one did what. What must not happen is that they persist
|
|
253
|
+
// INVISIBLY — so this test fails the moment the count changes in either direction.
|
|
254
|
+
const findings = [];
|
|
255
|
+
for (const src of shellSources()) {
|
|
256
|
+
for (const f of scan(src.code, src.kind)) findings.push({ file: src.file, form: f.id, why: f.why });
|
|
257
|
+
}
|
|
258
|
+
// MEASURED, not guessed: 5 findings across 3 files. The generator block carries THREE forms at
|
|
259
|
+
// once — it is the artifact written into every bootstrapped project, and it is why this test
|
|
260
|
+
// scans markdown.
|
|
261
|
+
// MEASURED. Dropped from 4 when the generator's aggregate guard was rewritten in the same
|
|
262
|
+
// session: the two survivors are assess-code.sh and assess-tests.sh, filed as 11e62b43 and
|
|
263
|
+
// deliberately NOT fixed here. Lower this in the commit that fixes them.
|
|
264
|
+
const KNOWN = 0;
|
|
265
|
+
assert.equal(findings.length, KNOWN,
|
|
266
|
+
'the forbidden-form inventory changed. If you FIXED one, lower KNOWN in the same commit; if a '
|
|
267
|
+
+ 'new one appeared, that is the defect this test exists to catch:\n'
|
|
268
|
+
+ findings.map((f) => ' - ' + f.file + ' :: ' + f.form + '\n ' + f.why).join('\n'));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('P7 - every entry names its property in words, not in its find string', () => {
|
|
272
|
+
// MONOREPO-ONLY, and measured rather than assumed: files[] ships `tests/`, not `test/`, so the
|
|
273
|
+
// mutation registry does NOT reach a tarball — correctly, it is repo machinery and says nothing
|
|
274
|
+
// about a user's installation. Gated on the same POSITIVE fact the other monorepo-only files
|
|
275
|
+
// use, so a broken detection takes them all down together.
|
|
276
|
+
//
|
|
277
|
+
// Found by running the suite from a freshly packed tarball. My first guess at the cause was
|
|
278
|
+
// wrong — I assumed the shipped .sh scripts were missing; they ship fine. Measuring beat it.
|
|
279
|
+
let siblingPresent = false;
|
|
280
|
+
try {
|
|
281
|
+
siblingPresent = fs.statSync(path.resolve(PKG, '..', 'harness-core', 'package.json')).isFile();
|
|
282
|
+
} catch { siblingPresent = false; }
|
|
283
|
+
if (!siblingPresent) {
|
|
284
|
+
console.log('# SKIP (monorepo-only): the mutation registry is repo machinery and is not '
|
|
285
|
+
+ 'shipped (files[] carries tests/, not test/). This says nothing about your installation.');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// A registry whose entries describe their `find` string stops meaning anything the moment the
|
|
289
|
+
// code is refactored. The gate's contract makes a non-applying mutation a FAILURE, which keeps
|
|
290
|
+
// that honest; the WORDS are what let a human re-derive the entry afterwards.
|
|
291
|
+
const reg = JSON.parse(fs.readFileSync(path.join(PKG, 'test', 'mutation-registry.json'), 'utf-8'));
|
|
292
|
+
assert.ok(reg.entries.length >= 6, 'one entry is not a registry: ' + reg.entries.length);
|
|
293
|
+
for (const e of reg.entries) {
|
|
294
|
+
assert.ok(e.property && e.property.length > 80,
|
|
295
|
+
e.id + ': the property must be stated in words a human can re-derive from: '
|
|
296
|
+
+ JSON.stringify(e.property));
|
|
297
|
+
assert.ok(!e.property.includes(e.mutation.find.trim()),
|
|
298
|
+
e.id + ': the property restates its own find string — it would mean nothing after a refactor');
|
|
299
|
+
assert.ok(fs.existsSync(path.join(PKG, e.file)), e.id + ': names a file that does not exist');
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
});
|