@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,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Each autocommit hook ended in a bare swallow — `catch (_err) { process.exit(0); }` — and the
|
|
4
|
+
// comment beside it said "never break Claude session on commit failures". The goal was right and the
|
|
5
|
+
// method was wrong: NOT BREAKING THE SESSION and SAYING NOTHING are different things, and only the
|
|
6
|
+
// first one is the contract, because the contract is about the exit code.
|
|
7
|
+
//
|
|
8
|
+
// This is not a hypothetical class. It is the class that hid a permanent defect: git commit put -m
|
|
9
|
+
// AFTER the `--`, so git read the message as a pathspec and every commit failed, from every
|
|
10
|
+
// directory, forever. Three hooks had never committed anything and nothing said so. It surfaced on
|
|
11
|
+
// 2026-08-26 only because a test asserted an EFFECT rather than an exit code.
|
|
12
|
+
//
|
|
13
|
+
// NOT changed here, and left to the owner: whether a DELETED target should be committed. That is a
|
|
14
|
+
// product question — is removing your roadmap something you want auto-committed? — and it is filed.
|
|
15
|
+
|
|
16
|
+
const { test, describe } = require('node:test');
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const { execFileSync } = require('node:child_process');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
24
|
+
|
|
25
|
+
const HOOKS = [
|
|
26
|
+
{ name: 'autocommit-roadmap', artifact: '.claude/feature-roadmap.json',
|
|
27
|
+
write: (root) => {
|
|
28
|
+
fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
|
|
29
|
+
return { file: path.join(root, '.claude', 'feature-roadmap.json'), body: '{"v":1}\n' };
|
|
30
|
+
} },
|
|
31
|
+
{ name: 'autocommit-insights', artifact: '.claude/insights/',
|
|
32
|
+
write: (root) => {
|
|
33
|
+
fs.mkdirSync(path.join(root, '.claude', 'insights'), { recursive: true });
|
|
34
|
+
return { file: path.join(root, '.claude', 'insights', 'index.md'), body: '## one\n' };
|
|
35
|
+
} },
|
|
36
|
+
{ name: 'autocommit-plans', artifact: 'docs/plans/',
|
|
37
|
+
write: (root) => {
|
|
38
|
+
fs.mkdirSync(path.join(root, 'docs', 'plans'), { recursive: true });
|
|
39
|
+
return { file: path.join(root, 'docs', 'plans', 'a.md'), body: '# plan\n' };
|
|
40
|
+
} },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** A project with the hooks installed. `identity` decides whether git can commit at all. */
|
|
44
|
+
function project(opts) {
|
|
45
|
+
const o = opts || {};
|
|
46
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-report-')));
|
|
47
|
+
fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true });
|
|
48
|
+
for (const h of HOOKS) {
|
|
49
|
+
fs.copyFileSync(path.join(TPL, 'hooks', h.name + '.cjs'),
|
|
50
|
+
path.join(root, '.claude', 'hooks', h.name + '.cjs'));
|
|
51
|
+
}
|
|
52
|
+
if (o.git !== false) {
|
|
53
|
+
const git = (args) => execFileSync('git', args, { cwd: root, stdio: 'ignore' });
|
|
54
|
+
git(['init', '-q']);
|
|
55
|
+
// An EMPTY identity is a real, common failure: git refuses to commit and says why. It is the
|
|
56
|
+
// cheapest way to drive the failure path without breaking the fixture in an artificial way.
|
|
57
|
+
git(['config', 'user.email', o.identity === false ? '' : 'test@example.invalid']);
|
|
58
|
+
git(['config', 'user.name', o.identity === false ? '' : 'test']);
|
|
59
|
+
}
|
|
60
|
+
return root;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Run a hook and capture BOTH streams. The stream matters: MEASURED in the Claude Code binary,
|
|
64
|
+
* a hook exiting 0 has its stderr discarded ("Exit code 0 - stdout/stderr not shown") while stdout
|
|
65
|
+
* can be surfaced ("Exit code 0 - stdout shown in transcript mode (ctrl+o)"). The first version of
|
|
66
|
+
* this feature wrote its notice to stderr — the one stream guaranteed to be invisible. */
|
|
67
|
+
function runHook(root, hookName) {
|
|
68
|
+
const script = path.join(root, '.claude', 'hooks', hookName + '.cjs');
|
|
69
|
+
const env = Object.assign({}, process.env, { CLAUDE_PROJECT_DIR: root });
|
|
70
|
+
const out = require('node:child_process').spawnSync(process.execPath, [script],
|
|
71
|
+
{ cwd: root, env, encoding: 'utf8' });
|
|
72
|
+
return { code: out.status, stdout: out.stdout || '', stderr: out.stderr || '' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cleanup = (d) => fs.rmSync(d, { recursive: true, force: true });
|
|
76
|
+
|
|
77
|
+
describe('an autocommit hook that cannot commit says so, once, without blocking', () => {
|
|
78
|
+
test('P1 — a real git failure is REPORTED, and the exit code is still 0', () => {
|
|
79
|
+
const root = project({ identity: false });
|
|
80
|
+
try {
|
|
81
|
+
const h = HOOKS[0];
|
|
82
|
+
const t = h.write(root);
|
|
83
|
+
fs.writeFileSync(t.file, t.body);
|
|
84
|
+
const r = runHook(root, h.name);
|
|
85
|
+
assert.equal(r.code, 0,
|
|
86
|
+
'the non-blocking contract is about the EXIT CODE and must be untouched');
|
|
87
|
+
assert.ok(r.stdout.includes('[' + h.name + ']'),
|
|
88
|
+
'the line must name the hook, or the reader cannot tell which artifact is uncommitted: '
|
|
89
|
+
+ r.stdout);
|
|
90
|
+
assert.ok(r.stdout.includes(h.artifact),
|
|
91
|
+
'and name the artifact: ' + r.stdout);
|
|
92
|
+
// WHY, not just what. git's own words are the only thing that says why, and they are only
|
|
93
|
+
// available because stderr is piped rather than ignored.
|
|
94
|
+
assert.match(r.stdout, /identity/i,
|
|
95
|
+
"git's own reason must survive into the message: " + r.stdout);
|
|
96
|
+
assert.equal(r.stdout.trim().split('\n').length, 1,
|
|
97
|
+
'exactly one line — a hook that floods output is a hook people silence: ' + r.stdout);
|
|
98
|
+
assert.equal(r.stderr.trim(), '',
|
|
99
|
+
'the notice must NOT go to stderr: a hook exiting 0 has its stderr discarded, so writing '
|
|
100
|
+
+ 'there is writing nowhere');
|
|
101
|
+
// The stage must be accurate. The outer catch also sees staging failures and a missing git
|
|
102
|
+
// binary, and calling either of those "could not commit" sends the reader to the wrong place.
|
|
103
|
+
assert.match(r.stdout, /could not commit/,
|
|
104
|
+
'this fixture fails AT the commit, so that is what the line must say: ' + r.stdout);
|
|
105
|
+
} finally { cleanup(root); }
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('P2 — nothing to commit is SILENT: a notice that cries wolf gets ignored', () => {
|
|
109
|
+
// The ordinary case, on most sessions, for all three hooks. If it printed, the next REAL failure
|
|
110
|
+
// would scroll past with the noise.
|
|
111
|
+
const root = project({});
|
|
112
|
+
try {
|
|
113
|
+
const h = HOOKS[0];
|
|
114
|
+
const t = h.write(root);
|
|
115
|
+
fs.writeFileSync(t.file, t.body);
|
|
116
|
+
execFileSync('git', ['add', '-A'], { cwd: root, stdio: 'ignore' });
|
|
117
|
+
execFileSync('git', ['commit', '-qm', 'base'], { cwd: root, stdio: 'ignore' });
|
|
118
|
+
const r = runHook(root, h.name);
|
|
119
|
+
assert.equal(r.code, 0);
|
|
120
|
+
assert.equal(r.stdout.trim(), '',
|
|
121
|
+
'the ordinary no-op path must stay silent: ' + r.stdout);
|
|
122
|
+
} finally { cleanup(root); }
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('P3 — not a git repository at all: silent, exit 0', () => {
|
|
126
|
+
const root = project({ git: false });
|
|
127
|
+
try {
|
|
128
|
+
const h = HOOKS[0];
|
|
129
|
+
const t = h.write(root);
|
|
130
|
+
fs.writeFileSync(t.file, t.body);
|
|
131
|
+
const r = runHook(root, h.name);
|
|
132
|
+
assert.equal(r.code, 0);
|
|
133
|
+
assert.equal(r.stdout.trim(), '', 'not-a-repo is not a failure to report: ' + r.stdout);
|
|
134
|
+
} finally { cleanup(root); }
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('P4 — the target is absent: silent, exit 0', () => {
|
|
138
|
+
const root = project({});
|
|
139
|
+
try {
|
|
140
|
+
const r = runHook(root, HOOKS[0].name);
|
|
141
|
+
assert.equal(r.code, 0);
|
|
142
|
+
assert.equal(r.stdout.trim(), '',
|
|
143
|
+
'an absent artifact is nothing to commit, not a failure: ' + r.stdout);
|
|
144
|
+
} finally { cleanup(root); }
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('P7 — a repository probe failure other than a plain non-repo is reported', () => {
|
|
148
|
+
// Every rev-parse failure used to be read as "not a git repository", so a machine with no git on
|
|
149
|
+
// PATH, a dubious-ownership refusal, or a permission error looked exactly like an ordinary
|
|
150
|
+
// non-repo directory. Two very different facts, one silence.
|
|
151
|
+
const root = project({ git: false });
|
|
152
|
+
try {
|
|
153
|
+
const h = HOOKS[0];
|
|
154
|
+
const t = h.write(root);
|
|
155
|
+
fs.writeFileSync(t.file, t.body);
|
|
156
|
+
// A directory owned by nobody this process can vouch for is hard to fake portably; the
|
|
157
|
+
// distinction itself is asserted on the source, and the ordinary case is proven live by P3.
|
|
158
|
+
const src = fs.readFileSync(path.join(TPL, 'hooks', h.name + '.cjs'), 'utf-8');
|
|
159
|
+
assert.match(src, /not a git repository/i,
|
|
160
|
+
'the ordinary case must be recognised by NAME, not by "any failure here is ordinary"');
|
|
161
|
+
assert.match(src, /throw probeErr/,
|
|
162
|
+
'and everything else must be re-thrown into the reporting path');
|
|
163
|
+
assert.equal(runHook(root, h.name).code, 0, 'and the ordinary case still exits 0');
|
|
164
|
+
} finally { cleanup(root); }
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('P5 — all three hooks behave identically', () => {
|
|
168
|
+
// They are the same shape; a difference between them would be a second thing to remember.
|
|
169
|
+
for (const h of HOOKS) {
|
|
170
|
+
const root = project({ identity: false });
|
|
171
|
+
try {
|
|
172
|
+
const t = h.write(root);
|
|
173
|
+
fs.writeFileSync(t.file, t.body);
|
|
174
|
+
const r = runHook(root, h.name);
|
|
175
|
+
assert.equal(r.code, 0, h.name + ' must exit 0');
|
|
176
|
+
assert.ok(r.stdout.includes('[' + h.name + ']'),
|
|
177
|
+
h.name + ' must report the failure too: ' + r.stdout);
|
|
178
|
+
} finally { cleanup(root); }
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('P6 — no hook still carries the bare swallow', () => {
|
|
183
|
+
for (const h of HOOKS) {
|
|
184
|
+
const src = fs.readFileSync(path.join(TPL, 'hooks', h.name + '.cjs'), 'utf-8');
|
|
185
|
+
assert.ok(!/catch \(_err\) \{\s*(\/\/[^\n]*\n\s*)*process\.exit\(0\);\s*\}/.test(src),
|
|
186
|
+
h.name + ' still swallows every failure without a word');
|
|
187
|
+
assert.match(src, /process\.stdout\.write/,
|
|
188
|
+
h.name + ' must report on stdout — stderr on exit 0 is discarded, so it is not a channel');
|
|
189
|
+
assert.ok(!/process\.stderr\.write/.test(src),
|
|
190
|
+
h.name + ' must not write its notice to the stream nobody reads');
|
|
191
|
+
assert.match(src, /let stage = 'start'/,
|
|
192
|
+
h.name + ' must track WHICH operation failed, or "could not commit" is claimed for a '
|
|
193
|
+
+ 'staging failure and a missing git binary too');
|
|
194
|
+
// The reason is only available because git's stderr is piped. Ignoring it leaves a message
|
|
195
|
+
// that names what failed and not why, which is half a report.
|
|
196
|
+
// The PROPERTY is that git's stderr is captured, not the exact array. The first version pinned
|
|
197
|
+
// ['ignore','ignore','pipe'] literally and went red when stdout was later piped too — for a
|
|
198
|
+
// different feature that needed to READ a git answer. Pinning a shape instead of a property
|
|
199
|
+
// makes a compatible change look like a regression.
|
|
200
|
+
const stdio = src.match(/const SILENT = \{ stdio: (\[[^\]]*\])/);
|
|
201
|
+
assert.ok(stdio, h.name + ' must configure git stdio explicitly');
|
|
202
|
+
const streams = JSON.parse(stdio[1].replace(/'/g, '"'));
|
|
203
|
+
assert.equal(streams[2], 'pipe',
|
|
204
|
+
h.name + " must capture git's own words on stderr rather than discard them: " + stdio[1]);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 05-growth-engine.md has FOUR live copies: the repo canonical .claude/ tree and templates/ of three
|
|
4
|
+
// PUBLISHED packages. Nothing tested that they agree.
|
|
5
|
+
//
|
|
6
|
+
// MEASURED before this test existed: tests/unit/sync-templates-guard.test.js guards the sync
|
|
7
|
+
// SCRIPT's choice of source root — a real defect, a different one. Cross-package copy identity had
|
|
8
|
+
// no guard at all, so a fix applied to one copy and forgotten in another would ship to npm in three
|
|
9
|
+
// packages that disagree, and nothing would go red.
|
|
10
|
+
//
|
|
11
|
+
// This feature edits all four in one change, which is exactly the moment to close it: the risk it
|
|
12
|
+
// guards is the risk this feature adds.
|
|
13
|
+
|
|
14
|
+
const { test, describe } = require('node:test');
|
|
15
|
+
const assert = require('node:assert/strict');
|
|
16
|
+
const crypto = require('node:crypto');
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
21
|
+
const REPO = path.resolve(PKG, '..', '..', '..');
|
|
22
|
+
const REL = path.join('.claude', 'skills', 'reverse-engineering-unicorn', 'modules');
|
|
23
|
+
const TPL = path.join('templates', '.claude', 'skills', 'reverse-engineering-unicorn', 'modules');
|
|
24
|
+
|
|
25
|
+
/** The four copies that ship. Vendored sub-projects and .stryker-tmp sandboxes are deliberately
|
|
26
|
+
* excluded: they are separate checked-in projects, not publish targets of this monorepo. */
|
|
27
|
+
const COPIES = [
|
|
28
|
+
['canonical', path.join(REPO, REL)],
|
|
29
|
+
['skills-reverse-engineering', path.join(REPO, 'packages', '@dzhechkov', 'skills-reverse-engineering', TPL)],
|
|
30
|
+
['p-replicator', path.join(REPO, 'packages', '@dzhechkov', 'p-replicator', TPL)],
|
|
31
|
+
['keysarium', path.join(REPO, 'packages', '@dzhechkov', 'keysarium', TPL)],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const sha = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
|
|
35
|
+
|
|
36
|
+
describe('the four live copies of the growth module agree', () => {
|
|
37
|
+
test('P1 - all four live copies are byte-identical', () => {
|
|
38
|
+
const seen = COPIES.map(([name, dir]) => {
|
|
39
|
+
const f = path.join(dir, '05-growth-engine.md');
|
|
40
|
+
assert.ok(fs.existsSync(f), 'copy is missing entirely: ' + name + ' → ' + f);
|
|
41
|
+
return { name, hash: sha(f) };
|
|
42
|
+
});
|
|
43
|
+
const distinct = [...new Set(seen.map((s) => s.hash))];
|
|
44
|
+
assert.equal(distinct.length, 1,
|
|
45
|
+
'copies diverged: ' + JSON.stringify(seen.map((s) => s.name + '=' + s.hash.slice(0, 8))));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('P2 - a drifted copy is NAMED, not just counted', () => {
|
|
49
|
+
// A test that says "they differ" sends a reader to diff four files by hand. The failure message
|
|
50
|
+
// has to say WHICH. Proven by constructing the failure rather than by trusting the message above.
|
|
51
|
+
const fake = [
|
|
52
|
+
{ name: 'canonical', hash: 'aaaa' },
|
|
53
|
+
{ name: 'keysarium', hash: 'bbbb' },
|
|
54
|
+
];
|
|
55
|
+
const msg = 'copies diverged: ' + JSON.stringify(fake.map((s) => s.name + '=' + s.hash.slice(0, 8)));
|
|
56
|
+
assert.match(msg, /keysarium/, 'the message must name the drifted copy');
|
|
57
|
+
assert.match(msg, /canonical/, 'and what it drifted from');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('P3 - the whole module directory agrees, not only the file this feature touched', () => {
|
|
61
|
+
// Scoping the guard to one filename would let the NEXT edit, to a sibling module, drift silently
|
|
62
|
+
// — the same class of miss this test exists to close, one file over.
|
|
63
|
+
const [, canonDir] = COPIES[0];
|
|
64
|
+
const names = fs.readdirSync(canonDir).filter((n) => n.endsWith('.md')).sort();
|
|
65
|
+
assert.ok(names.length >= 6, 'the module directory should hold the M0-M6 modules: ' + names.length);
|
|
66
|
+
for (const name of names) {
|
|
67
|
+
const hashes = COPIES.map(([label, dir]) => {
|
|
68
|
+
const f = path.join(dir, name);
|
|
69
|
+
assert.ok(fs.existsSync(f), name + ' missing from ' + label);
|
|
70
|
+
return label + '=' + sha(f).slice(0, 8);
|
|
71
|
+
});
|
|
72
|
+
const distinct = [...new Set(hashes.map((h) => h.split('=')[1]))];
|
|
73
|
+
assert.equal(distinct.length, 1, name + ' diverged: ' + JSON.stringify(hashes));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// /replicate and /start both write docker-compose.yml and .gitignore — on the RECOMMENDED path.
|
|
4
|
+
//
|
|
5
|
+
// `replicate.md` Phase 4 FINALIZE generates docker-compose.yml (from Architecture.md services),
|
|
6
|
+
// Dockerfile and .gitignore; `start.md` Phase 1 generated package.json, docker-compose.yml,
|
|
7
|
+
// .env.example, .gitignore and tsconfig.base.json with no existence guard at all. And
|
|
8
|
+
// `replicate.md` itself says "Run /start to bootstrap the project" — so the collision was the normal
|
|
9
|
+
// sequence, not an edge case.
|
|
10
|
+
//
|
|
11
|
+
// The field report proposed splitting ownership "by origin": architecture-derived to /replicate,
|
|
12
|
+
// build-derived to /start. That cannot work here, because BOTH derive compose from the SAME source —
|
|
13
|
+
// replicate.md from "Architecture.md services", start.md from "docs/Architecture.md → monorepo
|
|
14
|
+
// structure, Docker Compose, tech stack". One artifact, derived twice; an origin split has nothing
|
|
15
|
+
// to split on. So the guard is existence-plus-stated-change, which closes the silent loss without
|
|
16
|
+
// deciding who owns the file — that ownership question is deliberately left to the owner.
|
|
17
|
+
//
|
|
18
|
+
// These are PROMPT files a model executes, so the deterministic layer available is their content.
|
|
19
|
+
// Every assertion below is DISCRIMINATING: remove what it names and it goes red.
|
|
20
|
+
|
|
21
|
+
const { test, describe } = require('node:test');
|
|
22
|
+
const assert = require('node:assert/strict');
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const CMDS = path.join(__dirname, '..', '..', 'templates', '.claude', 'commands');
|
|
27
|
+
const read = (f) => fs.readFileSync(path.join(CMDS, f), 'utf-8');
|
|
28
|
+
|
|
29
|
+
/** Phase 1 of start.md — placement matters: a guard in a later phase guards nothing. */
|
|
30
|
+
function startPhase1(src) {
|
|
31
|
+
const start = src.indexOf('### Phase 1: Foundation');
|
|
32
|
+
const end = src.indexOf('### Phase 2');
|
|
33
|
+
assert.ok(start > 0, 'start.md must have a Phase 1');
|
|
34
|
+
assert.ok(end > start, 'start.md must have a Phase 2 after it');
|
|
35
|
+
return src.slice(start, end);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('/start must not silently discard what /replicate wrote (PR-008)', () => {
|
|
39
|
+
test('P1 — Phase 1 names BOTH overlapping files under the guard', () => {
|
|
40
|
+
const block = startPhase1(read('start.md'));
|
|
41
|
+
assert.match(block, /docker-compose\.yml/, 'the compose file must be named');
|
|
42
|
+
assert.match(block, /\.gitignore/, 'the gitignore must be named');
|
|
43
|
+
// Codex found a THIRD overlap I had missed: /replicate Phase 3 enhances README.md and /start
|
|
44
|
+
// Phase 4 generates-or-updates it, with no preservation rule on either side.
|
|
45
|
+
assert.match(block, /README\.md/, 'README.md overlaps too and must be under the same rule');
|
|
46
|
+
assert.match(block, /if not exists/,
|
|
47
|
+
'the guard phrase must be present — and it is /replicate\'s own phrase, so the two commands '
|
|
48
|
+
+ 'read as one rule rather than two dialects');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('P2 — the PRESENT case has its own stated action, not just an existence check', () => {
|
|
52
|
+
// Checking existence and then overwriting anyway would satisfy a weaker assertion. The property
|
|
53
|
+
// is what happens when the file IS there.
|
|
54
|
+
const block = startPhase1(read('start.md'));
|
|
55
|
+
assert.match(block, /файла НЕТ/, 'the absent case must be stated (a fresh tree still works)');
|
|
56
|
+
assert.match(block, /файл ЕСТЬ/, 'the present case must be stated');
|
|
57
|
+
assert.match(block, /не перегенерировать/,
|
|
58
|
+
'the present case must forbid regeneration outright — "take it into account" permits it');
|
|
59
|
+
// Cross-family QE (Codex gpt-5.6-sol): "keep it, change only for a documented reason" still let
|
|
60
|
+
// a run rewrite the whole file while calling it "added the missing port". The rule now demands a
|
|
61
|
+
// MINIMAL targeted edit, preservation of everything unrelated, and a look at the diff — the diff
|
|
62
|
+
// is what distinguishes the two, and a promise is not.
|
|
63
|
+
assert.match(block, /МИНИМАЛЬНУЮ/, 'the permitted edit must be bounded, not merely justified');
|
|
64
|
+
assert.match(block, /ПОСМОТРЕТЬ ДИФ/,
|
|
65
|
+
'inspecting the diff is what turns "I only added a port" from a claim into a check');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('P3 — a permitted change must be NAMED, because an unannounced diff is the same loss', () => {
|
|
69
|
+
const block = startPhase1(read('start.md'));
|
|
70
|
+
assert.match(block, /НАЗВАТЬ каждый изменённый фрагмент/,
|
|
71
|
+
'a legitimate edit is allowed, but every changed hunk has to be visible to the reader');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('P4 — replicate.md still guards .gitignore with the same phrase, and points at /start', () => {
|
|
75
|
+
const src = read('replicate.md');
|
|
76
|
+
assert.match(src, /`\.gitignore` — if not exists/,
|
|
77
|
+
'the pre-existing guard must survive — this feature adds one, it does not trade one for another');
|
|
78
|
+
// Codex: the collision is SYMMETRIC. /replicate re-run over a tree that already has a compose
|
|
79
|
+
// would discard it just as /start did; guarding one side only fixes half the defect.
|
|
80
|
+
assert.match(src, /`docker-compose\.yml` — from Architecture\.md services, \*\*if not exists\*\*/,
|
|
81
|
+
'replicate must guard its own compose too — the guard is symmetric or it is partial');
|
|
82
|
+
assert.match(src, /Phase 1/,
|
|
83
|
+
'replicate.md must point at the phase that reads these files, so the agreement is written down');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('P5 — the guard is imperative and file-naming, not advisory', () => {
|
|
87
|
+
const block = startPhase1(read('start.md'));
|
|
88
|
+
// "be careful not to overwrite" would pass a reviewer and instruct nothing. The rule must name
|
|
89
|
+
// its files and its actions.
|
|
90
|
+
assert.ok(!/будьте осторожн|постарайтесь не|по возможности/i.test(block),
|
|
91
|
+
'advisory wording is not a rule');
|
|
92
|
+
assert.match(block, /Правило|правило/,
|
|
93
|
+
'the block must present itself as a rule');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// One file, two canonical schemas. MEASURED: commands/next.md:84-96 documents feature-roadmap.json
|
|
4
|
+
// with a `priority` field over the closed set mvp|high|medium|low, while
|
|
5
|
+
// skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md names the SAME
|
|
6
|
+
// path ("Generate as .claude/feature-roadmap.json") and documents a shape with NO priority at all —
|
|
7
|
+
// and calls itself the single source of truth while being one of two. A generator following the
|
|
8
|
+
// second produces a roadmap the package's own status line cannot read.
|
|
9
|
+
//
|
|
10
|
+
// The consumer was silent about it: statusline.cjs filters `f.priority === 'mvp'`, which is correct
|
|
11
|
+
// against the schema, so a roadmap using `critical` — a value in NO documented enum — or carrying MVP
|
|
12
|
+
// in `tags` rendered "mvp 0/0" and said nothing. The number was right and the reader learned nothing,
|
|
13
|
+
// which is how a divergence becomes permanent.
|
|
14
|
+
//
|
|
15
|
+
// NOT DONE, deliberately: `tags.includes('mvp')` as a "fix". It would bless a document that also used
|
|
16
|
+
// a value in no enum, and teach readers to accept whatever a generator emits. The counter matches the
|
|
17
|
+
// specification; the documents diverged.
|
|
18
|
+
//
|
|
19
|
+
// Also REFUTED while measuring: the report's second defect, a supposed contradiction about who
|
|
20
|
+
// creates the file. feature.md:44 sits under "### What does NOT happen in Mode 2" — it is scoped to
|
|
21
|
+
// the mode where /replicate never runs. Both statements are true in their own mode; what was missing
|
|
22
|
+
// is one place saying both, which P3 now requires.
|
|
23
|
+
|
|
24
|
+
const { test, describe } = require('node:test');
|
|
25
|
+
const assert = require('node:assert/strict');
|
|
26
|
+
const { execFileSync } = require('node:child_process');
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const os = require('node:os');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
|
|
31
|
+
const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
32
|
+
const read = (rel) => fs.readFileSync(path.join(TPL, rel), 'utf-8');
|
|
33
|
+
|
|
34
|
+
const NEXT = 'commands/next.md';
|
|
35
|
+
const SUGGESTIONS = 'skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md';
|
|
36
|
+
|
|
37
|
+
const STRIP = new RegExp('\\x1b\\[[0-9;]*m', 'g');
|
|
38
|
+
|
|
39
|
+
/** Run the real status line over a real roadmap and return its plain-text Roadmap line. */
|
|
40
|
+
function roadmapLine(features) {
|
|
41
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-schema-')));
|
|
42
|
+
try {
|
|
43
|
+
fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true });
|
|
44
|
+
fs.copyFileSync(path.join(TPL, 'hooks', 'statusline.cjs'),
|
|
45
|
+
path.join(root, '.claude', 'hooks', 'statusline.cjs'));
|
|
46
|
+
fs.writeFileSync(path.join(root, '.claude', 'feature-roadmap.json'),
|
|
47
|
+
JSON.stringify({ version: '1.0', features }));
|
|
48
|
+
let code = 0;
|
|
49
|
+
let out = '';
|
|
50
|
+
try {
|
|
51
|
+
out = execFileSync(process.execPath, [path.join(root, '.claude', 'hooks', 'statusline.cjs')],
|
|
52
|
+
{ cwd: root, env: Object.assign({}, process.env, { CLAUDE_PROJECT_DIR: root }),
|
|
53
|
+
encoding: 'utf8', stdio: 'pipe' }) || '';
|
|
54
|
+
} catch (err) {
|
|
55
|
+
code = err.status == null ? 1 : err.status;
|
|
56
|
+
out = err.stdout ? err.stdout.toString() : '';
|
|
57
|
+
}
|
|
58
|
+
const text = out.replace(STRIP, '');
|
|
59
|
+
return { code, line: text.split('\n').find((l) => l.includes('Roadmap')) || '' };
|
|
60
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('feature-roadmap.json has ONE schema, and the reader says when a file misses it', () => {
|
|
64
|
+
test('P1 — next.md declares itself canonical and closes the priority set', () => {
|
|
65
|
+
const src = read(NEXT);
|
|
66
|
+
assert.match(src, /\*\*This table is THE schema for `\.claude\/feature-roadmap\.json`\.\*\*/,
|
|
67
|
+
'the schema must claim the role explicitly, or a second document can claim it too');
|
|
68
|
+
assert.match(src, /any other\s+document that shows the file's fields must point here/,
|
|
69
|
+
'and say what other documents must do instead of restating it');
|
|
70
|
+
assert.match(src, /`priority` is a CLOSED set — `mvp`, `high`, `medium`, `low`/,
|
|
71
|
+
'the enum must be stated as closed, in the canonical place');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('P2 — feature-suggestions.md points at the schema instead of restating one', () => {
|
|
75
|
+
const src = read(SUGGESTIONS);
|
|
76
|
+
assert.match(src, /follow `\.claude\/commands\/next\.md`, which holds the single schema/,
|
|
77
|
+
'the second document must defer, not describe');
|
|
78
|
+
// Cross-family QE: the first version was `!A || B`, which the new "single data file" phrase
|
|
79
|
+
// makes true whether or not the stale claim survives. The claim's ABSENCE is the property —
|
|
80
|
+
// but SCOPED: a later CLAUDE.md snippet in this same file calls the roadmap the source of truth
|
|
81
|
+
// for feature STATUS, which is both true and unrelated. Asserting over the whole file would have
|
|
82
|
+
// forbidden a correct sentence. The claim that mattered was the architecture line at the top.
|
|
83
|
+
const header = src.slice(0, src.indexOf('## 2.'));
|
|
84
|
+
assert.ok(!/single source of truth/.test(header),
|
|
85
|
+
'the schema-level claim must be gone from the architecture line, not merely accompanied');
|
|
86
|
+
assert.match(header, /single data file/, 'replaced by what it actually is');
|
|
87
|
+
// Its own JSON sketch must not teach the incompatible shape either: an example is what a
|
|
88
|
+
// generator copies, and an example without `priority` is a second schema in disguise.
|
|
89
|
+
const sketch = src.slice(src.indexOf('"features": ['), src.indexOf('```', src.indexOf('"features": [')));
|
|
90
|
+
assert.match(sketch, /"priority": "mvp\|high\|medium\|low"/,
|
|
91
|
+
'the example must carry the canonical priority field: ' + sketch.slice(0, 200));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('P3 — one place answers who creates the file, for BOTH modes', () => {
|
|
95
|
+
// The report called this a contradiction. It is not: feature.md's claim is scoped to Mode 2.
|
|
96
|
+
// What was missing is a single answer, so a reader landing on one half does not conclude the
|
|
97
|
+
// other is wrong.
|
|
98
|
+
const src = read(NEXT);
|
|
99
|
+
assert.match(src, /Who creates the file/, 'the question must be answered where the schema lives');
|
|
100
|
+
assert.match(src, /Phase 3 generates it from the PRD MVP scope/, 'the /replicate half');
|
|
101
|
+
assert.match(src, /the user\s+writes it by hand/, 'and the Mode 2 half');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('P4 — a priority outside the enum is MARKED, not silently zero', () => {
|
|
105
|
+
const r = roadmapLine([
|
|
106
|
+
{ id: 'a', status: 'next', priority: 'critical' },
|
|
107
|
+
{ id: 'b', status: 'done', priority: 'critical' },
|
|
108
|
+
]);
|
|
109
|
+
assert.equal(r.code, 0, 'the status line must never fail the session');
|
|
110
|
+
assert.match(r.line, /⚠2 schema/,
|
|
111
|
+
'an unreadable roadmap must say so, and say HOW MUCH: ' + r.line);
|
|
112
|
+
// Cross-family QE: the first version replaced the count with '?', discarding what WAS
|
|
113
|
+
// established. The known count is still knowledge; show both.
|
|
114
|
+
assert.match(r.line, /mvp 0\/0 ⚠2/,
|
|
115
|
+
'the known count must survive beside the warning: ' + r.line);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('P5 — MVP moved into tags, no priority at all: also MARKED', () => {
|
|
119
|
+
// The exact shape the field report was collected on.
|
|
120
|
+
const r = roadmapLine([
|
|
121
|
+
{ id: 'a', status: 'next', tags: ['mvp'] },
|
|
122
|
+
{ id: 'b', status: 'next', tags: ['mvp'] },
|
|
123
|
+
]);
|
|
124
|
+
assert.equal(r.code, 0, 'the status line must never fail the session');
|
|
125
|
+
assert.match(r.line, /mvp 0\/0 ⚠2 schema/,
|
|
126
|
+
'a roadmap with no priority field at all must be marked, with its count: ' + r.line);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('P8 — PARTIAL population is caught: one valid priority beside a broken sibling', () => {
|
|
130
|
+
// Cross-family QE named this false negative exactly: the first detector only inspected features
|
|
131
|
+
// that already HAD a string priority, so a valid entry standing next to a missing or non-string
|
|
132
|
+
// one passed silently — and a half-migrated roadmap is the most likely real one.
|
|
133
|
+
const r = roadmapLine([
|
|
134
|
+
{ id: 'a', status: 'done', priority: 'mvp' },
|
|
135
|
+
{ id: 'b', status: 'next' }, // missing entirely
|
|
136
|
+
{ id: 'c', status: 'next', priority: 3 }, // present, not a string
|
|
137
|
+
]);
|
|
138
|
+
assert.equal(r.code, 0);
|
|
139
|
+
assert.match(r.line, /⚠2 schema/,
|
|
140
|
+
'both broken siblings must be counted, not hidden by the valid one: ' + r.line);
|
|
141
|
+
assert.match(r.line, /mvp 1\/1/,
|
|
142
|
+
'and what IS known must still be reported: ' + r.line);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('P6 — a valid roadmap renders exactly as before, with no marker', () => {
|
|
146
|
+
// The guard on over-marking: a rule that fires on good input is worse than no rule.
|
|
147
|
+
const r = roadmapLine([
|
|
148
|
+
{ id: 'a', status: 'done', priority: 'mvp' },
|
|
149
|
+
{ id: 'b', status: 'next', priority: 'mvp' },
|
|
150
|
+
{ id: 'c', status: 'next', priority: 'low' },
|
|
151
|
+
]);
|
|
152
|
+
assert.equal(r.code, 0);
|
|
153
|
+
assert.match(r.line, /mvp 1\/2/, 'the real counts must still render: ' + r.line);
|
|
154
|
+
assert.doesNotMatch(r.line, /schema/, 'and no marker on a conforming file: ' + r.line);
|
|
155
|
+
|
|
156
|
+
// An EMPTY roadmap has nothing to be off-schema about; marking it would be a false alarm in the
|
|
157
|
+
// most common state of a fresh project.
|
|
158
|
+
const empty = roadmapLine([]);
|
|
159
|
+
assert.equal(empty.code, 0);
|
|
160
|
+
assert.doesNotMatch(empty.line, /schema/, 'an empty roadmap must not be marked: ' + empty.line);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('P7 — every case exits 0: the marker informs, it never blocks', () => {
|
|
164
|
+
for (const features of [
|
|
165
|
+
[{ id: 'a', status: 'next', priority: 'critical' }],
|
|
166
|
+
[{ id: 'a', status: 'next', tags: ['mvp'] }],
|
|
167
|
+
[{ id: 'a', status: 'next', priority: 'mvp' }],
|
|
168
|
+
[],
|
|
169
|
+
]) {
|
|
170
|
+
const r = roadmapLine(features);
|
|
171
|
+
assert.equal(r.code, 0,
|
|
172
|
+
'the status line is advisory and must stay non-blocking: ' + JSON.stringify(features));
|
|
173
|
+
// Cross-family QE: exit 0 alone is satisfied by a program that prints nothing at all. The
|
|
174
|
+
// line must actually be rendered in every case.
|
|
175
|
+
assert.notEqual(r.line, '',
|
|
176
|
+
'and must still RENDER a roadmap line: ' + JSON.stringify(features));
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|