@dzhechkov/p-replicator 1.5.18 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +126 -46
- package/CHANGELOG.md +135 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +2 -2
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/05_architecture.md +6 -2
- package/README/eng/README.md +1 -1
- package/README/ru/01_quickstart.md +2 -2
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +1 -1
- package/README/ru/html/index.html +7 -7
- package/README.md +36 -15
- package/bin/cli.js +0 -0
- package/package.json +11 -10
- package/sbom.json +245 -45
- package/src/utils.js +2 -0
- package/templates/.claude/agents/doc-validator.md +2 -1
- package/templates/.claude/agents/product-discoverer.md +1 -1
- package/templates/.claude/commands/next.md +16 -0
- package/templates/.claude/commands/replicate.md +126 -8
- package/templates/.claude/commands/start.md +19 -1
- package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
- package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
- package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
- package/templates/.claude/hooks/check-ports.cjs +232 -0
- package/templates/.claude/hooks/session-insights.cjs +13 -1
- package/templates/.claude/hooks/state-update.cjs +13 -1
- package/templates/.claude/hooks/statusline.cjs +145 -18
- package/templates/.claude/rules/docker-ports.md +123 -0
- package/templates/.claude/rules/replicate-pipeline.md +5 -2
- package/templates/.claude/settings.json +5 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
- package/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
- package/tests/e2e/lifecycle.test.js +21 -10
- package/tests/snapshot/baseline.json +29 -27
- package/tests/snapshot/update-baseline.js +2 -1
- package/tests/unit/adr-decision-coverage.test.js +137 -0
- package/tests/unit/adr-scanner-contract.test.js +108 -0
- package/tests/unit/autocommit-deletion.test.js +242 -0
- package/tests/unit/check-ports.test.js +184 -0
- package/tests/unit/db-port-rule.test.js +216 -0
- package/tests/unit/detect-parse-anchor.test.js +109 -0
- package/tests/unit/external-dependency-check.test.js +209 -0
- package/tests/unit/growth-module-b2b-gate.test.js +104 -0
- package/tests/unit/hooks-project-anchored.test.js +223 -0
- package/tests/unit/hooks-report-failures.test.js +207 -0
- package/tests/unit/pipeline-file-ownership.test.js +95 -0
- package/tests/unit/roadmap-one-schema.test.js +179 -0
- package/tests/unit/sparc-reconciliation.test.js +117 -0
- package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
- package/tests/unit/statusline-honest-labels.test.js +178 -0
- package/tests/unit/statusline-two-roots.test.js +237 -0
- package/tests/unit/sync-templates-guard.test.js +209 -0
- package/tests/unit/utils.test.js +2 -2
- package/tests/unit/validation-gate-teeth.test.js +158 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The project-type scanner probed seven paths from one parameter, and the paths disagreed about what
|
|
4
|
+
// that parameter was. MEASURED before the fix:
|
|
5
|
+
//
|
|
6
|
+
// {docs_path}/docs/ddd/ · /.ai-context/ · /docs/tests/*.feature · /docs/adr/*.md · /docs/ADR.md
|
|
7
|
+
// → all five imply docs_path is the PROJECT ROOT
|
|
8
|
+
// {docs_path}/Architecture.md · {docs_path}/Solution_Strategy.md
|
|
9
|
+
// → these two imply docs_path is `docs/`
|
|
10
|
+
//
|
|
11
|
+
// And the pipeline writes those two files to docs/ (commands/replicate.md:234,237). So the SPARC
|
|
12
|
+
// probes looked exactly where the pipeline does not write: the scanner could not recognise its own
|
|
13
|
+
// output as SPARC-shaped.
|
|
14
|
+
//
|
|
15
|
+
// The parameter description was the root of it — one sentence naming two directories: "In Claude
|
|
16
|
+
// Code / replicate context this is `docs/` (project root)." A reader implementing a probe picked
|
|
17
|
+
// whichever half they read last. Nobody passes the parameter explicitly (a sweep of SKILL.md and
|
|
18
|
+
// commands/replicate.md for `docs_path` returns nothing), so that sentence IS the specification.
|
|
19
|
+
//
|
|
20
|
+
// Both the field report and the reviewing swarm got this wrong in opposite directions: the report
|
|
21
|
+
// said all six probes were wrong, the swarm said the table was wrong and the probes right. Five
|
|
22
|
+
// probes are right, two are wrong, and the table contradicts itself.
|
|
23
|
+
|
|
24
|
+
const { test, describe } = require('node:test');
|
|
25
|
+
const assert = require('node:assert/strict');
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const MODULE = path.join(__dirname, '..', '..', 'templates', '.claude', 'skills',
|
|
30
|
+
'cc-toolkit-generator-enhanced', 'modules', '01-detect-parse.md');
|
|
31
|
+
const read = () => fs.readFileSync(MODULE, 'utf-8');
|
|
32
|
+
|
|
33
|
+
/** Every path this module probes, as written. */
|
|
34
|
+
function probes() {
|
|
35
|
+
const src = read();
|
|
36
|
+
return [...src.matchAll(/\{docs_path\}\/([^"')\s]+)/g)].map((m) => m[1]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('every probe resolves from ONE anchor, and the anchor is named once', () => {
|
|
40
|
+
test('P1 — no probe implies an anchor other than the project root', () => {
|
|
41
|
+
// The property is AGREEMENT across all of them, not the correctness of any single path. A
|
|
42
|
+
// top-level SPARC artifact would mean docs_path is `docs/` for that probe and the root for the
|
|
43
|
+
// others — which is the state this feature removes.
|
|
44
|
+
// DERIVED, not typed. Cross-family QE: the first version hard-coded seven basenames, so a probe
|
|
45
|
+
// for Research_Findings.md — a real pipeline document I had not listed — would have escaped it.
|
|
46
|
+
// The authoritative list is the pipeline's own: replicate.md writes every SPARC document to docs/.
|
|
47
|
+
const replicate = fs.readFileSync(path.join(__dirname, '..', '..', 'templates', '.claude',
|
|
48
|
+
'commands', 'replicate.md'), 'utf-8');
|
|
49
|
+
const written = new Set([...replicate.matchAll(/^- `docs\/([A-Z][A-Za-z_]*\.md)`/gm)].map((m) => m[1]));
|
|
50
|
+
assert.ok(written.size >= 9,
|
|
51
|
+
'the pipeline document list must be readable from replicate.md: ' + [...written]);
|
|
52
|
+
|
|
53
|
+
const disagreeing = probes().filter((p) => written.has(p));
|
|
54
|
+
assert.deepEqual(disagreeing, [],
|
|
55
|
+
'these probe a document the pipeline writes to docs/, as if it were at the project root — '
|
|
56
|
+
+ 'which only makes sense if docs_path is `docs/`, while every other probe treats it as the '
|
|
57
|
+
+ 'root: ' + JSON.stringify(disagreeing));
|
|
58
|
+
|
|
59
|
+
// And the prose must not re-introduce the ambiguity the probes just lost. Five sites carried it.
|
|
60
|
+
const src = read();
|
|
61
|
+
assert.ok(!/top-level or docs\//.test(src),
|
|
62
|
+
'the scan catalog still preserves the "top-level or docs/" reading this feature removes');
|
|
63
|
+
for (const doc of ['Architecture.md', 'Solution_Strategy.md', 'PRD.md', 'Specification.md']) {
|
|
64
|
+
const bare = new RegExp('(?<![/\\w])`' + doc.replace('.', '\\.') + '`');
|
|
65
|
+
assert.ok(!bare.test(src),
|
|
66
|
+
'a pipeline document is named without its docs/ prefix, which reads as project-root: ' + doc);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('P2 — the two SPARC probes look under docs/, where the pipeline writes', () => {
|
|
71
|
+
const src = read();
|
|
72
|
+
assert.match(src, /has_sparc_arch = exists\(f"\{docs_path\}\/docs\/Architecture\.md"\)/,
|
|
73
|
+
'commands/replicate.md:237 writes docs/Architecture.md');
|
|
74
|
+
assert.match(src, /has_sparc_sol\s+= exists\(f"\{docs_path\}\/docs\/Solution_Strategy\.md"\)/,
|
|
75
|
+
'commands/replicate.md:234 writes docs/Solution_Strategy.md');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('P3 — the root-level probe stays at the root', () => {
|
|
79
|
+
// .ai-context/ sits beside .claude/, not under docs/. Moving it "for consistency" would break a
|
|
80
|
+
// probe that was correct all along — the failure mode the field report actually proposed.
|
|
81
|
+
assert.ok(probes().includes('.ai-context/'),
|
|
82
|
+
'the .ai-context probe must remain anchored at the project root: ' + JSON.stringify(probes()));
|
|
83
|
+
assert.ok(!probes().includes('docs/.ai-context/'),
|
|
84
|
+
'.ai-context/ is a root-level directory; probing it under docs/ finds nothing');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('P4 — the parameter names ONE directory', () => {
|
|
88
|
+
const src = read();
|
|
89
|
+
const row = src.split('\n').find((l) => l.startsWith('| `docs_path` |'));
|
|
90
|
+
assert.ok(row, 'the parameter table row must exist');
|
|
91
|
+
assert.ok(!/this is `docs\/` \(project root\)/.test(row),
|
|
92
|
+
'the row still names two different directories in one breath — that sentence is what split '
|
|
93
|
+
+ 'the probes in the first place: ' + row);
|
|
94
|
+
assert.match(row, /the PROJECT ROOT/,
|
|
95
|
+
'it must name the anchor unambiguously');
|
|
96
|
+
assert.match(row, /not `docs\/`/,
|
|
97
|
+
'and rule out the other reading explicitly, since that reading is what produced the split');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('P5 — both ADR shapes are still collected', () => {
|
|
101
|
+
// This logic shipped in an earlier feature (one decision per file, and /replicate's single
|
|
102
|
+
// docs/ADR.md). Re-anchoring must not disturb it.
|
|
103
|
+
const src = read();
|
|
104
|
+
assert.match(src, /glob\(f"\{docs_path\}\/docs\/adr\/\*\.md"\)/, 'shape A: a directory of files');
|
|
105
|
+
assert.match(src, /f"\{docs_path\}\/docs\/ADR\.md"/, 'shape B: /replicate\'s single file');
|
|
106
|
+
assert.match(src, /has_adr = len\(collect_adrs\(docs_path\)\) > 0/,
|
|
107
|
+
'and the one definition that unifies them');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Every validation lens compared documents to documents. MEASURED — replicate.md's Phase-2 table
|
|
4
|
+
// listed five: stories→PRD, AC→stories, Architecture.md, Pseudocode.md, cross-document coherence.
|
|
5
|
+
// Nothing anywhere asked whether the outside world agrees, so a requirement could rest on a
|
|
6
|
+
// capability the provider does not have and reach code generation unchallenged. It is the only
|
|
7
|
+
// contact-with-reality defect in this pipeline; everything else it checks, it can check by reading
|
|
8
|
+
// its own output.
|
|
9
|
+
//
|
|
10
|
+
// TWO honesty constraints bind this suite, both from the field report:
|
|
11
|
+
// 1. check-pipeline-gaps.sh, which the report cites, DOES NOT EXIST. P7 keeps it uncited.
|
|
12
|
+
// 2. No external fact may be baked in. What an API can do DRIFTS: a fixture asserting one would
|
|
13
|
+
// fail when the world changes and pass when our own rule breaks. P6 asserts the ABSENCE of
|
|
14
|
+
// vendor names, and no assertion here mentions a capability of any real service.
|
|
15
|
+
|
|
16
|
+
const { test, describe } = require('node:test');
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
22
|
+
const read = (rel) => fs.readFileSync(path.join(TPL, rel), 'utf-8');
|
|
23
|
+
|
|
24
|
+
const SPARC = 'skills/sparc-prd-mini/SKILL.md';
|
|
25
|
+
const REPLICATE = 'commands/replicate.md';
|
|
26
|
+
|
|
27
|
+
/** The External Dependencies section of the Phase-5 Architecture.md template. Scoped, because an
|
|
28
|
+
* assertion over a whole file passes on any prose that happens to mention the words. */
|
|
29
|
+
function inventory() {
|
|
30
|
+
const src = read(SPARC);
|
|
31
|
+
const start = src.indexOf('## External Dependencies\n');
|
|
32
|
+
assert.ok(start > 0, 'the Phase-5 template must carry an External Dependencies section');
|
|
33
|
+
const end = src.indexOf('## Data Architecture', start);
|
|
34
|
+
assert.ok(end > start, 'and it must sit before Data Architecture, where the stack is still in view');
|
|
35
|
+
return src.slice(start, end);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Phase 2 of replicate.md — a lens declared in a later phase validates nothing. */
|
|
39
|
+
function phase2() {
|
|
40
|
+
const src = read(REPLICATE);
|
|
41
|
+
const start = src.indexOf('### Phase 2: VALIDATION');
|
|
42
|
+
const end = src.indexOf('### Phase 3', start);
|
|
43
|
+
assert.ok(start > 0 && end > start, 'replicate.md must have a Phase 2 before Phase 3');
|
|
44
|
+
return src.slice(start, end);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('the pipeline asks whether the outside world can do what a requirement needs (PR-010)', () => {
|
|
48
|
+
test('P1 — the inventory exists and carries all five columns', () => {
|
|
49
|
+
const block = inventory();
|
|
50
|
+
// The HEADER ROW, not five words scattered through prose. Cross-family QE: a paragraph
|
|
51
|
+
// containing all five labels would have replaced the table without failing.
|
|
52
|
+
const header = block.split('\n').find((l) => l.startsWith('| Capability needed |')) || '';
|
|
53
|
+
assert.notEqual(header, '', 'the inventory must have a table header row starting with the '
|
|
54
|
+
+ 'capability column');
|
|
55
|
+
for (const col of ['Capability needed', 'Provider / API', 'Evidence', 'Verdict',
|
|
56
|
+
'Requirements relying on it']) {
|
|
57
|
+
assert.ok(header.includes(col), 'the header row must carry the column: ' + col);
|
|
58
|
+
}
|
|
59
|
+
// One row per CAPABILITY, not per vendor: a provider can do one of two things you need.
|
|
60
|
+
assert.match(block, /One row per capability, not one row\s+per vendor/,
|
|
61
|
+
'the granularity must be stated, or one row hides two questions');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('P2 — the evidence rule is CLOSED on both sides', () => {
|
|
65
|
+
// Saying what evidence IS leaves every reader to decide what it is not. The three ways this
|
|
66
|
+
// check gets faked are named, so a row using one of them is visibly not evidence.
|
|
67
|
+
const block = inventory();
|
|
68
|
+
assert.match(block, /link to the PROVIDER'S OWN documentation/,
|
|
69
|
+
'what counts must be stated precisely — not "a source"');
|
|
70
|
+
assert.match(block, /plus the date it was checked/,
|
|
71
|
+
'a citation without a date cannot be re-checked later');
|
|
72
|
+
// Cross-family QE, the load-bearing finding: CONFIRMED was self-attested, and a plausible URL is
|
|
73
|
+
// the cheapest forgery there is. A verbatim quote is what a link alone cannot fake.
|
|
74
|
+
assert.match(block, /verbatim QUOTE from\s+that page stating the capability/,
|
|
75
|
+
'a non-fabricable artifact must be required, not just a link');
|
|
76
|
+
assert.match(block, /a URL nobody opened/,
|
|
77
|
+
'and the link-alone forgery must be named as not-evidence');
|
|
78
|
+
assert.match(block, /do NOT count/, 'and the rule must be closed on the other side too');
|
|
79
|
+
for (const fake of ['landing page', 'pricing page', 'recollection']) {
|
|
80
|
+
assert.ok(block.includes(fake), 'the not-evidence list must name: ' + fake);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('P3 — exactly three verdicts, and the empty case is explicit', () => {
|
|
85
|
+
const block = inventory();
|
|
86
|
+
// Each verdict must be a ROW of the verdict table, not a word somewhere in the prose. Found by
|
|
87
|
+
// mutation: renaming the UNCONFIRMED row to UNKNOWN left the vocabulary broken and the first
|
|
88
|
+
// version of this assertion green, because the word still appeared in the paragraph below it.
|
|
89
|
+
for (const v of ['CONFIRMED', 'UNCONFIRMED', 'CONTRADICTED']) {
|
|
90
|
+
assert.match(block, new RegExp('^\\| ' + v + ' \\|', 'm'),
|
|
91
|
+
'the verdict must be a row of the verdict table, not merely mentioned: ' + v);
|
|
92
|
+
}
|
|
93
|
+
// Two values would collapse "we checked and it cannot" into "nobody checked", which overstates
|
|
94
|
+
// one and hides the other. The reason has to be written down or the third value looks redundant.
|
|
95
|
+
assert.match(block, /exactly three, because two would hide a difference that\s+matters/,
|
|
96
|
+
'the reason for three values must be recorded, or a later edit will merge two of them');
|
|
97
|
+
// EXACTLY three: the first version asserted the three exist and let a fourth in silently.
|
|
98
|
+
const verdictRows = block.split('\n')
|
|
99
|
+
.filter((l) => /^\| [A-Z_]+ \|/.test(l) && !l.startsWith('| Verdict |'));
|
|
100
|
+
assert.equal(verdictRows.length, 3,
|
|
101
|
+
'the verdict table must have exactly three rows, not merely contain the three: '
|
|
102
|
+
+ JSON.stringify(verdictRows));
|
|
103
|
+
assert.match(block, /No external dependencies —\s+this product calls no third-party service/,
|
|
104
|
+
'a project with none must say so: an empty section and an absent one look identical');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('P4 — Phase 2 declares the sixth lens, inside Phase 2', () => {
|
|
108
|
+
const block = phase2();
|
|
109
|
+
const lensRow = block.split('\n').find((l) => l.startsWith('| `validator-dependencies` |')) || '';
|
|
110
|
+
assert.notEqual(lensRow, '',
|
|
111
|
+
'the lens must be a ROW of the validator table, not a mention in prose');
|
|
112
|
+
assert.match(lensRow, /External Dependencies/,
|
|
113
|
+
'and its scope column must name the section it reads: ' + lensRow);
|
|
114
|
+
assert.match(lensRow, /evidence that names that capability/,
|
|
115
|
+
'and its criteria column must say what it checks for: ' + lensRow);
|
|
116
|
+
assert.match(block, /the only one that looks OUTSIDE the\s+documents/,
|
|
117
|
+
'and say what makes it different from the other five, or it reads as a sixth of the same kind');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('P5 — the exit criteria carry BOTH consequences, and UNCONFIRMED does not pass silently', () => {
|
|
121
|
+
// Anchored to the exit-criteria ROW each term belongs to. Cross-family QE: three independent
|
|
122
|
+
// phrase matches anywhere in Phase 2 would pass on explanatory prose that changed no verdict.
|
|
123
|
+
const rows = {};
|
|
124
|
+
for (const l of phase2().split('\n')) {
|
|
125
|
+
const m = l.match(/^\| (🟢 READY|🟡 CAVEATS|🔴 NEEDS WORK) \|/);
|
|
126
|
+
if (m) rows[m[1]] = l;
|
|
127
|
+
}
|
|
128
|
+
assert.ok(rows['🟢 READY'] && rows['🟡 CAVEATS'] && rows['🔴 NEEDS WORK'],
|
|
129
|
+
'the exit-criteria table must carry all three verdict rows');
|
|
130
|
+
assert.match(rows['🔴 NEEDS WORK'], /any external dependency is `CONTRADICTED`/,
|
|
131
|
+
'a contradicted dependency must send the run back, in the row that sends it back');
|
|
132
|
+
assert.match(rows['🟡 CAVEATS'], /every `UNCONFIRMED` dependency NAMED row by row/,
|
|
133
|
+
'an unconfirmed dependency must be NAMED in the CAVEATS row — an unnamed caveat is one '
|
|
134
|
+
+ 'nobody acts on');
|
|
135
|
+
assert.match(rows['🟢 READY'], /no external dependency `UNCONFIRMED` or `CONTRADICTED`/,
|
|
136
|
+
'and READY must exclude both, or the strongest verdict is the one that checks least');
|
|
137
|
+
// And the consequence must be scoped to the REQUIREMENT: without that, an inventory of nothing
|
|
138
|
+
// but UNCONFIRMED rows reaches Phase 3 with a caveat and feasibility is never established.
|
|
139
|
+
assert.match(inventory(), /cannot enter Phase 3 — defer, remove or replace them/,
|
|
140
|
+
'UNCONFIRMED must stop the requirements that rest on it, or the check is optional in practice');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('P6 — NO vendor fact is baked in: external facts drift', () => {
|
|
144
|
+
// A fixture asserting what some API can do would fail when the world changes and pass when our
|
|
145
|
+
// own rule breaks. The template must teach the SHAPE and name nobody.
|
|
146
|
+
const block = inventory();
|
|
147
|
+
const vendors = ['Anthropic', 'OpenAI', 'Stripe', 'Twilio', 'SendGrid', 'AWS', 'Google Cloud',
|
|
148
|
+
'Azure', 'Firebase', 'Supabase'];
|
|
149
|
+
for (const v of vendors) {
|
|
150
|
+
assert.ok(!block.includes(v),
|
|
151
|
+
'the template must name no real provider — capabilities drift and a stale fact recorded as '
|
|
152
|
+
+ 'evidence is worse than none. Found: ' + v);
|
|
153
|
+
}
|
|
154
|
+
// A denylist can only ever catch the names on it. The positive assertion is what actually holds:
|
|
155
|
+
// the example row's own cells must be bracketed placeholders, so there is no cell for a real
|
|
156
|
+
// vendor to sit in. The list above stays as a cheap second net.
|
|
157
|
+
const exampleRow = block.split('\n')
|
|
158
|
+
.find((l) => l.startsWith('| [') && l.includes('CONFIRMED')) || '';
|
|
159
|
+
assert.notEqual(exampleRow, '', 'the template must show one example row');
|
|
160
|
+
const cells = exampleRow.split('|').map((c) => c.trim()).filter(Boolean);
|
|
161
|
+
for (const cell of cells) {
|
|
162
|
+
assert.ok(/^\[|^CONFIRMED$/.test(cell) || cell.startsWith('['),
|
|
163
|
+
'every example cell must be a bracketed placeholder or the verdict itself, so no real '
|
|
164
|
+
+ 'provider can sit in the row: ' + cell);
|
|
165
|
+
}
|
|
166
|
+
assert.match(block, /are PLACEHOLDERS/, 'and it must say the example names are placeholders');
|
|
167
|
+
assert.match(block, /a stale fact recorded as evidence is worse than\s+none/,
|
|
168
|
+
'with the reason, so a later editor does not helpfully fill in a real one');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('P8 — the agent that DISPATCHES the swarm lists the same lenses as the command', () => {
|
|
172
|
+
// Found by an independent reviewer after the fact: commands/replicate.md gained the sixth lens
|
|
173
|
+
// and templates/.claude/agents/doc-validator.md did not. Two files describing one swarm, one of
|
|
174
|
+
// them a lens short — and the sha256 snapshot test cannot see it, because both files hash fine.
|
|
175
|
+
const cmd = read(REPLICATE);
|
|
176
|
+
const agent = fs.readFileSync(path.join(TPL, 'agents', 'doc-validator.md'), 'utf-8');
|
|
177
|
+
const names = (src) => new Set([...src.matchAll(/\| `(validator-[a-z-]+)`/g)].map((m) => m[1]));
|
|
178
|
+
const inCmd = names(cmd);
|
|
179
|
+
const inAgent = names(agent);
|
|
180
|
+
assert.ok(inCmd.size >= 6, 'the command must dispatch at least six lenses: ' + [...inCmd]);
|
|
181
|
+
assert.deepEqual([...inAgent].sort(), [...inCmd].sort(),
|
|
182
|
+
'the dispatching agent and the command disagree about the swarm: '
|
|
183
|
+
+ JSON.stringify({ agent: [...inAgent], command: [...inCmd] }));
|
|
184
|
+
// And the count in the agent's own prose must match the table it introduces.
|
|
185
|
+
const stated = agent.match(/Launch (\d+) parallel validation agents/);
|
|
186
|
+
assert.ok(stated, 'doc-validator.md must state how many it launches');
|
|
187
|
+
assert.equal(Number(stated[1]), inAgent.size,
|
|
188
|
+
'doc-validator.md says ' + stated[1] + ' but tables ' + inAgent.size);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('P7 — nothing cites check-pipeline-gaps.sh, which does not exist', () => {
|
|
192
|
+
// The report cites it. MEASURED — a repo-wide find returns nothing. Pointing a reader at a
|
|
193
|
+
// script that is not there is a worse failure than the gap it was cited to close.
|
|
194
|
+
for (const f of [SPARC, REPLICATE]) {
|
|
195
|
+
assert.ok(!read(f).includes('check-pipeline-gaps'),
|
|
196
|
+
f + ' references a script that does not exist in this repository');
|
|
197
|
+
}
|
|
198
|
+
// The premise, asserted rather than assumed: if someone ever ADDS the script, this goes red and
|
|
199
|
+
// whoever added it has to revisit the rule instead of leaving a stale prohibition behind.
|
|
200
|
+
const pkgRoot = path.join(__dirname, '..', '..');
|
|
201
|
+
const candidates = ['check-pipeline-gaps.sh', path.join('scripts', 'check-pipeline-gaps.sh'),
|
|
202
|
+
path.join('bin', 'check-pipeline-gaps.sh')];
|
|
203
|
+
for (const c of candidates) {
|
|
204
|
+
assert.ok(!fs.existsSync(path.join(pkgRoot, c)),
|
|
205
|
+
'the script now exists at ' + c + ' — this rule was written on the premise that it does '
|
|
206
|
+
+ 'not, so revisit it rather than deleting this assertion');
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Two callers disabled the exact branch the module they call declares. MEASURED:
|
|
4
|
+
//
|
|
5
|
+
// commands/replicate.md:169 | M5: Growth Engine | If B2C/PLG | …
|
|
6
|
+
// agents/product-discoverer.md:22 | M5: Growth Engine | If B2C/PLG | …
|
|
7
|
+
// modules/05-growth-engine.md:62 - Если B2B → sales-led growth, не product-led; переключи framework
|
|
8
|
+
// reverse-engineering-unicorn/SKILL.md:94 MODULE 5: GROWTH ENGINE → … (no condition)
|
|
9
|
+
//
|
|
10
|
+
// Three files disagreed about whether M5 runs, and the two that said "only for B2C" were the ones
|
|
11
|
+
// that decided. For every B2B project the one output slot — product-discoverer.md "### Growth
|
|
12
|
+
// Channels [From growth engine analysis — if applicable]" — arrived empty, not because there was
|
|
13
|
+
// nothing to say but because the gate stopped the module that would have said it.
|
|
14
|
+
//
|
|
15
|
+
// NOT done, deliberately: editing the growth module. It exists in four byte-identical copies across
|
|
16
|
+
// three published packages, kept in step by a --check gate that exits 1 on drift. A two-line fix in
|
|
17
|
+
// two callers touches ONE package; touching the module is a three-package release. P5 pins that.
|
|
18
|
+
|
|
19
|
+
const { test, describe } = require('node:test');
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
const crypto = require('node:crypto');
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
26
|
+
const read = (rel) => fs.readFileSync(path.join(TPL, rel), 'utf-8');
|
|
27
|
+
|
|
28
|
+
const CALLERS = [
|
|
29
|
+
{ file: 'commands/replicate.md', produces: 'Channels, integrations' },
|
|
30
|
+
{ file: 'agents/product-discoverer.md', produces: 'Channels, integrations, viral loops' },
|
|
31
|
+
];
|
|
32
|
+
const MODULE = 'skills/reverse-engineering-unicorn/modules/05-growth-engine.md';
|
|
33
|
+
|
|
34
|
+
/** EVERY table row that decides whether M5 runs. Cross-family QE: taking the FIRST match let a gate
|
|
35
|
+
* come back in a duplicate row further down, invisible to both helpers. */
|
|
36
|
+
function m5Rows(file) {
|
|
37
|
+
const rows = read(file).split('\n').filter((l) => l.startsWith('| M5: Growth Engine |'));
|
|
38
|
+
assert.equal(rows.length, 1,
|
|
39
|
+
file + ' must have exactly one M5 row; found ' + rows.length + ': ' + JSON.stringify(rows));
|
|
40
|
+
return rows;
|
|
41
|
+
}
|
|
42
|
+
const cells = (row) => row.split('|').map((c) => c.trim());
|
|
43
|
+
|
|
44
|
+
describe('the growth module is not gated off for the type it handles itself', () => {
|
|
45
|
+
test('P1 — neither caller conditions M5 on a product type', () => {
|
|
46
|
+
// Asserted on the ROW, not the file: the phrase "B2C" appears legitimately elsewhere (the
|
|
47
|
+
// sentence explaining WHY the gate went), and a whole-file check would forbid the explanation.
|
|
48
|
+
// The property is not "no condition" — cross-family QE was right that `Always` over-promises,
|
|
49
|
+
// because M5's outputs are CAC, channels and loops and an internal tool with no acquisition
|
|
50
|
+
// objective has nothing to put in them. The property is that the condition does not EXCLUDE a
|
|
51
|
+
// product type the module handles: if it names types at all, B2B must be among them.
|
|
52
|
+
for (const { file } of CALLERS) {
|
|
53
|
+
for (const row of m5Rows(file)) {
|
|
54
|
+
const condition = cells(row)[2];
|
|
55
|
+
assert.notEqual(condition, '', file + ': the M5 condition cell is empty');
|
|
56
|
+
const namesTypes = /B2C|B2B|PLG|product-led|sales-led/i.test(condition);
|
|
57
|
+
if (namesTypes) {
|
|
58
|
+
assert.match(condition, /B2B/i,
|
|
59
|
+
file + ' gates M5 on product type and leaves B2B out — the module handles B2B at '
|
|
60
|
+
+ 'modules/05-growth-engine.md:62: ' + JSON.stringify(condition));
|
|
61
|
+
}
|
|
62
|
+
assert.ok(!/^If B2C/i.test(condition),
|
|
63
|
+
file + ' restored the original product-type gate: ' + JSON.stringify(condition));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('P2 — the row keeps saying what M5 produces', () => {
|
|
69
|
+
// A removal that took the row with it would satisfy P1 and lose the module from the table.
|
|
70
|
+
// The OUTPUT COLUMN, not a substring of the row: cross-family QE noted a substring check passes
|
|
71
|
+
// if the text drifts into the condition cell.
|
|
72
|
+
for (const { file, produces } of CALLERS) {
|
|
73
|
+
for (const row of m5Rows(file)) {
|
|
74
|
+
assert.equal(cells(row)[3], produces,
|
|
75
|
+
file + ' lost or moved M5\'s output column: ' + row);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('P3 — the orchestrator still runs M5 unconditionally: it is the fixed point', () => {
|
|
81
|
+
const skill = read('skills/reverse-engineering-unicorn/SKILL.md');
|
|
82
|
+
const line = skill.split('\n').find((l) => l.includes('MODULE 5: GROWTH ENGINE'));
|
|
83
|
+
assert.ok(line, 'SKILL.md must still list MODULE 5');
|
|
84
|
+
assert.ok(!/if |если |B2C|B2B|PLG/i.test(line),
|
|
85
|
+
'the orchestrator must stay unconditional — all three files agree with IT: ' + line);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('P4 — the module\'s own B2B branch survives the removal that it justifies', () => {
|
|
89
|
+
// This is the load-bearing one. Removing the gate is safe BECAUSE the module decides per type.
|
|
90
|
+
// Without this assertion someone could delete the justification and keep the removal, and the
|
|
91
|
+
// module would then run for B2B with no B2B behaviour.
|
|
92
|
+
assert.match(read(MODULE), /Если B2B → sales-led growth, не product-led/,
|
|
93
|
+
'the branch that makes the ungated call correct must still be there');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('P5 — the growth module is byte-identical: this feature touches two callers only', () => {
|
|
97
|
+
// Four byte-identical copies across three published packages hang off this file. Pinning its
|
|
98
|
+
// hash is what keeps a two-line fix from becoming a three-package release by accident.
|
|
99
|
+
const sha = crypto.createHash('sha256')
|
|
100
|
+
.update(fs.readFileSync(path.join(TPL, MODULE))).digest('hex');
|
|
101
|
+
assert.equal(sha, '98e8577aa5a5472574ac9bac590b76a4a3d16f99bc67ebec2b09b604c7c1faab',
|
|
102
|
+
'the growth module changed; that is a three-package release, not this feature');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The six shipped hooks resolved BOTH their own file and their own data against a directory that
|
|
4
|
+
// drifts. One `cd` inside a Bash tool call broke all six for the rest of the session, silently,
|
|
5
|
+
// because hooks are non-blocking.
|
|
6
|
+
//
|
|
7
|
+
// Two independent resolutions, failing differently:
|
|
8
|
+
// - the HOST resolves the command string -> wrong: MODULE_NOT_FOUND, loud;
|
|
9
|
+
// - the SCRIPT resolves its own data -> wrong: exit 0 having done nothing, silent.
|
|
10
|
+
//
|
|
11
|
+
// This suite is deliberately BEHAVIOURAL where it can be. The field report's own verification
|
|
12
|
+
// looped the hooks from a subdirectory and checked the exit code: all four returned 0 while
|
|
13
|
+
// committing nothing and injecting nothing. An exit code proves the module LOADED. P4/P5/P6 assert
|
|
14
|
+
// an EFFECT, and P5 is the one that fails if either half of the fix is missing.
|
|
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 PKG_DIR = path.resolve(__dirname, '..', '..');
|
|
24
|
+
const TPL = path.join(PKG_DIR, 'templates', '.claude');
|
|
25
|
+
const HOOKS = ['autocommit-roadmap', 'autocommit-insights', 'autocommit-plans',
|
|
26
|
+
'session-insights', 'statusline', 'state-update'];
|
|
27
|
+
|
|
28
|
+
const readTpl = (rel) => fs.readFileSync(path.join(TPL, rel), 'utf-8');
|
|
29
|
+
|
|
30
|
+
/** Source with comments removed. Cross-family QE: the first version of P3 was satisfied by the
|
|
31
|
+
* explanatory comment that NAMES both CLAUDE_PROJECT_DIR and __dirname, while the executable code
|
|
32
|
+
* could still anchor anywhere. A mention is not a use. */
|
|
33
|
+
function code(src) {
|
|
34
|
+
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
|
35
|
+
}
|
|
36
|
+
const settings = () => JSON.parse(readTpl('settings.json'));
|
|
37
|
+
|
|
38
|
+
/** Every command string the host will spawn: the four hooks plus the status line. */
|
|
39
|
+
function allCommands() {
|
|
40
|
+
const s = settings();
|
|
41
|
+
const out = [];
|
|
42
|
+
if (s.statusLine && s.statusLine.command) out.push(s.statusLine.command);
|
|
43
|
+
for (const entries of Object.values(s.hooks || {})) {
|
|
44
|
+
for (const entry of entries) for (const h of entry.hooks || []) out.push(h.command);
|
|
45
|
+
}
|
|
46
|
+
assert.ok(out.length >= 5, 'expected at least the 4 hooks + statusLine, got ' + out.length);
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A throwaway git project with the shipped hooks installed and a subdirectory to run them from. */
|
|
51
|
+
function project() {
|
|
52
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-anchor-'));
|
|
53
|
+
const real = fs.realpathSync(dir); // macOS /var -> /private/var, else git paths differ
|
|
54
|
+
fs.mkdirSync(path.join(real, '.claude', 'hooks'), { recursive: true });
|
|
55
|
+
for (const h of HOOKS) {
|
|
56
|
+
fs.copyFileSync(path.join(TPL, 'hooks', h + '.cjs'),
|
|
57
|
+
path.join(real, '.claude', 'hooks', h + '.cjs'));
|
|
58
|
+
}
|
|
59
|
+
fs.mkdirSync(path.join(real, 'projects', '01-sub'), { recursive: true });
|
|
60
|
+
const git = (args) => execFileSync('git', args, { cwd: real, stdio: 'ignore' });
|
|
61
|
+
git(['init', '-q']);
|
|
62
|
+
git(['config', 'user.email', 'test@example.invalid']);
|
|
63
|
+
git(['config', 'user.name', 'test']);
|
|
64
|
+
return { root: real, sub: path.join(real, 'projects', '01-sub'), git };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Run one hook FROM THE SUBDIRECTORY — the only place this defect is visible. */
|
|
68
|
+
function runFromSub(p, hook, { withEnv = true, args = [] } = {}) {
|
|
69
|
+
const env = { ...process.env };
|
|
70
|
+
if (withEnv) env.CLAUDE_PROJECT_DIR = p.root;
|
|
71
|
+
else delete env.CLAUDE_PROJECT_DIR;
|
|
72
|
+
try {
|
|
73
|
+
const stdout = execFileSync(process.execPath,
|
|
74
|
+
[path.join(p.root, '.claude', 'hooks', hook + '.cjs'), ...args],
|
|
75
|
+
{ cwd: p.sub, env, encoding: 'utf8', stdio: 'pipe' });
|
|
76
|
+
return { code: 0, stdout: stdout || '' };
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return { code: err.status ?? 1, stdout: err.stdout?.toString() ?? '' };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('hooks resolve against the project, not the drifting cwd (PR-013)', () => {
|
|
83
|
+
test('P1 — no command the host spawns resolves relatively', () => {
|
|
84
|
+
for (const cmd of allCommands()) {
|
|
85
|
+
assert.doesNotMatch(cmd, /node\s+["']?\.claude[/\\]/,
|
|
86
|
+
'a relative script path resolves against the drifting cwd, not settings.json: ' + cmd);
|
|
87
|
+
assert.match(cmd, /CLAUDE_PROJECT_DIR/,
|
|
88
|
+
'every command must be anchored at the project root: ' + cmd);
|
|
89
|
+
// A correctly-spelled anchor in front of a script that does not exist is still a dead hook.
|
|
90
|
+
const named = cmd.match(/hooks\/([A-Za-z0-9._-]+\.cjs)/);
|
|
91
|
+
assert.ok(named, 'the command must name a .cjs script: ' + cmd);
|
|
92
|
+
assert.ok(fs.existsSync(path.join(TPL, 'hooks', named[1])),
|
|
93
|
+
'settings.json points at a script this package does not ship: ' + named[1]);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('P2 — the anchor is the PowerShell-safe braced form, and the rejected spellings stay rejected', () => {
|
|
98
|
+
for (const cmd of allCommands()) {
|
|
99
|
+
// MEASURED from the Claude Code binary: it rewrites the exact token ${CLAUDE_PROJECT_DIR}
|
|
100
|
+
// to ${env:CLAUDE_PROJECT_DIR} for PowerShell, and warns that the UNBRACED $CLAUDE_PROJECT_DIR
|
|
101
|
+
// "PowerShell reads as an undefined variable ($null)".
|
|
102
|
+
assert.match(cmd, /\$\{CLAUDE_PROJECT_DIR\}/,
|
|
103
|
+
'must use the braced form the host rewrites for PowerShell: ' + cmd);
|
|
104
|
+
// ${CLAUDE_PROJECT_DIR:-.} is what this repo's own config uses and is REJECTED here: the
|
|
105
|
+
// rewrite is a literal replaceAll of "${CLAUDE_PROJECT_DIR}", so the :- form never matches
|
|
106
|
+
// and stays a POSIX-only construct — against the template's own cross-platform promise.
|
|
107
|
+
assert.doesNotMatch(cmd, /\$\{CLAUDE_PROJECT_DIR:-/,
|
|
108
|
+
'the POSIX default-expansion form is not cross-platform: ' + cmd);
|
|
109
|
+
assert.doesNotMatch(cmd, /\$CLAUDE_PROJECT_DIR[^{]/,
|
|
110
|
+
'the unbraced form is null on PowerShell: ' + cmd);
|
|
111
|
+
assert.match(cmd, /"\$\{CLAUDE_PROJECT_DIR\}[^"]*"/,
|
|
112
|
+
'the path must be double-quoted — a project path may contain spaces: ' + cmd);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('P3 — no hook reads process.cwd(); all six are anchored', () => {
|
|
117
|
+
for (const h of HOOKS) {
|
|
118
|
+
const src = code(readTpl(path.join('hooks', h + '.cjs')));
|
|
119
|
+
assert.doesNotMatch(src, /process\.cwd\(\)/,
|
|
120
|
+
h + '.cjs still anchors on the drifting cwd — settings.json alone only makes it RUN');
|
|
121
|
+
assert.match(src, /process\.env\.CLAUDE_PROJECT_DIR/,
|
|
122
|
+
h + '.cjs must consult the host first — it is authoritative about the project');
|
|
123
|
+
assert.match(src, /__dirname/,
|
|
124
|
+
h + '.cjs must fall back to its own location, so it works with the variable absent');
|
|
125
|
+
// A truthy but RELATIVE host value would still resolve against the drifting cwd — the very
|
|
126
|
+
// bug this anchor removes. Truthiness is not enough; absoluteness is the property.
|
|
127
|
+
assert.match(src, /path\.isAbsolute\(/,
|
|
128
|
+
h + '.cjs must require the host value to be ABSOLUTE, not merely non-empty');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('P4 — with CLAUDE_PROJECT_DIR UNSET, a hook still finds the project', () => {
|
|
133
|
+
// This is what makes the settings fix non-load-bearing for correctness: an older host, a
|
|
134
|
+
// hand-run, a CI shell. It also pins the || ordering: remove the __dirname fallback and this
|
|
135
|
+
// is the assertion that goes red.
|
|
136
|
+
const p = project();
|
|
137
|
+
try {
|
|
138
|
+
fs.mkdirSync(path.join(p.root, '.claude', 'insights'), { recursive: true });
|
|
139
|
+
fs.writeFileSync(path.join(p.root, '.claude', 'insights', 'index.md'),
|
|
140
|
+
'## Insight one\n\nbody one\n');
|
|
141
|
+
const r = runFromSub(p, 'session-insights', { withEnv: false });
|
|
142
|
+
assert.equal(r.code, 0, 'the hook must stay non-blocking');
|
|
143
|
+
assert.match(r.stdout, /Insight one/,
|
|
144
|
+
'with no env var and cwd in a subdirectory, the hook must still read the ROOT index');
|
|
145
|
+
} finally { fs.rmSync(p.root, { recursive: true, force: true }); }
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('P5 — EFFECT from a subdirectory: the roadmap is actually committed', () => {
|
|
149
|
+
// The property the whole feature exists for. An exit-code check passes on the broken hook;
|
|
150
|
+
// this does not. Fails if EITHER half of the fix is missing.
|
|
151
|
+
const p = project();
|
|
152
|
+
try {
|
|
153
|
+
fs.writeFileSync(path.join(p.root, '.claude', 'feature-roadmap.json'), '{"v":1}\n');
|
|
154
|
+
p.git(['add', '-A']);
|
|
155
|
+
p.git(['commit', '-q', '-m', 'base']);
|
|
156
|
+
fs.writeFileSync(path.join(p.root, '.claude', 'feature-roadmap.json'), '{"v":2}\n');
|
|
157
|
+
|
|
158
|
+
const before = execFileSync('git', ['rev-list', '--count', 'HEAD'],
|
|
159
|
+
{ cwd: p.root, encoding: 'utf8' }).trim();
|
|
160
|
+
const r = runFromSub(p, 'autocommit-roadmap');
|
|
161
|
+
assert.equal(r.code, 0, 'the hook must stay non-blocking');
|
|
162
|
+
const after = execFileSync('git', ['rev-list', '--count', 'HEAD'],
|
|
163
|
+
{ cwd: p.root, encoding: 'utf8' }).trim();
|
|
164
|
+
|
|
165
|
+
assert.equal(Number(after), Number(before) + 1,
|
|
166
|
+
'a changed roadmap must be COMMITTED when the hook runs from a subdirectory — '
|
|
167
|
+
+ 'this is the assertion the report\'s exit-code check could not make');
|
|
168
|
+
const clean = execFileSync('git', ['status', '--porcelain', '--', '.claude/feature-roadmap.json'],
|
|
169
|
+
{ cwd: p.root, encoding: 'utf8' });
|
|
170
|
+
assert.equal(clean.trim(), '', 'and the file must be left clean, not merely staged');
|
|
171
|
+
} finally { fs.rmSync(p.root, { recursive: true, force: true }); }
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('P7 — every autocommit hook puts -m BEFORE the `--`, or it commits nothing', () => {
|
|
175
|
+
// Found BY P5, not by review: everything after `--` is a pathspec, so
|
|
176
|
+
// ['commit','--only','--',RELATIVE,'-m',MSG] made git look for files literally named '-m' and
|
|
177
|
+
// 'docs(roadmap): auto-update'. It failed from EVERY directory, and the hook exits 0 on failure,
|
|
178
|
+
// so three autocommit hooks had never committed anything and nothing said so. P5 proves the
|
|
179
|
+
// behaviour for one of the three; this pins the shape for all three, since the other two have
|
|
180
|
+
// no git fixture of their own.
|
|
181
|
+
for (const h of ['autocommit-roadmap', 'autocommit-insights', 'autocommit-plans']) {
|
|
182
|
+
const src = readTpl(path.join('hooks', h + '.cjs'));
|
|
183
|
+
const m = src.match(/git\(\[('commit'[^\]]*)\]\)/);
|
|
184
|
+
assert.ok(m, h + '.cjs must invoke git commit');
|
|
185
|
+
const args = m[1];
|
|
186
|
+
// Cross-family QE: comparing indexOf directly made a MISSING -m pass, since -1 is less than
|
|
187
|
+
// any real index. Presence first, then order.
|
|
188
|
+
const mi = args.indexOf("'-m'");
|
|
189
|
+
const dd = args.indexOf("'--'");
|
|
190
|
+
assert.ok(mi >= 0, h + ".cjs: git commit must carry -m at all: " + args);
|
|
191
|
+
assert.ok(dd >= 0, h + ".cjs: git commit must carry the `--` pathspec separator: " + args);
|
|
192
|
+
assert.ok(mi < dd,
|
|
193
|
+
h + ".cjs: -m must precede `--`, else the message is parsed as a pathspec: " + args);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('P6 — writer and reader agree on ONE path, from any cwd', () => {
|
|
198
|
+
// state-update.cjs writes the state, statusline.cjs reads it. Two drifting anchors can
|
|
199
|
+
// disagree, and a status line reading a file nobody wrote is worse than none.
|
|
200
|
+
const p = project();
|
|
201
|
+
try {
|
|
202
|
+
// A DISTINCTIVE value: cross-family QE noted that matching /replicate|VALIDATE/ would pass on
|
|
203
|
+
// any static status output that happens to contain those words, without reading the file at
|
|
204
|
+
// all. A token that appears nowhere else can only have come through the state file.
|
|
205
|
+
const TOKEN = 'ZQPHASE7X';
|
|
206
|
+
const w = runFromSub(p, 'state-update',
|
|
207
|
+
{ args: ['--command', '/replicate', '--phase', TOKEN, '--index', '2', '--total', '4'] });
|
|
208
|
+
assert.equal(w.code, 0, 'the writer must stay non-blocking');
|
|
209
|
+
const stateFile = path.join(p.root, '.claude', '.p-replicator-state.json');
|
|
210
|
+
assert.ok(fs.existsSync(stateFile),
|
|
211
|
+
'the writer must publish state at the PROJECT root, not under the subdirectory it ran from');
|
|
212
|
+
assert.doesNotMatch(
|
|
213
|
+
fs.existsSync(path.join(p.sub, '.claude')) ? 'stray' : 'clean', /stray/,
|
|
214
|
+
'and must not have created a second .claude tree under the subdirectory');
|
|
215
|
+
|
|
216
|
+
const r = runFromSub(p, 'statusline');
|
|
217
|
+
assert.equal(r.code, 0, 'the reader must stay non-blocking');
|
|
218
|
+
assert.match(r.stdout, new RegExp(TOKEN),
|
|
219
|
+
'the reader must echo the DISTINCTIVE token the writer just published — a token that '
|
|
220
|
+
+ 'appears nowhere else can only have arrived through the state file');
|
|
221
|
+
} finally { fs.rmSync(p.root, { recursive: true, force: true }); }
|
|
222
|
+
});
|
|
223
|
+
});
|