@dzhechkov/p-replicator 1.5.18 → 1.6.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 +126 -46
- package/CHANGELOG.md +135 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +2 -2
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/05_architecture.md +6 -2
- package/README/eng/README.md +1 -1
- package/README/ru/01_quickstart.md +2 -2
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +1 -1
- package/README/ru/html/index.html +7 -7
- package/README.md +36 -15
- package/bin/cli.js +0 -0
- package/package.json +11 -10
- package/sbom.json +245 -45
- package/src/utils.js +2 -0
- package/templates/.claude/agents/doc-validator.md +2 -1
- package/templates/.claude/agents/product-discoverer.md +1 -1
- package/templates/.claude/commands/next.md +16 -0
- package/templates/.claude/commands/replicate.md +126 -8
- package/templates/.claude/commands/start.md +19 -1
- package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
- package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
- package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
- package/templates/.claude/hooks/check-ports.cjs +232 -0
- package/templates/.claude/hooks/session-insights.cjs +13 -1
- package/templates/.claude/hooks/state-update.cjs +13 -1
- package/templates/.claude/hooks/statusline.cjs +145 -18
- package/templates/.claude/rules/docker-ports.md +123 -0
- package/templates/.claude/rules/replicate-pipeline.md +5 -2
- package/templates/.claude/settings.json +5 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
- package/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
- package/tests/e2e/lifecycle.test.js +21 -10
- package/tests/snapshot/baseline.json +29 -27
- package/tests/snapshot/update-baseline.js +2 -1
- package/tests/unit/adr-decision-coverage.test.js +137 -0
- package/tests/unit/adr-scanner-contract.test.js +108 -0
- package/tests/unit/autocommit-deletion.test.js +242 -0
- package/tests/unit/check-ports.test.js +184 -0
- package/tests/unit/db-port-rule.test.js +216 -0
- package/tests/unit/detect-parse-anchor.test.js +109 -0
- package/tests/unit/external-dependency-check.test.js +209 -0
- package/tests/unit/growth-module-b2b-gate.test.js +104 -0
- package/tests/unit/hooks-project-anchored.test.js +223 -0
- package/tests/unit/hooks-report-failures.test.js +207 -0
- package/tests/unit/pipeline-file-ownership.test.js +95 -0
- package/tests/unit/roadmap-one-schema.test.js +179 -0
- package/tests/unit/sparc-reconciliation.test.js +117 -0
- package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
- package/tests/unit/statusline-honest-labels.test.js +178 -0
- package/tests/unit/statusline-two-roots.test.js +237 -0
- package/tests/unit/sync-templates-guard.test.js +209 -0
- package/tests/unit/utils.test.js +2 -2
- package/tests/unit/validation-gate-teeth.test.js +158 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// All three autocommit hooks opened with `if (!fs.existsSync(TARGET)) process.exit(0);`, so a DELETED
|
|
4
|
+
// roadmap was "nothing to do" — though a deletion is exactly the change these hooks exist to record.
|
|
5
|
+
// The history showed the file being maintained and then, silently, not.
|
|
6
|
+
//
|
|
7
|
+
// THE TRAP, measured before a line was written: simply deleting that guard breaks the failure
|
|
8
|
+
// reporting that shipped the day before.
|
|
9
|
+
//
|
|
10
|
+
// git add -- <a path that never existed> → exit 128, "fatal: pathspec … did not match any files"
|
|
11
|
+
// git add -- <a tracked file, deleted> → exit 0, and diff --cached then reports a change
|
|
12
|
+
//
|
|
13
|
+
// A failing git call now prints one line, so the naive fix would print a failure on EVERY session
|
|
14
|
+
// stop in every project without this artifact — which is most of them. That is the noise the same
|
|
15
|
+
// feature's own comment warns trains people to ignore notices. P3 is the guard on it.
|
|
16
|
+
//
|
|
17
|
+
// The discriminator is `git ls-files -- <path>`: entries for a path git knows, nothing for one it
|
|
18
|
+
// does not. It must run BEFORE staging: staging a deletion removes the entry from the index, after
|
|
19
|
+
// which the same question answers "not tracked". (`--error-unmatch` discriminates just as well, for
|
|
20
|
+
// files and directories alike — an earlier note here said otherwise and was withdrawn after
|
|
21
|
+
// re-measuring. `ls-files --` is preferred only because it answers with data instead of throwing.)
|
|
22
|
+
|
|
23
|
+
const { test, describe } = require('node:test');
|
|
24
|
+
const assert = require('node:assert/strict');
|
|
25
|
+
const { execFileSync, spawnSync } = require('node:child_process');
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const os = require('node:os');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
31
|
+
|
|
32
|
+
const HOOKS = [
|
|
33
|
+
{ name: 'autocommit-roadmap', remove: /auto-remove/,
|
|
34
|
+
make: (root) => {
|
|
35
|
+
fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
|
|
36
|
+
const f = path.join(root, '.claude', 'feature-roadmap.json');
|
|
37
|
+
fs.writeFileSync(f, '{"v":1}\n');
|
|
38
|
+
return { drop: () => fs.rmSync(f), touch: () => fs.writeFileSync(f, '{"v":2}\n') };
|
|
39
|
+
} },
|
|
40
|
+
{ name: 'autocommit-insights', remove: /auto-remove/,
|
|
41
|
+
make: (root) => {
|
|
42
|
+
const d = path.join(root, '.claude', 'insights');
|
|
43
|
+
fs.mkdirSync(d, { recursive: true });
|
|
44
|
+
fs.writeFileSync(path.join(d, 'index.md'), '## one\n');
|
|
45
|
+
return { drop: () => fs.rmSync(d, { recursive: true }),
|
|
46
|
+
touch: () => fs.writeFileSync(path.join(d, 'index.md'), '## one\n## two\n') };
|
|
47
|
+
} },
|
|
48
|
+
{ name: 'autocommit-plans', remove: /auto-remove/,
|
|
49
|
+
make: (root) => {
|
|
50
|
+
const d = path.join(root, 'docs', 'plans');
|
|
51
|
+
fs.mkdirSync(d, { recursive: true });
|
|
52
|
+
fs.writeFileSync(path.join(d, 'a.md'), '# plan\n');
|
|
53
|
+
return { drop: () => fs.rmSync(d, { recursive: true }),
|
|
54
|
+
touch: () => fs.writeFileSync(path.join(d, 'a.md'), '# plan v2\n') };
|
|
55
|
+
} },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
function project(opts) {
|
|
59
|
+
const o = opts || {};
|
|
60
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-del-')));
|
|
61
|
+
fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true });
|
|
62
|
+
for (const h of HOOKS) {
|
|
63
|
+
fs.copyFileSync(path.join(TPL, 'hooks', h.name + '.cjs'),
|
|
64
|
+
path.join(root, '.claude', 'hooks', h.name + '.cjs'));
|
|
65
|
+
}
|
|
66
|
+
if (o.git !== false) {
|
|
67
|
+
const git = (a) => execFileSync('git', a, { cwd: root, stdio: 'ignore' });
|
|
68
|
+
git(['init', '-q']);
|
|
69
|
+
git(['config', 'user.email', 'test@example.invalid']);
|
|
70
|
+
git(['config', 'user.name', 'test']);
|
|
71
|
+
}
|
|
72
|
+
return root;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const git = (root, args) =>
|
|
76
|
+
execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
|
|
77
|
+
const commitAll = (root) => {
|
|
78
|
+
execFileSync('git', ['add', '-A'], { cwd: root, stdio: 'ignore' });
|
|
79
|
+
execFileSync('git', ['commit', '-qm', 'base'], { cwd: root, stdio: 'ignore' });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function run(root, hookName) {
|
|
83
|
+
const r = spawnSync(process.execPath,
|
|
84
|
+
[path.join(root, '.claude', 'hooks', hookName + '.cjs')],
|
|
85
|
+
{ cwd: root, env: Object.assign({}, process.env, { CLAUDE_PROJECT_DIR: root }),
|
|
86
|
+
encoding: 'utf8' });
|
|
87
|
+
return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const cleanup = (d) => fs.rmSync(d, { recursive: true, force: true });
|
|
91
|
+
|
|
92
|
+
describe('a deleted artifact is recorded, and an absent one is still silent', () => {
|
|
93
|
+
test('P1 — deleting a tracked target produces a commit and leaves the tree clean', () => {
|
|
94
|
+
const root = project({});
|
|
95
|
+
try {
|
|
96
|
+
const h = HOOKS[0];
|
|
97
|
+
const ctl = h.make(root);
|
|
98
|
+
commitAll(root);
|
|
99
|
+
const before = Number(git(root, ['rev-list', '--count', 'HEAD']));
|
|
100
|
+
ctl.drop();
|
|
101
|
+
|
|
102
|
+
const r = run(root, h.name);
|
|
103
|
+
assert.equal(r.code, 0, 'the hook must stay non-blocking: ' + r.out);
|
|
104
|
+
assert.equal(Number(git(root, ['rev-list', '--count', 'HEAD'])), before + 1,
|
|
105
|
+
'the deletion must be committed, not treated as nothing to do');
|
|
106
|
+
assert.equal(git(root, ['status', '--porcelain']), '',
|
|
107
|
+
'and the tree must be left clean, not merely staged');
|
|
108
|
+
} finally { cleanup(root); }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('P2 — the commit names the removal, so the event is findable in the log', () => {
|
|
112
|
+
// The owner's stated purpose: be able to spot the event and undo it. A deletion recorded as
|
|
113
|
+
// "auto-update" is findable only by reading diffs.
|
|
114
|
+
const root = project({});
|
|
115
|
+
try {
|
|
116
|
+
const h = HOOKS[0];
|
|
117
|
+
const ctl = h.make(root);
|
|
118
|
+
commitAll(root);
|
|
119
|
+
ctl.drop();
|
|
120
|
+
run(root, h.name);
|
|
121
|
+
const subject = git(root, ['log', '-1', '--format=%s']);
|
|
122
|
+
assert.match(subject, h.remove,
|
|
123
|
+
'the subject must say the artifact was removed: ' + JSON.stringify(subject));
|
|
124
|
+
} finally { cleanup(root); }
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('P3 — a project that never had the artifact stays SILENT', () => {
|
|
128
|
+
// The guard on the naive fix. Deleting the existence check outright would make `git add` exit
|
|
129
|
+
// 128 here, and the failure reporting that shipped yesterday would print a line on every session
|
|
130
|
+
// stop in every project without this artifact.
|
|
131
|
+
const root = project({});
|
|
132
|
+
try {
|
|
133
|
+
fs.writeFileSync(path.join(root, 'unrelated.txt'), 'x\n');
|
|
134
|
+
commitAll(root);
|
|
135
|
+
for (const h of HOOKS) {
|
|
136
|
+
const r = run(root, h.name);
|
|
137
|
+
assert.equal(r.code, 0, h.name + ' must exit 0');
|
|
138
|
+
assert.equal(r.out.trim(), '',
|
|
139
|
+
h.name + ' printed something for an artifact this project never had: ' + r.out);
|
|
140
|
+
}
|
|
141
|
+
assert.equal(git(root, ['rev-list', '--count', 'HEAD']), '1',
|
|
142
|
+
'and nothing may be committed');
|
|
143
|
+
} finally { cleanup(root); }
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('P4 — not a git repository: silent, exit 0', () => {
|
|
147
|
+
const root = project({ git: false });
|
|
148
|
+
try {
|
|
149
|
+
const r = run(root, HOOKS[0].name);
|
|
150
|
+
assert.equal(r.code, 0);
|
|
151
|
+
assert.equal(r.out.trim(), '', 'not-a-repo is not a failure to report: ' + r.out);
|
|
152
|
+
} finally { cleanup(root); }
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('P5 — an existing, changed target still commits as before', () => {
|
|
156
|
+
const root = project({});
|
|
157
|
+
try {
|
|
158
|
+
const h = HOOKS[0];
|
|
159
|
+
const ctl = h.make(root);
|
|
160
|
+
commitAll(root);
|
|
161
|
+
ctl.touch();
|
|
162
|
+
const before = Number(git(root, ['rev-list', '--count', 'HEAD']));
|
|
163
|
+
assert.equal(run(root, h.name).code, 0);
|
|
164
|
+
assert.equal(Number(git(root, ['rev-list', '--count', 'HEAD'])), before + 1,
|
|
165
|
+
'the ordinary update path must be untouched');
|
|
166
|
+
assert.doesNotMatch(git(root, ['log', '-1', '--format=%s']), /auto-remove/,
|
|
167
|
+
'and an update must NOT be labelled a removal');
|
|
168
|
+
} finally { cleanup(root); }
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('P6 — an existing, unchanged target is silent', () => {
|
|
172
|
+
const root = project({});
|
|
173
|
+
try {
|
|
174
|
+
const h = HOOKS[0];
|
|
175
|
+
h.make(root);
|
|
176
|
+
commitAll(root);
|
|
177
|
+
const before = Number(git(root, ['rev-list', '--count', 'HEAD']));
|
|
178
|
+
const r = run(root, h.name);
|
|
179
|
+
assert.equal(r.code, 0);
|
|
180
|
+
assert.equal(r.out.trim(), '', 'nothing to commit must stay silent: ' + r.out);
|
|
181
|
+
assert.equal(Number(git(root, ['rev-list', '--count', 'HEAD'])), before,
|
|
182
|
+
'and must not create an empty commit');
|
|
183
|
+
} finally { cleanup(root); }
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('P8 — a PARTIAL deletion is still a removal, even though the directory remains', () => {
|
|
187
|
+
// Cross-family QE: classifying from `existsSync` on the DIRECTORY was wrong. Deleting one file
|
|
188
|
+
// inside it — or every tracked file while an ignored one keeps the directory present — left the
|
|
189
|
+
// path existing, so the commit said "auto-save" and the removal was unfindable by the very
|
|
190
|
+
// search this feature promises. The classification now comes from what git actually STAGED.
|
|
191
|
+
const root = project({});
|
|
192
|
+
try {
|
|
193
|
+
const d = path.join(root, 'docs', 'plans');
|
|
194
|
+
fs.mkdirSync(d, { recursive: true });
|
|
195
|
+
fs.writeFileSync(path.join(d, 'a.md'), 'a\n');
|
|
196
|
+
fs.writeFileSync(path.join(d, 'b.md'), 'b\n');
|
|
197
|
+
commitAll(root);
|
|
198
|
+
fs.rmSync(path.join(d, 'a.md')); // the directory still exists
|
|
199
|
+
|
|
200
|
+
const r = run(root, 'autocommit-plans');
|
|
201
|
+
assert.equal(r.code, 0, r.out);
|
|
202
|
+
assert.ok(fs.existsSync(d), 'the fixture must leave the directory in place');
|
|
203
|
+
assert.match(git(root, ['log', '-1', '--format=%s']), /auto-remove/,
|
|
204
|
+
'one deleted file inside a surviving directory is still a removal: '
|
|
205
|
+
+ git(root, ['log', '-1', '--format=%s']));
|
|
206
|
+
} finally { cleanup(root); }
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('P9 — a real git failure is not laundered into "never existed"', () => {
|
|
210
|
+
// The `ls-files` answer decides whether a deletion is recorded at all. A bare catch that turned
|
|
211
|
+
// every failure into an empty answer would silently skip the deletion — a bypass of the feature
|
|
212
|
+
// itself, wearing the shape of the ordinary case.
|
|
213
|
+
for (const h of HOOKS) {
|
|
214
|
+
const src = fs.readFileSync(path.join(TPL, 'hooks', h.name + '.cjs'), 'utf-8');
|
|
215
|
+
const at = src.indexOf("git(['ls-files'");
|
|
216
|
+
assert.ok(at > 0, h.name + ' must ask git whether the path is tracked');
|
|
217
|
+
const around = src.slice(at - 200, at + 200);
|
|
218
|
+
assert.ok(!/catch\s*\{\s*tracked\s*=\s*''/.test(around),
|
|
219
|
+
h.name + ' swallows a real ls-files failure as "not tracked": ' + around);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('P7 — all three hooks record their own deletion identically', () => {
|
|
224
|
+
// Two of the three watch a DIRECTORY rather than a file, so the deletion path has to work for
|
|
225
|
+
// both shapes — that is what this case proves, and it is why the fixtures differ.
|
|
226
|
+
for (const h of HOOKS) {
|
|
227
|
+
const root = project({});
|
|
228
|
+
try {
|
|
229
|
+
const ctl = h.make(root);
|
|
230
|
+
commitAll(root);
|
|
231
|
+
const before = Number(git(root, ['rev-list', '--count', 'HEAD']));
|
|
232
|
+
ctl.drop();
|
|
233
|
+
const r = run(root, h.name);
|
|
234
|
+
assert.equal(r.code, 0, h.name + ' must exit 0: ' + r.out);
|
|
235
|
+
assert.equal(Number(git(root, ['rev-list', '--count', 'HEAD'])), before + 1,
|
|
236
|
+
h.name + ' did not record its deletion');
|
|
237
|
+
assert.match(git(root, ['log', '-1', '--format=%s']), h.remove,
|
|
238
|
+
h.name + ' did not name the removal');
|
|
239
|
+
} finally { cleanup(root); }
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The package shipped a rule saying a database must not face the internet, and the rule said of
|
|
4
|
+
// itself — correctly — that it was not a check. This is the check.
|
|
5
|
+
//
|
|
6
|
+
// It is a REWRITE, not a transcription. MEASURED: the bash original it replaces never looked at
|
|
7
|
+
// `network_mode: host` (`grep network_mode check-port-conflicts.sh` → nothing), while
|
|
8
|
+
// rules/docker-ports.md forbids it explicitly. A faithful copy would have shipped a check unable to
|
|
9
|
+
// enforce the invariant it is named after.
|
|
10
|
+
//
|
|
11
|
+
// The load-bearing property is the THIRD exit code. A check that answers "clean" when it could not
|
|
12
|
+
// read the config is worse than no check: it turns an unknown into a reassurance. So an absent
|
|
13
|
+
// compose, a missing docker and an unparseable config all exit 2, and P6/P7/P8 assert exactly that.
|
|
14
|
+
//
|
|
15
|
+
// These are BEHAVIOUR tests: they run the real utility over real compose files with real docker.
|
|
16
|
+
|
|
17
|
+
const { test, describe } = require('node:test');
|
|
18
|
+
const assert = require('node:assert/strict');
|
|
19
|
+
const { spawnSync } = require('node:child_process');
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const os = require('node:os');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
25
|
+
const TPL = path.join(PKG, 'templates', '.claude');
|
|
26
|
+
const CHECK = path.join(TPL, 'hooks', 'check-ports.cjs');
|
|
27
|
+
|
|
28
|
+
const DOCKER = spawnSync('docker', ['--version'], { encoding: 'utf8' }).status === 0;
|
|
29
|
+
|
|
30
|
+
/** Write a compose file into a throwaway directory and run the real check over it. */
|
|
31
|
+
function check(compose, opts) {
|
|
32
|
+
const o = opts || {};
|
|
33
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-ports-')));
|
|
34
|
+
try {
|
|
35
|
+
if (compose !== null) fs.writeFileSync(path.join(dir, 'docker-compose.yml'), compose);
|
|
36
|
+
const env = Object.assign({}, process.env);
|
|
37
|
+
if (o.noDocker) env.PATH = dir; // an empty PATH: docker cannot be found
|
|
38
|
+
const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8', env });
|
|
39
|
+
return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
|
|
40
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const storagePublished = `services:
|
|
44
|
+
db:
|
|
45
|
+
image: postgres:16
|
|
46
|
+
ports: ["5432:5432"]
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
describe('the storage-port rule finally has a check that can fail', () => {
|
|
50
|
+
test('P1 — it ships as a hooks-directory component, not a new component kind', () => {
|
|
51
|
+
// The earlier decision to defer this assumed a new `scripts/` directory and five new consumers.
|
|
52
|
+
// MEASURED: hooks/ already carries a non-event utility (state-update.cjs), so an existing kind
|
|
53
|
+
// and an existing counter carry this too.
|
|
54
|
+
assert.ok(fs.existsSync(CHECK), 'the check must ship under templates/.claude/hooks/');
|
|
55
|
+
const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
|
|
56
|
+
assert.ok(COMPONENTS.hooks.items['check-ports'],
|
|
57
|
+
'it must be registered as a hooks component, or init/doctor/verify will not know it');
|
|
58
|
+
const statusline = fs.readFileSync(path.join(TPL, 'hooks', 'statusline.cjs'), 'utf-8');
|
|
59
|
+
const m = statusline.match(/hooksExpected:\s*(\d+)/);
|
|
60
|
+
assert.ok(m, 'statusline must declare hooksExpected');
|
|
61
|
+
assert.equal(Number(m[1]), Object.keys(COMPONENTS.hooks.items).length,
|
|
62
|
+
'the status line would report a phantom missing hook: ' + m[1]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('P2 — it is NOT registered as an event hook', () => {
|
|
66
|
+
// Registering it would run `docker compose config` on every session stop, on every project,
|
|
67
|
+
// including the ones with no compose at all. It is invoked deliberately.
|
|
68
|
+
const settings = fs.readFileSync(path.join(TPL, 'settings.json'), 'utf-8');
|
|
69
|
+
assert.ok(!settings.includes('check-ports'),
|
|
70
|
+
'check-ports must not be wired to an event — it is a utility, like state-update.cjs');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('P3 — a loopback bind PASSES: the exception is part of the rule', { skip: !DOCKER }, () => {
|
|
74
|
+
for (const bind of ['127.0.0.1:55432:5432', '[::1]:55432:5432']) {
|
|
75
|
+
const r = check('services:\n db:\n image: postgres:16\n ports: ["' + bind + '"]\n');
|
|
76
|
+
assert.equal(r.code, 0,
|
|
77
|
+
bind + ' is legitimate — a check without the exception forbids the test setup the same '
|
|
78
|
+
+ 'pipeline prescribes: ' + r.out);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('P4 — every forbidden form FAILS, including the one the original missed',
|
|
83
|
+
{ skip: !DOCKER }, () => {
|
|
84
|
+
const cases = [
|
|
85
|
+
['ports: ["5432:5432"]', 'no address at all binds every interface'],
|
|
86
|
+
['ports: ["0.0.0.0:5432:5432"]', 'all interfaces, explicitly'],
|
|
87
|
+
['ports: ["203.0.113.7:5432:5432"]', 'ONE public address — not "all interfaces", equally reachable'],
|
|
88
|
+
['network_mode: host', 'no ports: entry at all, and the container listens on the host'],
|
|
89
|
+
];
|
|
90
|
+
for (const [line, why] of cases) {
|
|
91
|
+
const r = check('services:\n db:\n image: postgres:16\n ' + line + '\n');
|
|
92
|
+
assert.equal(r.code, 1, 'must be a violation (' + why + '): ' + r.out);
|
|
93
|
+
assert.match(r.out, /Правило №0/, 'and must name the rule it enforces: ' + r.out);
|
|
94
|
+
assert.match(r.out, /db/, 'and the service: ' + r.out);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('P5 — an app published beside a reverse-proxy FAILS', { skip: !DOCKER }, () => {
|
|
99
|
+
const r = check('services:\n proxy:\n image: caddy:2\n ports: ["80:80"]\n'
|
|
100
|
+
+ ' app:\n image: node:22\n ports: ["3000:3000"]\n');
|
|
101
|
+
assert.equal(r.code, 1, 'the proxy is the only door: ' + r.out);
|
|
102
|
+
assert.match(r.out, /app/, 'the offending service must be named, not the proxy: ' + r.out);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('P6 — an unreadable config exits 2 and SAYS the check did not run', { skip: !DOCKER }, () => {
|
|
106
|
+
// The whole point of the third exit code. "Could not look" is not "nothing found".
|
|
107
|
+
const r = check('services:\n db:\n image: postgres:16\n'
|
|
108
|
+
+ ' ports: ["${UNSET_VAR:?required}:5432"]\n');
|
|
109
|
+
assert.equal(r.code, 2, 'an unreadable config must not read as clean: ' + r.out);
|
|
110
|
+
assert.match(r.out, /проверка НЕ выполнена/, 'and must say so in words: ' + r.out);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('P7 — no compose file exits 2', () => {
|
|
114
|
+
const r = check(null);
|
|
115
|
+
assert.equal(r.code, 2, 'nothing to check is not the same as nothing wrong: ' + r.out);
|
|
116
|
+
assert.match(r.out, /проверка НЕ выполнена/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('P8 — docker unavailable exits 2', () => {
|
|
120
|
+
// Driven by an empty PATH rather than by uninstalling docker: the property is the code path,
|
|
121
|
+
// and a test that needs the machine reconfigured is a test nobody runs.
|
|
122
|
+
const r = check(storagePublished, { noDocker: true });
|
|
123
|
+
assert.equal(r.code, 2, 'without docker the question cannot be answered: ' + r.out);
|
|
124
|
+
assert.match(r.out, /docker недоступен/, 'and the reason must be named: ' + r.out);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('P10 — one matrix, all three verdicts: a constant check cannot pass this',
|
|
128
|
+
{ skip: !DOCKER }, () => {
|
|
129
|
+
// Cross-family QE: every earlier case asserted ONE direction, so an always-clean binary passed
|
|
130
|
+
// P3 and P9, an always-failing one passed P4 and P5, and a completely non-functional one still
|
|
131
|
+
// passed P1 and P2. A single run of the SAME executable must produce all three codes.
|
|
132
|
+
const matrix = [
|
|
133
|
+
[0, 'services:\n db:\n image: postgres:16\n'],
|
|
134
|
+
[1, 'services:\n db:\n image: postgres:16\n ports: ["5432:5432"]\n'],
|
|
135
|
+
[2, null],
|
|
136
|
+
];
|
|
137
|
+
const seen = matrix.map(([, compose]) => check(compose).code);
|
|
138
|
+
assert.deepEqual(seen, [0, 1, 2],
|
|
139
|
+
'the same binary must answer clean / violation / could-not-check: ' + JSON.stringify(seen));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('P11 — the counter-examples the reviewer supplied', { skip: !DOCKER }, () => {
|
|
143
|
+
// Each of these was a real defect in the first version, and each is named by the input that
|
|
144
|
+
// produced it rather than by the rule it broke.
|
|
145
|
+
const cases = [
|
|
146
|
+
[0, 'services:\n db:\n image: postgres:16\n command:\n - postgres\n'
|
|
147
|
+
+ ' - -c\n - "log_line_prefix=published: 6543"\n',
|
|
148
|
+
'a sequence item under command: is not a published port'],
|
|
149
|
+
[0, 'services:\n ui:\n image: rediscommander/redis-commander:latest\n'
|
|
150
|
+
+ ' ports: ["8081:8081"]\n',
|
|
151
|
+
'a redis WEB UI is not storage — substring matching said it was'],
|
|
152
|
+
[1, 'services:\n db:\n image: mcr.microsoft.com/mssql/server:2022-latest\n'
|
|
153
|
+
+ ' ports: ["1433:1433"]\n',
|
|
154
|
+
'mssql on 1433 was missed by both the name list and the port set'],
|
|
155
|
+
[1, 'services:\n db:\n image: postgres:16\n ports: ["[::]:5432:5432"]\n',
|
|
156
|
+
'the IPv6 wildcard is in the rule\'s forbidden table and was untested'],
|
|
157
|
+
[1, 'services:\n db:\n image: myco/custom-store:1\n ports: ["5432:5432"]\n',
|
|
158
|
+
'an unrecognised engine on a well-known port is still storage'],
|
|
159
|
+
];
|
|
160
|
+
for (const [expected, compose, why] of cases) {
|
|
161
|
+
const r = check(compose);
|
|
162
|
+
assert.equal(r.code, expected, why + ' — got ' + r.code + ': ' + r.out);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('P12 — several services, and the right one is named', { skip: !DOCKER }, () => {
|
|
167
|
+
// Fields must not leak between services: the offender has to be identified, not just counted.
|
|
168
|
+
const r = check('services:\n cache:\n image: redis:7\n ports: ["127.0.0.1:6379:6379"]\n'
|
|
169
|
+
+ ' db:\n image: postgres:16\n ports: ["5432:5432"]\n'
|
|
170
|
+
+ ' web:\n image: node:22\n ports: ["3000:3000"]\n');
|
|
171
|
+
assert.equal(r.code, 1, r.out);
|
|
172
|
+
assert.match(r.out, /db/, 'the offender must be named: ' + r.out);
|
|
173
|
+
assert.ok(!/cache/.test(r.out),
|
|
174
|
+
'the loopback-bound cache is legal and must not be reported: ' + r.out);
|
|
175
|
+
assert.ok(!/web/.test(r.out),
|
|
176
|
+
'a non-storage service with no proxy present is not this rule\'s business: ' + r.out);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('P9 — a clean compose exits 0', { skip: !DOCKER }, () => {
|
|
180
|
+
const r = check('services:\n db:\n image: postgres:16\n'
|
|
181
|
+
+ ' web:\n image: node:22\n ports: ["3000:3000"]\n');
|
|
182
|
+
assert.equal(r.code, 0, 'a storage service with no publication is exactly right: ' + r.out);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Nothing in this package said a database port is not published to the internet. MEASURED: the
|
|
4
|
+
// Architecture Constraints handed to every project (templates/.claude/rules/replicate-pipeline.md)
|
|
5
|
+
// carried exactly five items — pattern, containers, infrastructure, deploy, MCP — and nothing about
|
|
6
|
+
// storage; a sweep of templates/ for such a rule returned only unrelated hits.
|
|
7
|
+
//
|
|
8
|
+
// The working check already exists, in a course repository, and when it fails it prints
|
|
9
|
+
// "Правило: .claude/rules/docker-ports.md, «Правило №0»" — pointing at a file this package did not
|
|
10
|
+
// ship. The rule and the check are two different things, and only the rule ships here — ADR-001 D2,
|
|
11
|
+
// whose load-bearing reasons are that a shipped script is a new component KIND touching five
|
|
12
|
+
// consumers, and that a Node rewrite of a bash+awk compose parser is real work, not a transcription.
|
|
13
|
+
//
|
|
14
|
+
// P6 is the load-bearing one for the future: "5 rules" was a constant maintained BY HAND in seven
|
|
15
|
+
// places, and missing one leaves doctor or the status line reporting a rule missing that is not.
|
|
16
|
+
|
|
17
|
+
const { test, describe } = require('node:test');
|
|
18
|
+
const assert = require('node:assert/strict');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const PKG_DIR = path.resolve(__dirname, '..', '..');
|
|
23
|
+
const read = (rel) => fs.readFileSync(path.join(PKG_DIR, rel), 'utf-8');
|
|
24
|
+
|
|
25
|
+
const RULE = path.join('templates', '.claude', 'rules', 'docker-ports.md');
|
|
26
|
+
const PIPELINE = path.join('templates', '.claude', 'rules', 'replicate-pipeline.md');
|
|
27
|
+
|
|
28
|
+
/** The Architecture Constraints block — the list actually handed to sparc-prd-mini. Scoped, because
|
|
29
|
+
* a constraint stated anywhere else in the file is not passed to anything. */
|
|
30
|
+
function constraints() {
|
|
31
|
+
const src = read(PIPELINE);
|
|
32
|
+
const start = src.indexOf('## Architecture Constraints');
|
|
33
|
+
const end = src.indexOf('\n## ', start + 1);
|
|
34
|
+
assert.ok(start > 0, 'replicate-pipeline.md must have an Architecture Constraints block');
|
|
35
|
+
assert.ok(end > start, 'and a following section');
|
|
36
|
+
return src.slice(start, end);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('a database port is not published to the internet (PR-012)', () => {
|
|
40
|
+
test('P1 — the constraints block carries the rule and names the file', () => {
|
|
41
|
+
const block = constraints();
|
|
42
|
+
// The INVARIANT, not one remedy. Cross-family QE: "does not publish outward" is too narrow —
|
|
43
|
+
// an explicit public address like 203.0.113.7:5432:5432 is not "all interfaces" and is exactly
|
|
44
|
+
// as reachable from the internet; and network_mode: host needs no ports: entry at all.
|
|
45
|
+
assert.match(block, /Storage: у баз и очередей НЕТ публикации на хост/,
|
|
46
|
+
'the constraint must be IN the block that is passed to Phase 1, not elsewhere in the file');
|
|
47
|
+
assert.match(block, /кроме привязки к петле/, 'and carry the loopback exception');
|
|
48
|
+
assert.match(block, /network_mode: host/,
|
|
49
|
+
'and name the escape path that needs no ports: entry at all');
|
|
50
|
+
assert.match(block, /docker-ports\.md/,
|
|
51
|
+
'and name the rule file, so the constraint and the rule are one thing rather than two');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('P2 — the rule file exists and carries the name the shipped check prints', () => {
|
|
55
|
+
// The check prints: Правило: .claude/rules/docker-ports.md, «Правило №0».
|
|
56
|
+
// If the heading is renamed, that pointer stops resolving and the reader is left at a dead end.
|
|
57
|
+
const src = read(RULE);
|
|
58
|
+
assert.match(src, /Правило №0/,
|
|
59
|
+
'the rule must carry the exact name the existing check refers people to');
|
|
60
|
+
// The forbidden list is the security content: each row is a way a reader gets it wrong.
|
|
61
|
+
for (const bad of ['0.0.0.0:5432:5432', '\\[::\\]:5432:5432', '203.0.113.7:5432:5432',
|
|
62
|
+
'network_mode: host']) {
|
|
63
|
+
assert.match(src, new RegExp(bad), 'the forbidden list must name: ' + bad);
|
|
64
|
+
}
|
|
65
|
+
assert.match(src, /убрать `ports:` целиком/,
|
|
66
|
+
'the remedy must be stated as removing publication, not as adding expose:');
|
|
67
|
+
assert.match(src, /`expose:` в современном Compose ничего не открывает/,
|
|
68
|
+
'and expose: must be described honestly — it documents, it does not grant access');
|
|
69
|
+
assert.match(src, /достучатся до\s+сервиса по его имени/,
|
|
70
|
+
'and why the remedy suffices — otherwise it reads as a loss of capability');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('P3 — the LOOPBACK exception is present, as an allowance and not a footnote', () => {
|
|
74
|
+
// Without it the rule forbids exactly what the same pipeline prescribes: a test environment that
|
|
75
|
+
// binds 127.0.0.1 so tests can run from the host. The field report's own pseudocode had this
|
|
76
|
+
// defect and would have failed the project's own compose.
|
|
77
|
+
const src = read(RULE);
|
|
78
|
+
assert.match(src, /Исключение: петля — это законно/,
|
|
79
|
+
'the exception needs its own heading — a rule whose exception is buried gets applied without it');
|
|
80
|
+
assert.match(src, /127\.0\.0\.1:55432:5432/, 'shown as a concrete legal line');
|
|
81
|
+
assert.match(src, /ЗАКОННО/, 'and labelled as permitted, not merely described');
|
|
82
|
+
// Both loopback forms are equals — a rule that allows ::1 while its own self-check greps only
|
|
83
|
+
// for 127.0.0.1 contradicts itself, which is what the first version did.
|
|
84
|
+
assert.match(src, /Петлевых форм две и они равноправны/,
|
|
85
|
+
'both loopback forms must be named as equals');
|
|
86
|
+
assert.match(src, /host_ip: ::1/,
|
|
87
|
+
'and the self-check must accept the IPv6 one it allows');
|
|
88
|
+
assert.match(src, /исключение\s*\n?такая же часть правила, как и запрет/,
|
|
89
|
+
'and its standing stated, so a later edit does not trim it as an aside');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('P4 — the rule says what it is NOT, so nobody infers a guarantee', () => {
|
|
93
|
+
// A rule that reads like a gate is worse than no rule: it stops people looking for the gate.
|
|
94
|
+
const src = read(RULE);
|
|
95
|
+
// The property has MOVED, not weakened. When this test was written the package shipped the rule
|
|
96
|
+
// and no check, so the limit was "nobody may infer a guarantee". A check now ships — so the
|
|
97
|
+
// limit becomes what the check does NOT cover, and the fact that it must be invoked. A rule that
|
|
98
|
+
// reads like an automatic gate is still worse than no rule; only the shape of the honesty moved.
|
|
99
|
+
assert.match(src, /Чем это правило НЕ является|Чего она НЕ делает/,
|
|
100
|
+
'the limit needs its own heading');
|
|
101
|
+
assert.match(src, /не привязана ни к какому событию — её надо позвать/,
|
|
102
|
+
'a check nobody invokes guarantees nothing, and the rule must say so');
|
|
103
|
+
assert.match(src, /конфликты с портами, уже занятыми другими контейнерами, она не ищет/,
|
|
104
|
+
'and name what it does not look at, so nobody infers a wider guarantee');
|
|
105
|
+
assert.match(src, /проверка НЕ\s*\n?ВЫПОЛНЕНА/,
|
|
106
|
+
'and the third exit code must be documented where a reader will look for it');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('P5 — the field report\'s non-verbatim compose fragment is not quoted', () => {
|
|
110
|
+
// The report quotes a postgres service carrying an `expose:` line. MEASURED: the real file has
|
|
111
|
+
// no such line. Copying it would put a fabricated quotation in a security rule.
|
|
112
|
+
const src = read(RULE);
|
|
113
|
+
assert.ok(!/image: postgres[\s\S]{0,200}expose:[\s\S]{0,40}5432[\s\S]{0,40}ports:/.test(src),
|
|
114
|
+
'the rule must not reproduce the report\'s composite fragment');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('P6 — every count site agrees with COMPONENTS.rules.items', () => {
|
|
118
|
+
// The property is AGREEMENT, not a particular number: the next rule added moves all seven or
|
|
119
|
+
// this fails. statusline.cjs is a TEMPLATE copied into other projects and cannot import src/,
|
|
120
|
+
// and the four prose sites are prose, so the constant is irreducibly distributed — what is
|
|
121
|
+
// removed here is the SILENCE when the sites disagree.
|
|
122
|
+
const utils = require(path.join(PKG_DIR, 'src', 'utils.js'));
|
|
123
|
+
const n = Object.keys(utils.COMPONENTS.rules.items).length;
|
|
124
|
+
assert.ok(n >= 6, 'the docker-ports rule must be registered as a component: got ' + n);
|
|
125
|
+
|
|
126
|
+
const statusline = read(path.join('templates', '.claude', 'hooks', 'statusline.cjs'));
|
|
127
|
+
const m = statusline.match(/rulesExpected:\s*(\d+)/);
|
|
128
|
+
assert.ok(m, 'statusline.cjs must declare rulesExpected');
|
|
129
|
+
assert.equal(Number(m[1]), n,
|
|
130
|
+
'statusline would report a phantom missing rule: rulesExpected=' + m[1] + ' vs ' + n);
|
|
131
|
+
|
|
132
|
+
// SCAN, do not enumerate. The first version listed six prose sites by hand and MISSED SEVEN more,
|
|
133
|
+
// including templates/.claude/commands/replicate.md — the file that SHIPS INTO EVERY GENERATED
|
|
134
|
+
// PROJECT and names the rules a run must not overwrite. `docker-ports` was absent from that list,
|
|
135
|
+
// so the security rule this feature added could be silently regenerated away. Two independent
|
|
136
|
+
// reviewers found it; a hand-picked list is how it survived my own review.
|
|
137
|
+
//
|
|
138
|
+
// Historical documents are exempt BY NAME: a changelog entry describing what 1.4 shipped is a
|
|
139
|
+
// true statement about the past, and rewriting it would be falsifying a record.
|
|
140
|
+
const HISTORICAL = /changelog/i;
|
|
141
|
+
const COUNT_CLAIM = /(\d+)\s+rules\b|(\d+)\s+правил|Rules\s+●(\d+)|\*\*(\d+) rules\*\*/g;
|
|
142
|
+
const scanned = [];
|
|
143
|
+
const walk = (dir) => {
|
|
144
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
145
|
+
const full = path.join(dir, e.name);
|
|
146
|
+
if (e.isDirectory()) {
|
|
147
|
+
if (e.name === 'node_modules' || e.name === '.git') continue;
|
|
148
|
+
walk(full);
|
|
149
|
+
} else if (/\.(md|html)$/.test(e.name) && !HISTORICAL.test(full)) {
|
|
150
|
+
scanned.push(full);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
walk(PKG_DIR);
|
|
155
|
+
assert.ok(scanned.length > 10, 'the scan must actually find documents: ' + scanned.length);
|
|
156
|
+
|
|
157
|
+
// `claimed`, not `n`. A rename made the inner variable shadow the authoritative one and the
|
|
158
|
+
// comparison became `n !== n` — a test that could never fail, caught by mutation and not by
|
|
159
|
+
// reading it. The same shape a reviewer caught in this suite's sibling yesterday.
|
|
160
|
+
const wrong = [];
|
|
161
|
+
for (const file of scanned) {
|
|
162
|
+
const text = fs.readFileSync(file, 'utf-8');
|
|
163
|
+
for (const hit of text.matchAll(COUNT_CLAIM)) {
|
|
164
|
+
// A release-notes SECTION inside an otherwise-current document is historical too — the README
|
|
165
|
+
// and the RU html bundle both embed one. The test is not a character window (which missed a
|
|
166
|
+
// line 1300 lines below its heading) but the NEAREST PRECEDING heading: a count written under
|
|
167
|
+
// a version heading is a statement about that version, and rewriting it would falsify a
|
|
168
|
+
// record.
|
|
169
|
+
const before = text.slice(0, hit.index);
|
|
170
|
+
const headings = before.match(/^#{1,3} .*$|^<h[1-3][^>]*>.*$/gm) || [];
|
|
171
|
+
const nearest = headings.length ? headings[headings.length - 1] : '';
|
|
172
|
+
const historicalSection = /changelog|release notes|version history|история версий|\[?v?\d+\.\d+/i;
|
|
173
|
+
if (historicalSection.test(nearest)) continue;
|
|
174
|
+
// and an explicit past-tense marker on the line itself
|
|
175
|
+
const lineStart = before.lastIndexOf('\n') + 1;
|
|
176
|
+
const line = text.slice(lineStart, text.indexOf('\n', hit.index));
|
|
177
|
+
if (/initial published version|первая опубликованная/i.test(line)) continue;
|
|
178
|
+
const claimed = Number(hit[1] || hit[2] || hit[3] || hit[4]);
|
|
179
|
+
if (claimed !== n) {
|
|
180
|
+
wrong.push(path.relative(PKG_DIR, file) + ': "' + hit[0].trim() + '"');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
assert.deepEqual(wrong, [],
|
|
185
|
+
'these say a rule count that is not ' + n + ': ' + JSON.stringify(wrong, null, 1));
|
|
186
|
+
|
|
187
|
+
// An EIGHTH site the survey missed // An EIGHTH site the survey missed, found by the suite going red: the e2e --help assertion,
|
|
188
|
+
// which is not a file constant but a re-typed expectation. cli.js:57 already derives its number
|
|
189
|
+
// from COMPONENTS, so a test that hardcodes one is a second source of truth — exactly the defect
|
|
190
|
+
// its own comment says it exists to catch ("cli.js used to say 1 rule while EXPECTED_RULES had 2
|
|
191
|
+
// entries"). It is now derived, and this asserts it stays derived.
|
|
192
|
+
const e2e = read(path.join('tests', 'e2e', 'lifecycle.test.js'));
|
|
193
|
+
const helpAt = e2e.indexOf("describe('e2e: --help shows correct component counts'");
|
|
194
|
+
assert.ok(helpAt > 0, 'the --help count test must exist');
|
|
195
|
+
const helpBlock = e2e.slice(helpAt, e2e.indexOf('});', e2e.indexOf('} finally', helpAt)));
|
|
196
|
+
assert.match(helpBlock, /Object\.keys\(COMPONENTS\[group\]\.items\)\.length/,
|
|
197
|
+
'the --help count test must DERIVE from COMPONENTS, not re-type the numbers');
|
|
198
|
+
assert.ok(!/\/\d+\\s\+rules\//.test(helpBlock),
|
|
199
|
+
'and must not carry a hardcoded rule count');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('P7 — the HOOK surface stays Node-only', () => {
|
|
203
|
+
// ADR-001 D2, NARROWED by measurement during Step 7. The first version asserted that NO .sh
|
|
204
|
+
// ships anywhere in templates/, and that is FALSE: skills/brutal-honesty-review/scripts/
|
|
205
|
+
// assess-tests.sh and assess-code.sh already ship. The cross-platform promise in settings.json
|
|
206
|
+
// ("Cross-platform Node scripts (no bash dependencies)") is scoped to the HOOKS — all six are
|
|
207
|
+
// .cjs so Windows works — and a port check is hook-shaped, so that is the surface to guard.
|
|
208
|
+
// Asserting the broader claim would have been a test enforcing something untrue.
|
|
209
|
+
const hooks = path.join(PKG_DIR, 'templates', '.claude', 'hooks');
|
|
210
|
+
const files = fs.readdirSync(hooks);
|
|
211
|
+
assert.deepEqual(files.filter((f) => f.endsWith('.sh')), [],
|
|
212
|
+
'a .sh among the hooks contradicts the cross-platform promise in settings.json');
|
|
213
|
+
assert.ok(files.filter((f) => f.endsWith('.cjs')).length >= 6,
|
|
214
|
+
'and the six shipped hooks must still be .cjs: ' + JSON.stringify(files));
|
|
215
|
+
});
|
|
216
|
+
});
|