@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,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A scenario could promise a check that no algorithm performs, and nothing in the pipeline could
|
|
4
|
+
// notice. MEASURED: Specification.md's user-story template produced Gherkin acceptance criteria with
|
|
5
|
+
// no identifier of any kind, Pseudocode.md's algorithm template was "### Algorithm: [Name]" with no
|
|
6
|
+
// back-reference, and a sweep of the skill for traceab|SC-\d|scenario id returned nothing. The two
|
|
7
|
+
// documents are written one phase apart and were never compared again.
|
|
8
|
+
//
|
|
9
|
+
// WHAT THIS FEATURE CLAIMS, exactly. It establishes that a CLAIM exists and that its two ends name
|
|
10
|
+
// each other. It does not establish that the algorithm performs the check — no comparison of names
|
|
11
|
+
// can. The nearest precedent says the same of itself: the K2 gate's own source records that its C1
|
|
12
|
+
// check is a grep and that PROSE satisfies it. So this catches "nobody wrote anything about this
|
|
13
|
+
// scenario"; it does not catch "someone wrote a line that mentions it", and closing a gap with
|
|
14
|
+
// paperwork is precisely the failure the field report is about. P6 asserts the limit is written into
|
|
15
|
+
// the template, so the next reader sees the same caveat the author did.
|
|
16
|
+
|
|
17
|
+
const { test, describe } = require('node:test');
|
|
18
|
+
const assert = require('node:assert/strict');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const SKILL = path.join(__dirname, '..', '..', 'templates', '.claude', 'skills',
|
|
23
|
+
'sparc-prd-mini', 'SKILL.md');
|
|
24
|
+
const read = () => fs.readFileSync(SKILL, 'utf-8');
|
|
25
|
+
|
|
26
|
+
function phase(src, heading, nextHeading) {
|
|
27
|
+
const start = src.indexOf(heading);
|
|
28
|
+
const end = src.indexOf(nextHeading, start + 1);
|
|
29
|
+
assert.ok(start > 0, 'missing section: ' + heading);
|
|
30
|
+
assert.ok(end > start, 'missing terminator: ' + nextHeading);
|
|
31
|
+
return src.slice(start, end);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const phase3 = (src) => phase(src, '### Phase 3: SPECIFICATION', '### Phase 4: PSEUDOCODE');
|
|
35
|
+
const phase4 = (src) => phase(src, '### Phase 4: PSEUDOCODE', '### Phase 5: ARCHITECTURE');
|
|
36
|
+
|
|
37
|
+
describe('a scenario and the algorithm that claims it can be traced to each other (PR-003)', () => {
|
|
38
|
+
test('P1 — Phase 3 gives every acceptance scenario an ID of a stated form', () => {
|
|
39
|
+
const block = phase3(read());
|
|
40
|
+
assert.match(block, /\[SC-<story-id>-1\]/,
|
|
41
|
+
'the ID must appear IN the template, so its form is unambiguous rather than described');
|
|
42
|
+
assert.match(block, /`SC-<US-id>-<n>`/, 'and the form must be stated');
|
|
43
|
+
// Cross-family QE: <story-id> had no definition and no uniqueness rule, so two scenarios could
|
|
44
|
+
// end up with the same name and a trace would point at either.
|
|
45
|
+
assert.match(block, /`US-<nnn>` — three digits, assigned in order, never reused/,
|
|
46
|
+
'the story id must be DEFINED, with a uniqueness rule, or the scenario id is not well-formed');
|
|
47
|
+
assert.match(block, /the scenarios of `US-007` are `SC-US-007-1`/,
|
|
48
|
+
'and shown worked once, so the composition is unambiguous');
|
|
49
|
+
assert.match(block, /nothing to trace to if a scenario has no\s+name/,
|
|
50
|
+
'and WHY, or a later editor drops the ID as noise');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('P2 — Phase 4 algorithms declare what they realise, where they are written', () => {
|
|
54
|
+
const block = phase4(read());
|
|
55
|
+
// Structural, not textual: between the algorithm heading and its INPUT line. Cross-family QE
|
|
56
|
+
// noted the first version only proved the string occurs somewhere in the phase.
|
|
57
|
+
const at = block.indexOf('### Algorithm: [Name]');
|
|
58
|
+
assert.ok(at > 0, 'the algorithm template must exist');
|
|
59
|
+
const algo = block.slice(at, block.indexOf('COMPLEXITY', at));
|
|
60
|
+
assert.match(algo, /REALISES: \[SC-… ids this algorithm implements/,
|
|
61
|
+
'the declaration must sit in the algorithm block itself, not in a table someone must remember');
|
|
62
|
+
assert.ok(algo.indexOf('REALISES') < algo.indexOf('INPUT:'),
|
|
63
|
+
'and beside INPUT/OUTPUT where the algorithm is written, not appended after it');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('P3 — the reconciliation is INSIDE Phase 4 and OUTSIDE the [MANUAL] CP4 block', () => {
|
|
67
|
+
// Placement is the property, twice over. A step after Phase 4 reconciles nothing in time; a step
|
|
68
|
+
// inside the fenced [MANUAL] block is skipped by an AUTO run, so a "mandatory" step would run in
|
|
69
|
+
// half of runs. The sibling Phase-5 feature shipped that exact defect and had it caught in
|
|
70
|
+
// review — "precedes the checkpoint text" and "outside the MANUAL-only block" are two different
|
|
71
|
+
// facts, and only the second one is the property.
|
|
72
|
+
const src = read();
|
|
73
|
+
const block = phase4(src);
|
|
74
|
+
const step = block.indexOf('**Шаг 4.9 — ПОКРЫТИЕ СЦЕНАРИЕВ');
|
|
75
|
+
const manual = block.indexOf('**[MANUAL] CP4:**');
|
|
76
|
+
assert.ok(step > 0, 'the coverage step must live inside Phase 4');
|
|
77
|
+
assert.ok(manual > step,
|
|
78
|
+
'and BEFORE the manual checkpoint block, not after it: ' + step + ' vs ' + manual);
|
|
79
|
+
// NOTE: an earlier version added a "not inside the fenced block" assertion here. It was VACUOUS
|
|
80
|
+
// — the fence is located FROM `manual`, so after `manual > step` the step cannot be inside it.
|
|
81
|
+
// Cross-family QE caught it. The assertion above is the real guard, and the mutation that moves
|
|
82
|
+
// the step into the fenced block turns it red, which is the evidence that matters.
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('P4 — a Scenario Coverage block is required in EVERY case, including all-covered', () => {
|
|
86
|
+
const block = phase4(read());
|
|
87
|
+
assert.match(block, /## Scenario Coverage/,
|
|
88
|
+
'the required output block must be named, or "reconcile" is advice');
|
|
89
|
+
assert.match(block, /in every\s+case, including the one where everything is covered/,
|
|
90
|
+
'an absent block and a block saying "all covered" are indistinguishable to the next reader');
|
|
91
|
+
assert.match(block, /Not claimed by any algorithm:/,
|
|
92
|
+
'and the uncovered scenarios must be listed, not summarised as a count');
|
|
93
|
+
// Cross-family QE: the check ran ONE WAY. An algorithm could declare REALISES for a scenario
|
|
94
|
+
// nobody ever wrote, and the dangling reference reads exactly like coverage.
|
|
95
|
+
assert.match(block, /Claimed by an algorithm but absent from Specification\.md:/,
|
|
96
|
+
'traceability must run both ways, or a dangling REALISES passes as coverage');
|
|
97
|
+
assert.match(block, /Both tables are required, and both may be the single word `none`/,
|
|
98
|
+
'and the empty case must be written out — an empty table and a forgotten one look identical');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('P5 — the reason vocabulary is CLOSED, and the two escape hatches are rejected by name', () => {
|
|
102
|
+
const block = phase4(read());
|
|
103
|
+
const expected = ['ui-only', 'external-service', 'out-of-mvp-scope', 'data-only', 'config-only'];
|
|
104
|
+
for (const reason of expected) {
|
|
105
|
+
assert.ok(block.includes('`' + reason + '`'),
|
|
106
|
+
'the closed list must carry the reason: ' + reason);
|
|
107
|
+
}
|
|
108
|
+
assert.match(block, /CLOSED list of five/, 'and declare itself closed, with its size');
|
|
109
|
+
// CLOSED means closed: the first version asserted the values exist and let a sixth in silently.
|
|
110
|
+
// The count is the property, and it must agree with the stated size.
|
|
111
|
+
const at = block.indexOf('CLOSED list of five');
|
|
112
|
+
const table = block.slice(at, block.indexOf('Free text is NOT a reason', at));
|
|
113
|
+
const rows = table.split('\n').filter((l) => /^\| `[a-z-]+` \|/.test(l));
|
|
114
|
+
assert.equal(rows.length, expected.length,
|
|
115
|
+
'the reason table must have exactly the stated number of rows: ' + JSON.stringify(rows));
|
|
116
|
+
// Without these two named, "free text" creeps back as the default and the list means nothing.
|
|
117
|
+
assert.match(block, /Free text is NOT a reason, and `N\/A` is NOT a reason/,
|
|
118
|
+
'the two ways this field gets emptied must be rejected by name');
|
|
119
|
+
assert.match(block, /A field that accepts anything records nothing/,
|
|
120
|
+
'with the reason, so the rejection survives an editor who finds it unfriendly');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('P6 — the template states its own limit', () => {
|
|
124
|
+
// The load-bearing honesty. A traceability block LOOKS like proof of implementation, and the
|
|
125
|
+
// field report's actual complaint is gaps closed with paperwork — which this cannot detect.
|
|
126
|
+
const block = phase4(read());
|
|
127
|
+
assert.match(block, /It does NOT establish that the algorithm's steps actually perform the check/,
|
|
128
|
+
'the limit must be written into the artifact, not only into the feature\'s own report');
|
|
129
|
+
assert.match(block, /nobody wrote anything about this\s+scenario/,
|
|
130
|
+
'and the class it DOES catch must be named, so the claim is bounded on both sides');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('P7 — the phase order is still S→P→A→R→C', () => {
|
|
134
|
+
// Regression guard on the file this edits: the skill is named sparc-prd-mini and its phases are
|
|
135
|
+
// the acronym. A reorder would leave it claiming a methodology it no longer follows.
|
|
136
|
+
const src = read();
|
|
137
|
+
const order = ['### Phase 3: SPECIFICATION', '### Phase 4: PSEUDOCODE',
|
|
138
|
+
'### Phase 5: ARCHITECTURE', '### Phase 6: REFINEMENT', '### Phase 7: COMPLETION'];
|
|
139
|
+
let prev = -1;
|
|
140
|
+
for (const h of order) {
|
|
141
|
+
const at = src.indexOf(h);
|
|
142
|
+
assert.ok(at > prev, 'phases must appear in SPARC order; out of place: ' + h);
|
|
143
|
+
prev = at;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Two ways the status line said something untrue, both reproduced before the fix.
|
|
4
|
+
//
|
|
5
|
+
// 1. The progress bar was built from done/total and captioned `mvp`, with `Done x/y` rendered
|
|
6
|
+
// separately beside it — one glyph, two different quantities, read as one statement.
|
|
7
|
+
//
|
|
8
|
+
// 2. The domain regexes had no word boundaries, so ordinary sentences picked a domain:
|
|
9
|
+
// "Run healthchecks before deploy" → healthcare (`health` inside `healthchecks`)
|
|
10
|
+
// "We translate the UI" → enterprise (`sla` inside `tranSLAte`)
|
|
11
|
+
// "A slack bot for standups" → enterprise (`sla` inside `SLAck`)
|
|
12
|
+
// "Embankment mapping service" → banking (`bank` inside `emBANKment`)
|
|
13
|
+
// All four expressions were affected.
|
|
14
|
+
//
|
|
15
|
+
// THE TRAP, and it is why P3/P4 exist: `\b` is not the fix. It is defined over \w = [A-Za-z0-9_], so
|
|
16
|
+
// there is no word boundary between a space and a Cyrillic letter — MEASURED, /\bбанк/.test('банк
|
|
17
|
+
// России') is FALSE. A reflex `\b` fix would have silently deleted every Russian term while looking
|
|
18
|
+
// correct in review. The boundaries are Unicode lookarounds with the `u` flag.
|
|
19
|
+
|
|
20
|
+
const { test, describe } = require('node:test');
|
|
21
|
+
const assert = require('node:assert/strict');
|
|
22
|
+
const { spawnSync } = require('node:child_process');
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const os = require('node:os');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
|
|
27
|
+
const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
28
|
+
const HOOK = path.join(TPL, 'hooks', 'statusline.cjs');
|
|
29
|
+
const STRIP = new RegExp('\\x1b\\[[0-9;]*m', 'g');
|
|
30
|
+
|
|
31
|
+
/** Run the REAL hook over a project whose CLAUDE.md says `text`, return its plain output. */
|
|
32
|
+
function render(opts) {
|
|
33
|
+
const o = opts || {};
|
|
34
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-labels-')));
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true });
|
|
37
|
+
fs.copyFileSync(HOOK, path.join(root, '.claude', 'hooks', 'statusline.cjs'));
|
|
38
|
+
if (o.claudeMd !== undefined) fs.writeFileSync(path.join(root, 'CLAUDE.md'), o.claudeMd);
|
|
39
|
+
// The Domain segment lives INSIDE the Roadmap line, so a domain fixture needs a roadmap too —
|
|
40
|
+
// without one the hook takes its "no roadmap yet" branch and never renders a domain at all.
|
|
41
|
+
if (o.features === undefined && o.claudeMd !== undefined) {
|
|
42
|
+
o.features = [{ id: 'a', status: 'next', priority: 'mvp' }];
|
|
43
|
+
}
|
|
44
|
+
if (o.features) {
|
|
45
|
+
fs.writeFileSync(path.join(root, '.claude', 'feature-roadmap.json'),
|
|
46
|
+
JSON.stringify({ version: '1.0', features: o.features }));
|
|
47
|
+
}
|
|
48
|
+
const r = spawnSync(process.execPath, [path.join(root, '.claude', 'hooks', 'statusline.cjs')],
|
|
49
|
+
{ cwd: root, env: Object.assign({}, process.env, { CLAUDE_PROJECT_DIR: root }),
|
|
50
|
+
encoding: 'utf8' });
|
|
51
|
+
return { code: r.status, text: (r.stdout || '').replace(STRIP, '') };
|
|
52
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const domainOf = (text) => {
|
|
56
|
+
const m = text.match(/Domain:\s*(\S+)/);
|
|
57
|
+
return m ? m[1] : null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The source of parseDomain, for the assertions that must look at the expressions themselves. */
|
|
61
|
+
function parseDomainSource() {
|
|
62
|
+
const src = fs.readFileSync(HOOK, 'utf-8');
|
|
63
|
+
const start = src.indexOf('function parseDomain');
|
|
64
|
+
const end = src.indexOf('\nfunction ', start + 1);
|
|
65
|
+
assert.ok(start > 0 && end > start, 'parseDomain must exist');
|
|
66
|
+
return src.slice(start, end);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('the status line says what it means (PR-011, two small fixes)', () => {
|
|
70
|
+
test('P1 — ordinary sentences no longer pick a domain', () => {
|
|
71
|
+
// Each case is the EXACT sentence that produced the false positive, so a future edit that
|
|
72
|
+
// half-fixes one term is visible rather than averaged away.
|
|
73
|
+
const cases = [
|
|
74
|
+
['Run healthchecks before deploy', 'health inside healthchecks'],
|
|
75
|
+
['We translate the UI into three languages', 'sla inside translate'],
|
|
76
|
+
['A slack bot for standups', 'sla inside slack'],
|
|
77
|
+
['Embankment mapping service', 'bank inside embankment'],
|
|
78
|
+
['Translation memory service', 'sla again'],
|
|
79
|
+
// Cross-family QE counter-examples: a left-only boundary was not enough for the Russian stems.
|
|
80
|
+
['Сервис организации банкетов под ключ', 'банк inside банкетов'],
|
|
81
|
+
['Финты в футболе: разбор техники', 'финт inside финты'],
|
|
82
|
+
['ЦБС городской библиотеки', 'цб inside ЦБС — a two-letter acronym cannot be made safe'],
|
|
83
|
+
['Video conversion service', 'bare conversion is not commerce'],
|
|
84
|
+
];
|
|
85
|
+
for (const [text, why] of cases) {
|
|
86
|
+
const r = render({ claudeMd: '# Project\n\n' + text + '\n' });
|
|
87
|
+
assert.equal(r.code, 0, 'the status line must never fail the session');
|
|
88
|
+
assert.equal(domainOf(r.text), null,
|
|
89
|
+
'still matched (' + why + '): ' + JSON.stringify(text) + ' → ' + domainOf(r.text));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('P2 — real domains are still detected, Cyrillic stems included', () => {
|
|
94
|
+
// The half a reflex `\b` fix would have deleted. Every Cyrillic entry here is a STEM meant to
|
|
95
|
+
// match its inflections, so it must keep matching them.
|
|
96
|
+
const cases = [
|
|
97
|
+
['Мобильный банк для МСБ', 'banking'],
|
|
98
|
+
['Финтех-стартап', 'banking'],
|
|
99
|
+
['A healthcare records system', 'healthcare'],
|
|
100
|
+
['Клиника на 40 врачей', 'healthcare'],
|
|
101
|
+
['Retail analytics platform', 'retail'],
|
|
102
|
+
['Рекомендательная система товаров', 'retail'],
|
|
103
|
+
['Enterprise SLA dashboard', 'enterprise'],
|
|
104
|
+
// Cross-family QE: these were MISSED by the first version — a fix that loses true positives
|
|
105
|
+
// trades one wrong answer for another.
|
|
106
|
+
['Retailer inventory analytics', 'retail'],
|
|
107
|
+
['Health tech startup', 'healthcare'],
|
|
108
|
+
['Platform for enterprises', 'enterprise'],
|
|
109
|
+
['Улучшаем конверсию воронки', 'retail'],
|
|
110
|
+
['Банковское приложение', 'banking'],
|
|
111
|
+
];
|
|
112
|
+
for (const [text, expected] of cases) {
|
|
113
|
+
const r = render({ claudeMd: '# Project\n\n' + text + '\n' });
|
|
114
|
+
assert.equal(domainOf(r.text), expected,
|
|
115
|
+
JSON.stringify(text) + ' should be ' + expected + ', got ' + domainOf(r.text));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('P3 — no domain expression uses \\b', () => {
|
|
120
|
+
// Asserted on the source because `\b` fails SILENTLY around Cyrillic: it does not error, it just
|
|
121
|
+
// stops matching, and every Russian term would vanish while the code looked right.
|
|
122
|
+
// Comments stripped first: the comment above these expressions EXPLAINS why \b is wrong and
|
|
123
|
+
// therefore contains it. A mention is not a use — the same trap this suite has hit before.
|
|
124
|
+
const code = parseDomainSource().split('\n').filter((l) => !/^\s*\/\//.test(l)).join('\n');
|
|
125
|
+
assert.ok(!/\\b/.test(code),
|
|
126
|
+
'\\b is not a word boundary around Cyrillic in JavaScript — /\\bбанк/.test("банк России") is '
|
|
127
|
+
+ 'false. Use a Unicode lookaround.');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('P4 — the boundaries are Unicode lookarounds with the u flag', () => {
|
|
131
|
+
const src = parseDomainSource();
|
|
132
|
+
assert.match(src, /\(\?<!\[\\\\p\{L\}\\\\p\{N\}\]\)/,
|
|
133
|
+
'a left boundary over Unicode letters is required');
|
|
134
|
+
assert.match(src, /\(\?!\[\\\\p\{L\}\\\\p\{N\}\]\)/,
|
|
135
|
+
'and a right boundary for whole words');
|
|
136
|
+
assert.match(src, /'iu'/, "and the `u` flag, or \\p{L} is not a property escape at all");
|
|
137
|
+
// The stem/word distinction is the reason the Cyrillic half survives; losing it would take the
|
|
138
|
+
// inflections with it.
|
|
139
|
+
assert.match(src, /const stem = /, 'stems take a left boundary only');
|
|
140
|
+
assert.match(src, /const word = /, 'whole words take both');
|
|
141
|
+
// Cross-family QE: the helpers being DECLARED proves nothing — dead declarations plus one 'iu'
|
|
142
|
+
// token satisfied the assertions above while an unbounded /conversion/i survived beside them.
|
|
143
|
+
// Every domain expression must be BUILT by one of the two helpers.
|
|
144
|
+
const built = [...src.matchAll(/const (\w+) = (stem|word)\(/g)].map((m) => m[1]);
|
|
145
|
+
assert.ok(built.length >= 6,
|
|
146
|
+
'each domain must be built by stem() or word(): found ' + JSON.stringify(built));
|
|
147
|
+
const raw = [...src.matchAll(/const (\w+(?:Stem|Word)) = \//g)].map((m) => m[1]);
|
|
148
|
+
assert.deepEqual(raw, [],
|
|
149
|
+
'these are raw regex literals, bypassing the boundary helpers: ' + JSON.stringify(raw));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('P5 — the bar is captioned with what it draws', () => {
|
|
153
|
+
const r = render({ features: [
|
|
154
|
+
{ id: 'a', status: 'done', priority: 'mvp' },
|
|
155
|
+
{ id: 'b', status: 'next', priority: 'mvp' },
|
|
156
|
+
{ id: 'c', status: 'next', priority: 'low' },
|
|
157
|
+
] });
|
|
158
|
+
const line = r.text.split('\n').find((l) => l.includes('Roadmap')) || '';
|
|
159
|
+
assert.match(line, /\[[●○]+\]\s+Done\s+1\/3/,
|
|
160
|
+
'the bar is built from done/total, so Done is its caption: ' + line);
|
|
161
|
+
assert.match(line, /mvp\s+1\/2/, 'and mvp keeps its own segment: ' + line);
|
|
162
|
+
assert.ok(!/\[[●○]+\]\s+mvp/.test(line),
|
|
163
|
+
'the bar must not be captioned with a quantity it does not draw: ' + line);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('P6 — with no MVP features the bar is still rendered', () => {
|
|
167
|
+
// The field report proposed hiding the bar when mvpTotal === 0. The bar is CORRECT; hiding a
|
|
168
|
+
// working indicator to mask a wrong caption fixes the wrong half.
|
|
169
|
+
const r = render({ features: [
|
|
170
|
+
{ id: 'a', status: 'done', priority: 'low' },
|
|
171
|
+
{ id: 'b', status: 'next', priority: 'low' },
|
|
172
|
+
] });
|
|
173
|
+
const line = r.text.split('\n').find((l) => l.includes('Roadmap')) || '';
|
|
174
|
+
assert.match(line, /\[[●○]+\]/, 'the bar must survive an mvp count of zero: ' + line);
|
|
175
|
+
assert.match(line, /Done\s+1\/2/, 'and still report the real progress: ' + line);
|
|
176
|
+
assert.match(line, /mvp\s+0\/0/, 'while the mvp segment says plainly that there are none');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The status line answered TWO questions with ONE anchor, so it told half the truth from every
|
|
4
|
+
// directory. MEASURED before the fix, with the toolkit at a course root and a 13-feature roadmap one
|
|
5
|
+
// level down in projects/01-app:
|
|
6
|
+
//
|
|
7
|
+
// from the course root: "Roadmap — no roadmap yet" Toolkit Skills 10/10, Hooks 6/6
|
|
8
|
+
// from projects/01-app: "Roadmap Done 0/13" Toolkit Skills 0/10, Hooks 0/6, Settings missing
|
|
9
|
+
//
|
|
10
|
+
// WHERE THE INSTRUMENTS ARE is settled by the hook's own location and is correct from any cwd.
|
|
11
|
+
// WHICH PROJECT'S ROADMAP TO SHOW is a different question, answered by surveying projects/*.
|
|
12
|
+
//
|
|
13
|
+
// The report also proposed walking UPWARD to find the project. Measured: the roadmap lies BELOW, so
|
|
14
|
+
// an upward walk returns the same root and the line does not change — the remedy does not treat its
|
|
15
|
+
// own symptom. Not implemented; the backlog item orders it third.
|
|
16
|
+
//
|
|
17
|
+
// These tests run the REAL hook as a real subprocess and read its REAL rendered output.
|
|
18
|
+
|
|
19
|
+
const { test, describe } = require('node:test');
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
const { execFileSync } = require('node:child_process');
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const PKG_DIR = path.resolve(__dirname, '..', '..');
|
|
27
|
+
const TPL = path.join(PKG_DIR, 'templates', '.claude');
|
|
28
|
+
const HOOK_SRC = path.join(TPL, 'hooks', 'statusline.cjs');
|
|
29
|
+
|
|
30
|
+
function writeRoadmap(dotClaudeDir, spec) {
|
|
31
|
+
const total = spec.total;
|
|
32
|
+
const done = spec.done || 0;
|
|
33
|
+
const mvp = spec.mvp || 0;
|
|
34
|
+
const features = [];
|
|
35
|
+
for (let i = 0; i < total; i++) {
|
|
36
|
+
features.push({
|
|
37
|
+
id: 'f' + i,
|
|
38
|
+
status: i < done ? 'done' : 'next',
|
|
39
|
+
// 'priority' is the package's own documented key — commands/next.md:66 — NOT 'tags'. The field
|
|
40
|
+
// report claimed the reader was wrong here; measuring the schema refuted that, and rewriting
|
|
41
|
+
// the reader toward 'tags' would break the documented contract. Filed separately: 4ca0970c.
|
|
42
|
+
// 'low', not 'later': the priority set is CLOSED (mvp|high|medium|low) and this fixture
|
|
43
|
+
// used a value in no enum — an off-schema roadmap of my own making, which the schema
|
|
44
|
+
// marker correctly flagged the moment it existed.
|
|
45
|
+
priority: i < mvp ? 'mvp' : 'low',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
fs.writeFileSync(path.join(dotClaudeDir, 'feature-roadmap.json'),
|
|
49
|
+
JSON.stringify({ version: '1.0', features }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A course-shaped fixture: the toolkit at the root, sub-projects below. */
|
|
53
|
+
function course(opts) {
|
|
54
|
+
const o = opts || {};
|
|
55
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-2roots-')));
|
|
56
|
+
fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true });
|
|
57
|
+
fs.copyFileSync(HOOK_SRC, path.join(root, '.claude', 'hooks', 'statusline.cjs'));
|
|
58
|
+
if (o.toolkit !== false) {
|
|
59
|
+
for (let i = 1; i <= 10; i++) {
|
|
60
|
+
fs.mkdirSync(path.join(root, '.claude', 'skills', 's' + i), { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
fs.copyFileSync(path.join(TPL, 'settings.json'), path.join(root, '.claude', 'settings.json'));
|
|
63
|
+
}
|
|
64
|
+
if (o.rootRoadmap) writeRoadmap(path.join(root, '.claude'), o.rootRoadmap);
|
|
65
|
+
for (const [name, spec] of Object.entries(o.subs || {})) {
|
|
66
|
+
const dir = path.join(root, 'projects', name, '.claude');
|
|
67
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
68
|
+
if (spec === 'malformed') fs.writeFileSync(path.join(dir, 'feature-roadmap.json'), '{not json');
|
|
69
|
+
else writeRoadmap(dir, spec);
|
|
70
|
+
}
|
|
71
|
+
return root;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const STRIP = new RegExp('\\x1b\\[[0-9;]*m', 'g');
|
|
75
|
+
|
|
76
|
+
/** Run the hook from `cwd`, with the host naming `projectRoot`. Returns plain text. */
|
|
77
|
+
function render(root, opts) {
|
|
78
|
+
const o = opts || {};
|
|
79
|
+
const cwd = o.cwd || root;
|
|
80
|
+
const env = Object.assign({}, process.env, { CLAUDE_PROJECT_DIR: o.projectRoot || root });
|
|
81
|
+
try {
|
|
82
|
+
const out = execFileSync(process.execPath,
|
|
83
|
+
[path.join(root, '.claude', 'hooks', 'statusline.cjs')],
|
|
84
|
+
{ cwd, env, encoding: 'utf8', stdio: 'pipe' });
|
|
85
|
+
return { code: 0, text: (out || '').replace(STRIP, '') };
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return { code: err.status == null ? 1 : err.status,
|
|
88
|
+
text: (err.stdout ? err.stdout.toString() : '').replace(STRIP, '') };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const line = (text, marker) => (text.split('\n').find((l) => l.includes(marker)) || '');
|
|
93
|
+
const cleanup = (d) => fs.rmSync(d, { recursive: true, force: true });
|
|
94
|
+
|
|
95
|
+
describe('the status line answers two questions with two anchors (PR-011)', () => {
|
|
96
|
+
test('P1 — from a subdirectory, the toolkit counts are the ROOT\'s, not zeros', () => {
|
|
97
|
+
const root = course({ subs: { '01-app': { total: 13 } } });
|
|
98
|
+
try {
|
|
99
|
+
const r = render(root, { cwd: path.join(root, 'projects', '01-app') });
|
|
100
|
+
const toolkit = line(r.text, 'Toolkit');
|
|
101
|
+
assert.match(toolkit, /Skills\s+.?10\/10/,
|
|
102
|
+
'the toolkit lives at the root and must be counted there from any cwd: ' + toolkit);
|
|
103
|
+
// Derived, not typed: the expected total is whatever the package registers, so adding a hook
|
|
104
|
+
// moves this with it instead of turning it red for a correct reason.
|
|
105
|
+
const expectedHooks = Object.keys(
|
|
106
|
+
require(path.join(PKG_DIR, 'src', 'utils.js')).COMPONENTS.hooks.items).length;
|
|
107
|
+
assert.match(toolkit, new RegExp('Hooks\\s+.?1\\/' + expectedHooks),
|
|
108
|
+
'and the hook count too — the fixture ships exactly one hook: ' + toolkit);
|
|
109
|
+
// Cross-family QE: `line()` returns '' when the segment is absent, and '' trivially satisfies
|
|
110
|
+
// doesNotMatch — so dropping the Settings segment entirely would have passed. Presence first.
|
|
111
|
+
const settings = line(r.text, 'Settings');
|
|
112
|
+
assert.notEqual(settings, '', 'the Settings segment must be rendered at all');
|
|
113
|
+
assert.doesNotMatch(settings, /missing/,
|
|
114
|
+
'settings.json is at the root; reporting it missing from a subdirectory is the old defect');
|
|
115
|
+
} finally { cleanup(root); }
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('P2 — root has no roadmap, a sub-project has 13: the line reports those 13', () => {
|
|
119
|
+
// The exact measured symptom: "no roadmap yet" while thirteen features sat one level down.
|
|
120
|
+
const root = course({ subs: { '01-app': { total: 13, done: 4 } } });
|
|
121
|
+
try {
|
|
122
|
+
const roadmap = line(render(root).text, 'Roadmap');
|
|
123
|
+
assert.doesNotMatch(roadmap, /no roadmap yet/,
|
|
124
|
+
'a roadmap exists one level down; claiming none is the defect: ' + roadmap);
|
|
125
|
+
assert.match(roadmap, /4\/13/, 'the sub-project figures must appear: ' + roadmap);
|
|
126
|
+
} finally { cleanup(root); }
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('P3 — sub-project figures are LABELLED as theirs, never presented as the root\'s own', () => {
|
|
130
|
+
// A number that means something other than it appears to is worse than a missing number.
|
|
131
|
+
const root = course({ subs: { '01-app': { total: 13, done: 4 } } });
|
|
132
|
+
try {
|
|
133
|
+
const roadmap = line(render(root).text, 'Roadmap');
|
|
134
|
+
assert.match(roadmap, /in\s+01-app/,
|
|
135
|
+
'the line must say WHERE the figures come from: ' + roadmap);
|
|
136
|
+
assert.doesNotMatch(roadmap, /mvp/,
|
|
137
|
+
'the root has no roadmap, so it has no mvp counter to report: ' + roadmap);
|
|
138
|
+
} finally { cleanup(root); }
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('P4 — when the root HAS its own roadmap, the primary counters are the root\'s', () => {
|
|
142
|
+
const root = course({
|
|
143
|
+
rootRoadmap: { total: 5, done: 2, mvp: 3 },
|
|
144
|
+
subs: { '01-app': { total: 13, done: 4 } },
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
const roadmap = line(render(root).text, 'Roadmap');
|
|
148
|
+
assert.match(roadmap, /Done\s+2\/5/,
|
|
149
|
+
'the root\'s own 2/5 must be the headline, not the sub-project\'s 4/13: ' + roadmap);
|
|
150
|
+
// 2/3, not 0/3: the fixture's first two features are both done AND mvp. The code was right
|
|
151
|
+
// and the first version of this expectation was wrong.
|
|
152
|
+
assert.match(roadmap, /mvp\s+2\/3/, 'and its own mvp counter: ' + roadmap);
|
|
153
|
+
// Sub-projects are still surfaced — as a separate, labelled segment. Merging them into
|
|
154
|
+
// "Done x/y" would silently change what that fraction means.
|
|
155
|
+
assert.match(roadmap, /\+1 sub\s+13/,
|
|
156
|
+
'sub-projects must appear as their own segment: ' + roadmap);
|
|
157
|
+
assert.doesNotMatch(roadmap, /Done\s+6\/18/,
|
|
158
|
+
'totals must NOT be merged into the root\'s counters: ' + roadmap);
|
|
159
|
+
} finally { cleanup(root); }
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('P8 — several sub-projects, arbitrary names: the aggregate and the count are real', () => {
|
|
163
|
+
// Cross-family QE: every fixture was named '01-app', so the "N projects" branch had never been
|
|
164
|
+
// rendered and a survey that only recognised that one name would have passed the whole suite.
|
|
165
|
+
const root = course({ subs: { 'zeta-svc': { total: 4, done: 1 }, 'alpha-web': { total: 6, done: 2 } } });
|
|
166
|
+
try {
|
|
167
|
+
const roadmap = line(render(root).text, 'Roadmap');
|
|
168
|
+
assert.match(roadmap, /in\s+2 projects/,
|
|
169
|
+
'more than one sub-project must be reported as a count, not as one name: ' + roadmap);
|
|
170
|
+
assert.match(roadmap, /3\/10/,
|
|
171
|
+
'and the aggregate must be the real sum of both, 1+2 of 4+6: ' + roadmap);
|
|
172
|
+
} finally { cleanup(root); }
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('P9 — the survey is BOUNDED: it runs on every prompt', () => {
|
|
176
|
+
// An unbounded scan adds its cost to every keystroke. 40 entries against a limit of 24.
|
|
177
|
+
const subs = {};
|
|
178
|
+
for (let i = 0; i < 40; i++) subs['p' + String(i).padStart(2, '0')] = { total: 1 };
|
|
179
|
+
const root = course({ subs });
|
|
180
|
+
try {
|
|
181
|
+
const r = render(root);
|
|
182
|
+
assert.equal(r.code, 0, 'the status line must never fail the session');
|
|
183
|
+
const roadmap = line(r.text, 'Roadmap');
|
|
184
|
+
assert.match(roadmap, /in\s+24 projects/,
|
|
185
|
+
'the scan must stop at its stated limit rather than walking all 40: ' + roadmap);
|
|
186
|
+
} finally { cleanup(root); }
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('P5 — no roadmap anywhere: the legacy line is untouched', () => {
|
|
190
|
+
const root = course({});
|
|
191
|
+
try {
|
|
192
|
+
assert.match(line(render(root).text, 'Roadmap'),
|
|
193
|
+
/no roadmap yet \(run \/next or \/replicate\)/,
|
|
194
|
+
'the empty case must stay byte-identical — this feature adds a survey, it does not '
|
|
195
|
+
+ 'rewrite the line everyone already knows');
|
|
196
|
+
} finally { cleanup(root); }
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('P6 — a file named projects/x and a malformed roadmap are skipped, never thrown on', () => {
|
|
200
|
+
const root = course({ subs: { '01-app': { total: 3 }, '02-bad': 'malformed' } });
|
|
201
|
+
try {
|
|
202
|
+
fs.writeFileSync(path.join(root, 'projects', 'notadir'), 'x');
|
|
203
|
+
const r = render(root);
|
|
204
|
+
assert.equal(r.code, 0, 'the status line must never fail the session');
|
|
205
|
+
const roadmap = line(r.text, 'Roadmap');
|
|
206
|
+
assert.match(roadmap, /0\/3/,
|
|
207
|
+
'the good sub-project still counts; the bad ones are skipped: ' + roadmap);
|
|
208
|
+
} finally { cleanup(root); }
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('P7 — the toolkit questions name TOOLKIT_ROOT, not a cwd expression', () => {
|
|
212
|
+
// Guards the split itself: re-point either reader at a cwd and this goes red. Comments are
|
|
213
|
+
// stripped first — a mention in prose is not a use in code.
|
|
214
|
+
// Line-wise, not regex-wise: a /\*...\*/ stripper over this file removed 12 158 of its 18 153
|
|
215
|
+
// characters and lost every function name, so the assertions below would have been searching an
|
|
216
|
+
// empty string. Dropping comment LINES is boring, and it is right. A trailing same-line comment
|
|
217
|
+
// survives, which cannot realistically fake a whole path.join expression.
|
|
218
|
+
const src = fs.readFileSync(HOOK_SRC, 'utf-8').split('\n')
|
|
219
|
+
.filter((l) => !/^\s*(\/\/|\/\*|\*)/.test(l)).join('\n');
|
|
220
|
+
assert.doesNotMatch(src, /process\.cwd\(\)/, 'the anchor must not come from the drifting cwd');
|
|
221
|
+
const toolkit = src.slice(src.indexOf('function parseToolkit'),
|
|
222
|
+
src.indexOf('function parseExpectedToolkit'));
|
|
223
|
+
assert.match(toolkit, /TOOLKIT_ROOT/,
|
|
224
|
+
'parseToolkit must name the root it means, so re-pointing it is a visible change');
|
|
225
|
+
const settingsAt = src.indexOf('function parseSettingsStatus');
|
|
226
|
+
assert.match(src.slice(settingsAt, settingsAt + 400), /TOOLKIT_ROOT/,
|
|
227
|
+
'and so must parseSettingsStatus');
|
|
228
|
+
assert.match(src, /function parseSubProjects/,
|
|
229
|
+
'the survey is what replaces guessing which project is meant');
|
|
230
|
+
// Cross-family QE: a defined-but-never-called function satisfies the line above. The behavioural
|
|
231
|
+
// tests are the real proof; this makes the dead-code case red here too.
|
|
232
|
+
assert.ok(src.split('parseSubProjects').length - 1 >= 2,
|
|
233
|
+
'parseSubProjects must be CALLED, not merely defined');
|
|
234
|
+
assert.match(src, /SUB_SCAN_LIMIT/,
|
|
235
|
+
'the survey must carry its own bound — it renders on every prompt');
|
|
236
|
+
});
|
|
237
|
+
});
|