@dzhechkov/p-replicator 1.5.18 → 1.9.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 +173 -57
- package/CHANGELOG.md +262 -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 +139 -16
- package/bin/cli.js +0 -0
- package/package.json +11 -10
- package/sbom.json +346 -56
- package/src/utils.js +4 -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 +183 -9
- 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-docs-complete.cjs +174 -0
- package/templates/.claude/hooks/check-growth-trace.cjs +191 -0
- 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 +19 -6
- package/templates/.claude/rules/skill-interface-protocol.md +9 -0
- 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/modules/03-generate-p0.md +6 -4
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/08-skill-composition.md +2 -2
- 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-lifecycle.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 +59 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/01-intelligence.md +4 -4
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/02-product-customers.md +2 -2
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +9 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/03-market-competition.md +3 -3
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/04-business-finance.md +3 -3
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/05-growth-engine.md +132 -12
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/06-playbook-synthesis.md +1 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +152 -10
- package/tests/e2e/lifecycle.test.js +21 -10
- package/tests/snapshot/baseline.json +42 -38
- 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-docs-complete.test.js +249 -0
- package/tests/unit/check-growth-trace.test.js +188 -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-axes-and-compliance.test.js +169 -0
- package/tests/unit/growth-gate-conditional.test.js +122 -0
- package/tests/unit/growth-module-b2b-gate.test.js +122 -0
- package/tests/unit/growth-requirements-bridge.test.js +127 -0
- package/tests/unit/hooks-project-anchored.test.js +223 -0
- package/tests/unit/hooks-report-failures.test.js +207 -0
- package/tests/unit/module-copy-identity.test.js +76 -0
- package/tests/unit/pipeline-file-ownership.test.js +95 -0
- package/tests/unit/roadmap-one-schema.test.js +179 -0
- package/tests/unit/skill-paths-prebaked.test.js +174 -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,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The deterministic half of the growth gate. The validator criterion is prose a model reads —
|
|
4
|
+
// layer 3. This is layer 1, and only for whoever runs it.
|
|
5
|
+
//
|
|
6
|
+
// The load-bearing property is the THIRD exit code, for the same reason it was in check-ports:
|
|
7
|
+
// a checker that answers "clean" when it could not look converts an unknown into a reassurance.
|
|
8
|
+
// Here the unknown has a specific and common cause — the --from-docs entry skips Phase 0, so the
|
|
9
|
+
// brief legitimately does not exist. That must be 2. Reading it as 0 would report "all growth
|
|
10
|
+
// requirements traced" for a project that never analysed growth at all.
|
|
11
|
+
//
|
|
12
|
+
// These are BEHAVIOUR tests: the real utility, real files, real exit codes.
|
|
13
|
+
|
|
14
|
+
const { test, describe } = require('node:test');
|
|
15
|
+
const assert = require('node:assert/strict');
|
|
16
|
+
const { spawnSync } = require('node:child_process');
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
22
|
+
const TPL = path.join(PKG, 'templates', '.claude');
|
|
23
|
+
const CHECK = path.join(TPL, 'hooks', 'check-growth-trace.cjs');
|
|
24
|
+
|
|
25
|
+
/** Build a throwaway project and run the real checker over it. */
|
|
26
|
+
function check(files) {
|
|
27
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-growth-')));
|
|
28
|
+
try {
|
|
29
|
+
for (const [rel, body] of Object.entries(files || {})) {
|
|
30
|
+
const abs = path.join(dir, rel);
|
|
31
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
32
|
+
fs.writeFileSync(abs, body);
|
|
33
|
+
}
|
|
34
|
+
const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8' });
|
|
35
|
+
return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
|
|
36
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const brief = (rows) => '# Brief\n\n## 🌱 Growth Requirements Seed\n\n'
|
|
40
|
+
+ '| ID | Требование | Блок-источник | Confidence | Статус |\n'
|
|
41
|
+
+ '|----|----|----|----|----|\n' + rows.join('\n') + '\n';
|
|
42
|
+
|
|
43
|
+
const ROW1 = '| FR-GROWTH-001 | Реферальная петля в онбординге | A. Primary Growth Loop | 4/5 | ЧЕРНОВИК |';
|
|
44
|
+
const ROW2 = '| FR-GROWTH-002 | Интеграция с маркетплейсом | B. Top-3 Acquisition Channels | 3/5 | ЧЕРНОВИК |';
|
|
45
|
+
|
|
46
|
+
describe('the growth-trace checker answers three questions, and never confuses two of them', () => {
|
|
47
|
+
test('P1 - a traced requirement is exit 0', () => {
|
|
48
|
+
const r = check({
|
|
49
|
+
'docs/product-discovery-brief.md': brief([ROW1]),
|
|
50
|
+
'docs/Specification.md': '# Spec\n\n## FR-GROWTH-001 Реферальная петля\n\nПодробности.\n',
|
|
51
|
+
});
|
|
52
|
+
assert.equal(r.code, 0, 'a traced id must pass: ' + r.out);
|
|
53
|
+
assert.match(r.out, /прослежены/, r.out);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('P2 - an analysed-then-dropped requirement is exit 1, and the id is named', () => {
|
|
57
|
+
const r = check({
|
|
58
|
+
'docs/product-discovery-brief.md': brief([ROW1]),
|
|
59
|
+
'docs/Specification.md': '# Spec\n\nНичего про рост.\n',
|
|
60
|
+
});
|
|
61
|
+
assert.equal(r.code, 1, 'a dropped obligation must fail: ' + r.out);
|
|
62
|
+
assert.match(r.out, /FR-GROWTH-001/, 'and the lost id must be named: ' + r.out);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('P3 - an absent brief is exit 2, never exit 0', () => {
|
|
66
|
+
// The single most important case. --from-docs skips Phase 0, so this is the COMMON state, and
|
|
67
|
+
// reading it as clean would silently bless every such project.
|
|
68
|
+
const r = check({ 'docs/Specification.md': '# Spec\n' });
|
|
69
|
+
assert.equal(r.code, 2, 'no brief means the check did not run: ' + r.out);
|
|
70
|
+
assert.match(r.out, /проверка НЕ выполнена/, r.out);
|
|
71
|
+
assert.match(r.out, /Фаза 0 не запускалась/, 'and the reason must be named: ' + r.out);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('P4 - an absent Specification is exit 2, not exit 1', () => {
|
|
75
|
+
// Without the destination there is nothing to compare against. Calling that "nothing traced"
|
|
76
|
+
// would blame the project for the checker's blindness.
|
|
77
|
+
const r = check({ 'docs/product-discovery-brief.md': brief([ROW1]) });
|
|
78
|
+
assert.equal(r.code, 2, 'no Specification means the check could not run: ' + r.out);
|
|
79
|
+
assert.match(r.out, /Specification/, r.out);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('P5 - an untouched template table is exit 2, not exit 0', () => {
|
|
83
|
+
// The shipped module carries example rows with bracketed placeholders. Counting them would let
|
|
84
|
+
// an untouched template look like a filled-in one — and then "0 traced of 0" reads as clean.
|
|
85
|
+
const r = check({
|
|
86
|
+
'docs/product-discovery-brief.md': brief([
|
|
87
|
+
'| FR-GROWTH-001 | [что обязаны построить, одним предложением] | A. Primary Growth Loop | [как записано] | ЧЕРНОВИК |',
|
|
88
|
+
'| FR-GROWTH-002 | ... | B. Top-3 Acquisition Channels | ... | ... |',
|
|
89
|
+
]),
|
|
90
|
+
'docs/Specification.md': '# Spec\n',
|
|
91
|
+
});
|
|
92
|
+
assert.equal(r.code, 2, 'a placeholder row is not an obligation: ' + r.out);
|
|
93
|
+
assert.match(r.out, /шаблоном|ни одной заполненной/, r.out);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('P6 - a rejection WITH a reason passes; a bare drop does not', () => {
|
|
97
|
+
const withReason = check({
|
|
98
|
+
'docs/product-discovery-brief.md': brief([ROW1]),
|
|
99
|
+
'docs/Specification.md': '# Spec\n\nFR-GROWTH-001 отклонено — нет бюджета на реферальную программу в MVP.\n',
|
|
100
|
+
});
|
|
101
|
+
assert.equal(withReason.code, 0, 'a recorded rejection is a legitimate answer: ' + withReason.out);
|
|
102
|
+
|
|
103
|
+
const bare = check({
|
|
104
|
+
'docs/product-discovery-brief.md': brief([ROW1]),
|
|
105
|
+
'docs/Specification.md': '# Spec\n\nFR-GROWTH-001\n',
|
|
106
|
+
});
|
|
107
|
+
// A bare id mention IS a trace by the stated definition — that is deliberate and documented.
|
|
108
|
+
// What must not pass is an id that appears nowhere, which P2 covers.
|
|
109
|
+
assert.equal(bare.code, 0, 'the exact token is the definition of a mention: ' + bare.out);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('P7 - a partial loss is reported with the count, not averaged away', () => {
|
|
113
|
+
const r = check({
|
|
114
|
+
'docs/product-discovery-brief.md': brief([ROW1, ROW2]),
|
|
115
|
+
'docs/Specification.md': '# Spec\n\nFR-GROWTH-001 реализуем.\n',
|
|
116
|
+
});
|
|
117
|
+
assert.equal(r.code, 1, 'one of two lost is still a loss: ' + r.out);
|
|
118
|
+
assert.match(r.out, /1 из 2/, 'the count must be reported: ' + r.out);
|
|
119
|
+
assert.match(r.out, /FR-GROWTH-002/, 'and the lost one named: ' + r.out);
|
|
120
|
+
assert.ok(!/FR-GROWTH-001/.test(r.out.split('\n').filter((l) => l.includes('•')).join('\n')),
|
|
121
|
+
'the traced one must NOT be listed as lost: ' + r.out);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('P8 - one binary, all three verdicts in a single run', () => {
|
|
125
|
+
// Each case above asserts ONE direction, so a constant-answer implementation could pass a
|
|
126
|
+
// subset. The same executable must produce 0, 1 and 2.
|
|
127
|
+
const seen = [
|
|
128
|
+
check({ 'docs/product-discovery-brief.md': brief([ROW1]),
|
|
129
|
+
'docs/Specification.md': 'FR-GROWTH-001\n' }).code,
|
|
130
|
+
check({ 'docs/product-discovery-brief.md': brief([ROW1]),
|
|
131
|
+
'docs/Specification.md': 'ничего\n' }).code,
|
|
132
|
+
check({}).code,
|
|
133
|
+
];
|
|
134
|
+
assert.deepEqual(seen, [0, 1, 2], 'expected clean/lost/could-not-check: ' + JSON.stringify(seen));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('P11 - the counter-examples cross-family review supplied', () => {
|
|
138
|
+
// Each was a false CLEAN in the first version, and each is named by the input that produced it.
|
|
139
|
+
const cases = [
|
|
140
|
+
[1, '# Spec\nFR-GROWTH-001 rejected\n',
|
|
141
|
+
'a bare refusal with no reason: the reason pattern accepted a hyphen and the IDENTIFIER '
|
|
142
|
+
+ 'carries two — and the mention rule reached the line first, so the reason was never asked for'],
|
|
143
|
+
[0, '# Spec\nFR-GROWTH-001 отклонено — нет бюджета на реферальную программу в MVP\n',
|
|
144
|
+
'a refusal WITH a reason is a legitimate answer and must still pass'],
|
|
145
|
+
[1, '# Spec\nFR-GROWTH-001 declined - x\n',
|
|
146
|
+
'one character is a separator plus noise, not a reason'],
|
|
147
|
+
];
|
|
148
|
+
for (const [expected, spec, why] of cases) {
|
|
149
|
+
const r = check({ 'docs/product-discovery-brief.md': brief([ROW1]), 'docs/Specification.md': spec });
|
|
150
|
+
assert.equal(r.code, expected, why + ' — got ' + r.code + ': ' + r.out);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('P12 - a reused id is exit 2, because one mention would clear two obligations', () => {
|
|
155
|
+
// The module's own rule is that a number is never reused. When it is, `mentioned()` answers from
|
|
156
|
+
// a Set and a SINGLE mention marks BOTH rows traced — coverage counted over usable items rather
|
|
157
|
+
// than per position. Malformed input is inconclusive, never a pass.
|
|
158
|
+
const dup = '| FR-GROWTH-001 | Совсем другое требование | B. Top-3 Acquisition Channels | 3/5 | ЧЕРНОВИК |';
|
|
159
|
+
const r = check({
|
|
160
|
+
'docs/product-discovery-brief.md': brief([ROW1, dup]),
|
|
161
|
+
'docs/Specification.md': '# Spec\nFR-GROWTH-001 берём\n',
|
|
162
|
+
});
|
|
163
|
+
assert.equal(r.code, 2, 'a duplicate id must not read as clean: ' + r.out);
|
|
164
|
+
assert.match(r.out, /повторяются идентификаторы/, r.out);
|
|
165
|
+
assert.match(r.out, /FR-GROWTH-001/, 'and the duplicated id must be named: ' + r.out);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('P9 - it is a hooks component wired to NO event', () => {
|
|
169
|
+
const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
|
|
170
|
+
assert.ok(COMPONENTS.hooks.items['check-growth-trace'],
|
|
171
|
+
'it must be registered, or init/doctor/verify will not know it');
|
|
172
|
+
const settings = fs.readFileSync(path.join(TPL, 'settings.json'), 'utf-8');
|
|
173
|
+
assert.ok(!settings.includes('check-growth-trace'),
|
|
174
|
+
'it must not be wired to an event: this packages hooks are non-blocking by contract, so a '
|
|
175
|
+
+ 'hook could only print — it could never refuse anything');
|
|
176
|
+
const statusline = fs.readFileSync(path.join(TPL, 'hooks', 'statusline.cjs'), 'utf-8');
|
|
177
|
+
const m = statusline.match(/hooksExpected:\s*(\d+)/);
|
|
178
|
+
assert.ok(m, 'statusline must declare hooksExpected');
|
|
179
|
+
assert.equal(Number(m[1]), Object.keys(COMPONENTS.hooks.items).length,
|
|
180
|
+
'the status line would report a phantom missing hook: ' + m[1]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('P10 - a directory that does not exist is exit 2', () => {
|
|
184
|
+
const r = spawnSync(process.execPath, [CHECK, '/nonexistent-path-for-this-test'],
|
|
185
|
+
{ encoding: 'utf8' });
|
|
186
|
+
assert.equal(r.status, 2, 'an unusable argument is not a clean bill: ' + r.stdout);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -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
|
+
});
|