@dzhechkov/p-replicator 1.5.17 → 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 +173 -65
- 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 +41 -18
- package/bin/cli.js +0 -0
- package/package.json +11 -10
- package/sbom.json +347 -77
- 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/goap-research-ed25519/SKILL.md +340 -47
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
- package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
- package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
- 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 +51 -34
- 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,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// adr-scanner-both-shapes (ADR-001) — the content contract of the toolkit generator's ADR handling.
|
|
4
|
+
//
|
|
5
|
+
// Why content assertions: these are PROMPT modules executed by a model, so the strongest
|
|
6
|
+
// deterministic layer available is the text itself. Before this feature, `has_adr` required a
|
|
7
|
+
// docs/adr/ DIRECTORY with >5 files while /replicate writes ONE docs/ADR.md — the flag was
|
|
8
|
+
// unreachable and every `IF has_adr:` consumer was dead code. Worse, a naive fix (relaxing the
|
|
9
|
+
// threshold) would have rerouted SPARC projects, because the flag sat inside pipeline routing.
|
|
10
|
+
//
|
|
11
|
+
// P1..P5 below mirror the ADR's Confirmation section. Each is DISCRIMINATING: restoring the old
|
|
12
|
+
// text at any site turns the matching assertion red (verified by mutation at Step 8).
|
|
13
|
+
|
|
14
|
+
const { test, describe } = require('node:test');
|
|
15
|
+
const assert = require('node:assert/strict');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
const GEN = path.join(__dirname, '..', '..', 'templates', '.claude', 'skills', 'cc-toolkit-generator-enhanced');
|
|
20
|
+
|
|
21
|
+
function read(rel) {
|
|
22
|
+
return fs.readFileSync(path.join(GEN, rel), 'utf-8');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Every .md under the generator skill, relative paths. */
|
|
26
|
+
function allModules(dir = GEN, acc = []) {
|
|
27
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
28
|
+
const p = path.join(dir, e.name);
|
|
29
|
+
if (e.isDirectory()) allModules(p, acc);
|
|
30
|
+
else if (e.name.endsWith('.md')) acc.push(path.relative(GEN, p));
|
|
31
|
+
}
|
|
32
|
+
return acc;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('ADR scanner contract (adr-scanner-both-shapes ADR-001)', () => {
|
|
36
|
+
test('P1 — exactly ONE has_adr definition across the WHOLE generator, and it counts decisions', () => {
|
|
37
|
+
// AM-1: a second `>5` copy hid in references/enhanced-recommendations.md — hence the tree-wide
|
|
38
|
+
// sweep, not a single-file check.
|
|
39
|
+
const defs = [];
|
|
40
|
+
for (const rel of allModules()) {
|
|
41
|
+
const src = read(rel);
|
|
42
|
+
for (const line of src.split('\n')) {
|
|
43
|
+
if (/^\s*has_adr\s*=/.test(line)) defs.push({ rel, line: line.trim() });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
assert.equal(defs.length, 1,
|
|
47
|
+
'exactly one has_adr definition may exist; found: ' + JSON.stringify(defs));
|
|
48
|
+
assert.match(defs[0].line, /collect_adrs/,
|
|
49
|
+
'the one definition must count collector output (decisions), not raw files');
|
|
50
|
+
assert.ok(!/>\s*5/.test(defs[0].line), 'the unreachable >5 file threshold must not survive');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('P2 — the scan catalog names BOTH storage shapes', () => {
|
|
54
|
+
const src = read(path.join('modules', '01-detect-parse.md'));
|
|
55
|
+
assert.ok(src.includes('docs/adr/*.md'), 'directory shape must stay catalogued');
|
|
56
|
+
assert.ok(src.includes('docs/ADR.md'), 'the single-file shape /replicate writes must be catalogued');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('P3 — pipeline routing never consults has_adr (the SPARC-reroute trap)', () => {
|
|
60
|
+
const src = read(path.join('modules', '01-detect-parse.md'));
|
|
61
|
+
const m = src.match(/def detect_pipeline[\s\S]*?\n```/);
|
|
62
|
+
assert.ok(m, 'detect_pipeline block must exist');
|
|
63
|
+
// AM-2: strip comment lines first. A comment EXPLAINING why the flag is absent is not the flag
|
|
64
|
+
// participating in routing — the first draft of this assertion failed on its own rationale
|
|
65
|
+
// comment. Mention is not use; the check must read code, not prose.
|
|
66
|
+
const code = m[0].split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
|
|
67
|
+
assert.ok(!/\bhas_adr\b/.test(code),
|
|
68
|
+
'has_adr inside detect_pipeline CODE re-creates the reroute hazard the ADR refuses');
|
|
69
|
+
assert.ok(/has_idea2prd_adr_dir\s*=\s*len\(glob\(f"\{docs_path\}\/docs\/adr\/\*\.md"\)\)\s*>\s*5/.test(m[0]),
|
|
70
|
+
'the shape marker must keep the byte-identical routing expression');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('P4 — zero private ADR globs outside the collector', () => {
|
|
74
|
+
// The dead-code class came from consumers re-reading a directory for themselves. Only the
|
|
75
|
+
// collector (inside 01-detect-parse.md) may touch ADR paths.
|
|
76
|
+
const offenders = [];
|
|
77
|
+
for (const rel of allModules()) {
|
|
78
|
+
if (rel === path.join('modules', '01-detect-parse.md')) continue;
|
|
79
|
+
const src = read(rel);
|
|
80
|
+
for (const line of src.split('\n')) {
|
|
81
|
+
// AM-2: the pipeline-IDENTITY shape marker is the ONE named exception. It COUNTS files to
|
|
82
|
+
// recognise which pipeline produced the docs; it never reads ADR content, so it cannot
|
|
83
|
+
// re-create the dead-code class. It must carry the marker name — an unnamed glob is an
|
|
84
|
+
// offender even if its expression is identical.
|
|
85
|
+
// Cross-family QE (gpt-5.6-sol): keying the exemption on the NAME ANYWHERE let a private
|
|
86
|
+
// glob smuggle itself past by mentioning the marker in a trailing comment. The exemption now
|
|
87
|
+
// requires the line to BE the marker's assignment.
|
|
88
|
+
if (/^\s*has_idea2prd_adr_dir\s*=/.test(line)) continue;
|
|
89
|
+
if (/glob\([^)]*adr\/\*\.md/.test(line) || /FOR EACH adr\/\*\.md/.test(line) || /SCAN adr\/\*\.md/.test(line)) {
|
|
90
|
+
offenders.push(rel + ': ' + line.trim());
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
assert.deepEqual(offenders, [],
|
|
95
|
+
'consumer modules must iterate detected_docs.idea2prd.adrs, never glob for themselves');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('P5 — the collector carries the worked examples, acid ids verbatim', () => {
|
|
99
|
+
const src = read(path.join('modules', '01-detect-parse.md'));
|
|
100
|
+
for (const acid of ['A1', 'A4', 'A5']) {
|
|
101
|
+
assert.ok(new RegExp('\\*\\*' + acid + '\\*\\*').test(src),
|
|
102
|
+
'worked example ' + acid + ' must be stated in the collector section');
|
|
103
|
+
}
|
|
104
|
+
assert.ok(src.includes('collect_adrs'), 'the collector must exist by its contract name');
|
|
105
|
+
assert.ok(src.includes('dedupe_stable_first_by_id_then_title'),
|
|
106
|
+
'the merge must dedupe STABLE-FIRST (A5) — precedence stated, not left to the helper');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -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
|
+
});
|