@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,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The ten shipped skills referenced each other by claude.ai paths (`/mnt/skills/user/<name>/`) and
|
|
4
|
+
// worked only because two rules files tell the model to rewrite them at read time. That is layer 4
|
|
5
|
+
// on the cost-of-detection ladder: probabilistic, and silent when it lapses. MEASURED before this
|
|
6
|
+
// change: 83 occurrences across 19 files.
|
|
7
|
+
//
|
|
8
|
+
// A blind transform would have been worse than the defect, in two separate ways:
|
|
9
|
+
//
|
|
10
|
+
// 1. Seven of those occurrences are the GENERATOR'S OWN instructions to scan its output for
|
|
11
|
+
// unrewritten paths, and more are the rewrite tables themselves. Rewriting a MENTION destroys
|
|
12
|
+
// the mechanism that keeps generated skills clean.
|
|
13
|
+
// 2. Two referenced skills — frontend-design, idea2prd-manual — are NOT shipped here. Rewriting
|
|
14
|
+
// them yields `.claude/skills/frontend-design/`: a path that looks local, looks valid, and
|
|
15
|
+
// resolves to nothing. Worse than a foreign path, which at least announces itself.
|
|
16
|
+
//
|
|
17
|
+
// So the guard is an ALLOWLIST OF EXACT LOCATIONS, not a count. A count is defeated by adding one
|
|
18
|
+
// violation and deleting one mention; naming the survivors is not — and P4 asserts the second
|
|
19
|
+
// direction, because losing the generator's self-check is the failure this exists to prevent.
|
|
20
|
+
|
|
21
|
+
const { test, describe } = require('node:test');
|
|
22
|
+
const assert = require('node:assert/strict');
|
|
23
|
+
const crypto = require('node:crypto');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
|
|
27
|
+
const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
28
|
+
const SKILLS = path.join(TPL, 'skills');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Files permitted to mention `/mnt/`, keyed by a fingerprint of THE MENTION LINES THEMSELVES.
|
|
32
|
+
*
|
|
33
|
+
* A per-file COUNT was the first version and cross-family review broke it in one move: in
|
|
34
|
+
* `03-generate-p0.md`, replace one rewrite-table row with an unrelated unrewritten path and the total
|
|
35
|
+
* stays 11, so the guard stays green over exactly the add/delete mutation the ADR says must fail.
|
|
36
|
+
* The fingerprint is over the sorted mention lines, so swapping one for another changes it.
|
|
37
|
+
*/
|
|
38
|
+
const ALLOWED = {
|
|
39
|
+
'pipeline-forge/SKILL.md': 'b2773e5b80c19885',
|
|
40
|
+
'pipeline-forge/references/patterns-catalog.md': '9025717e78f2ead3',
|
|
41
|
+
'pipeline-forge/references/self-extracted-patterns.md': '010eb3198d929b13',
|
|
42
|
+
'cc-toolkit-generator-enhanced/SKILL.md': 'd7c97e6b6cae4831',
|
|
43
|
+
'cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md': '7826a204d7214a22',
|
|
44
|
+
'cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md': 'c01aaa46eb670a1b',
|
|
45
|
+
'cc-toolkit-generator-enhanced/modules/01-detect-parse.md': '70a68e025bd84997',
|
|
46
|
+
'cc-toolkit-generator-enhanced/modules/03-generate-p0.md': '6410a1ea4107ee33',
|
|
47
|
+
'cc-toolkit-generator-enhanced/modules/04-generate-p1.md': '634066fcf5a485bf',
|
|
48
|
+
'cc-toolkit-generator-enhanced/modules/06-package-deliver.md': '12d0dd630ba3d992',
|
|
49
|
+
'cc-toolkit-generator-enhanced/modules/08-skill-composition.md': 'd9b68fbe2cf1dbfc',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The fingerprint of one file's mention lines — the identity the allowlist is keyed on. */
|
|
53
|
+
function mentionPrint(rel) {
|
|
54
|
+
const lines = fs.readFileSync(path.join(SKILLS, rel), 'utf-8').split('\n')
|
|
55
|
+
.filter((l) => l.includes('/mnt/')).map((l) => l.trim());
|
|
56
|
+
return crypto.createHash('sha256').update(lines.join('\n')).digest('hex').slice(0, 16);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every file under templates/.claude/skills, as repo-relative-to-SKILLS paths. */
|
|
60
|
+
function walk(dir, base) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
63
|
+
const abs = path.join(dir, e.name);
|
|
64
|
+
if (e.isDirectory()) out.push(...walk(abs, base));
|
|
65
|
+
else out.push(path.relative(base, abs).split(path.sep).join('/'));
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const countMnt = (rel) => {
|
|
71
|
+
const body = fs.readFileSync(path.join(SKILLS, rel), 'utf-8');
|
|
72
|
+
return (body.match(/\/mnt\//g) || []).length;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
describe('shipped skills resolve without a rewrite step', () => {
|
|
76
|
+
test('P1 - no unrewritten /mnt/ path outside the named allowlist', () => {
|
|
77
|
+
const offenders = [];
|
|
78
|
+
for (const rel of walk(SKILLS, SKILLS)) {
|
|
79
|
+
const n = countMnt(rel);
|
|
80
|
+
if (n > 0 && !(rel in ALLOWED)) offenders.push(rel + ' (' + n + ')');
|
|
81
|
+
}
|
|
82
|
+
assert.deepEqual(offenders, [],
|
|
83
|
+
'these files carry claude.ai paths and are not on the mention allowlist: '
|
|
84
|
+
+ JSON.stringify(offenders));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('P2 - every rewritten .claude/skills/<name>/ path resolves to something that exists', () => {
|
|
88
|
+
// NARROWED after the first version failed loudly and was right to: a `.claude/skills/<name>`
|
|
89
|
+
// target does NOT have to be a skill this package ships. Phase 3 GENERATES several into the
|
|
90
|
+
// user's project (`project-context`, `coding-standards`, `security-patterns` — named in
|
|
91
|
+
// replicate-pipeline.md), the rewrite tables carry placeholder names, and two skills are
|
|
92
|
+
// declared optional externals. What this feature created is the class under test: a path that
|
|
93
|
+
// REPLACED a /mnt/ reference to a skill shipped here.
|
|
94
|
+
const shipped = new Set(fs.readdirSync(SKILLS));
|
|
95
|
+
const GENERATED = new Set(['project-context', 'coding-standards', 'security-patterns',
|
|
96
|
+
'testing-patterns', 'aggregate-patterns', 'event-handlers', 'feature-navigator']);
|
|
97
|
+
const OPTIONAL_EXTERNAL = new Set(['frontend-design', 'idea2prd-manual']);
|
|
98
|
+
const bad = [];
|
|
99
|
+
for (const rel of walk(SKILLS, SKILLS)) {
|
|
100
|
+
const body = fs.readFileSync(path.join(SKILLS, rel), 'utf-8');
|
|
101
|
+
for (const line of body.split('\n')) {
|
|
102
|
+
// A line that shows BOTH sides of the mapping is describing the transform, not performing it.
|
|
103
|
+
if (line.includes('/mnt/')) continue;
|
|
104
|
+
const re = /\.claude\/skills\/([a-z0-9-]+)/g;
|
|
105
|
+
for (let m = re.exec(line); m !== null; m = re.exec(line)) {
|
|
106
|
+
const name = m[1];
|
|
107
|
+
if (shipped.has(name) || GENERATED.has(name) || OPTIONAL_EXTERNAL.has(name)) continue;
|
|
108
|
+
if (name === 'skills' || line.includes('{{') || line.includes('[name]')) continue;
|
|
109
|
+
bad.push(rel + ' → .claude/skills/' + name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
assert.deepEqual([...new Set(bad)], [],
|
|
114
|
+
'a path points at a skill that is neither shipped, nor Phase-3 generated, nor a declared '
|
|
115
|
+
+ 'optional external: ' + JSON.stringify([...new Set(bad)]));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('P3 - absent skills are declared optional, not rewritten', () => {
|
|
119
|
+
// frontend-design is referenced but not shipped. It must NOT have become a local-looking path.
|
|
120
|
+
const cjm = fs.readFileSync(
|
|
121
|
+
path.join(SKILLS, 'reverse-engineering-unicorn', 'modules', '025-cjm-prototype.md'), 'utf-8');
|
|
122
|
+
assert.match(cjm, /`frontend-design` — OPTIONAL, ВНЕШНИЙ/,
|
|
123
|
+
'an absent dependency must be declared optional');
|
|
124
|
+
assert.match(cjm, /\*\*Fallback, если его нет:\*\*/,
|
|
125
|
+
'and carry a fallback — the shipped skill-interface-protocol §6 requires one for every OPTIONAL');
|
|
126
|
+
assert.match(cjm, /Молча пропускать нельзя/,
|
|
127
|
+
'and must refuse the silent-skip reading');
|
|
128
|
+
assert.ok(!/\.claude\/skills\/frontend-design\/SKILL\.md/.test(cjm),
|
|
129
|
+
'it must not be given a local path at all — that is the dangling-path failure this avoids');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('P4 - each allowlisted mention still exists, by identity not by count', () => {
|
|
133
|
+
// Both directions, and neither expressible as a total. A NEW unrewritten path changes the
|
|
134
|
+
// fingerprint; so does a DELETED self-check — and losing the generator's `grep -r /mnt/` would
|
|
135
|
+
// silently stop it noticing unrewritten paths in the skills it GENERATES.
|
|
136
|
+
const drift = [];
|
|
137
|
+
for (const [rel, expected] of Object.entries(ALLOWED)) {
|
|
138
|
+
const abs = path.join(SKILLS, rel);
|
|
139
|
+
if (!fs.existsSync(abs)) { drift.push(rel + ' (file gone)'); continue; }
|
|
140
|
+
const got = mentionPrint(rel);
|
|
141
|
+
if (got !== expected) drift.push(rel + ' expected ' + expected + ', got ' + got);
|
|
142
|
+
}
|
|
143
|
+
assert.deepEqual(drift, [],
|
|
144
|
+
'a mention changed: a new unrewritten path, a lost self-check, or one swapped for the other '
|
|
145
|
+
+ '— a per-file count could not tell these apart: ' + JSON.stringify(drift));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('P5 - the guard is an allowlist of locations, not a count', () => {
|
|
149
|
+
// Asserted on the guard itself. A total-count assertion passes after someone adds one violation
|
|
150
|
+
// and removes one mention, which is exactly the edit this feature makes plausible.
|
|
151
|
+
const self = fs.readFileSync(__filename, 'utf-8');
|
|
152
|
+
assert.match(self, /const ALLOWED = \{/, 'the guard must name locations');
|
|
153
|
+
assert.ok(Object.keys(ALLOWED).length >= 5, 'and enumerate them all');
|
|
154
|
+
// Keyed by fingerprint, not count — the property cross-family review found missing.
|
|
155
|
+
assert.match(self, /function mentionPrint/, 'identity, not arithmetic');
|
|
156
|
+
for (const v of Object.values(ALLOWED)) {
|
|
157
|
+
assert.match(String(v), /^[0-9a-f]{16}$/, 'every entry must be a fingerprint, not a number');
|
|
158
|
+
}
|
|
159
|
+
// A count over the whole tree would be a single number; the allowlist is per-file, and P1 keys
|
|
160
|
+
// on membership rather than on any total.
|
|
161
|
+
assert.match(self, /!\(rel in ALLOWED\)/,
|
|
162
|
+
'P1 must decide by membership, not by comparing a sum');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('P6 - the rewrite rules survive, for skills a user brings from claude.ai', () => {
|
|
166
|
+
// The fix removes the shipped skills' DEPENDENCE on the table, not the table. A user may still
|
|
167
|
+
// install a skill written against /mnt/ paths, and then the rule is what makes it work.
|
|
168
|
+
const proto = fs.readFileSync(path.join(TPL, 'rules', 'skill-interface-protocol.md'), 'utf-8');
|
|
169
|
+
assert.match(proto, /\/mnt\/skills\/user\/\[name\]\/`? \| `\.claude\/skills\/\[name\]\/`/,
|
|
170
|
+
'the rewrite table must remain');
|
|
171
|
+
const pipeline = fs.readFileSync(path.join(TPL, 'rules', 'replicate-pipeline.md'), 'utf-8');
|
|
172
|
+
assert.match(pipeline, /\/mnt\/skills\/user\//, 'and the pipeline rule must keep its copy');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// sparc-prd-mini — Phase 5 must reconcile with Phase 4's data model before its checkpoint.
|
|
4
|
+
//
|
|
5
|
+
// The skill runs strictly linearly: Phase 4 PSEUDOCODE authors `## Data Structures` — entity types
|
|
6
|
+
// with concrete field types — and only then does Phase 5 ARCHITECTURE choose the storage and
|
|
7
|
+
// technology those structures have to live in. A sweep from Phase 5 to end-of-file found ZERO
|
|
8
|
+
// re-reads of Pseudocode.md, so nothing ever noticed the two documents disagreeing. One ordering
|
|
9
|
+
// fact explained three separately-reported field symptoms: a boolean where the schema took an enum,
|
|
10
|
+
// an algorithm using a column the schema lacks, and a status with three values on one side and five
|
|
11
|
+
// on the other.
|
|
12
|
+
//
|
|
13
|
+
// The fix is a RECONCILIATION step, deliberately NOT a reorder: SPARC is an acronym — Specification,
|
|
14
|
+
// Pseudocode, Architecture, Refinement, Completion — and the skill is named `sparc-prd-mini`.
|
|
15
|
+
// Swapping P and A would leave the file claiming a methodology it no longer follows. P5 below guards
|
|
16
|
+
// that refused decision, so a later edit cannot quietly take it.
|
|
17
|
+
//
|
|
18
|
+
// These are PROMPT modules executed by a model, so the strongest deterministic layer available is
|
|
19
|
+
// the file's own content. Each assertion is DISCRIMINATING: removing what it names turns it 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 SKILL = path.join(
|
|
27
|
+
__dirname, '..', '..', 'templates', '.claude', 'skills', 'sparc-prd-mini', 'SKILL.md',
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
function read() {
|
|
31
|
+
return fs.readFileSync(SKILL, 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The text between the Phase 5 heading and the Phase 6 heading — placement is the property. */
|
|
35
|
+
function phase5(src) {
|
|
36
|
+
const start = src.indexOf('### Phase 5: ARCHITECTURE');
|
|
37
|
+
const end = src.indexOf('### Phase 6: REFINEMENT');
|
|
38
|
+
assert.ok(start > 0, 'Phase 5 heading must exist');
|
|
39
|
+
assert.ok(end > start, 'Phase 6 heading must follow Phase 5');
|
|
40
|
+
return src.slice(start, end);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('sparc-prd-mini — Phase 5 reconciles with Pseudocode (PR-005/PR-010)', () => {
|
|
44
|
+
test('P1 — the reconciliation step lives INSIDE Phase 5, before its checkpoint', () => {
|
|
45
|
+
const block = phase5(read());
|
|
46
|
+
assert.match(block, /СВЕРКА С ПСЕВДОКОДОМ/,
|
|
47
|
+
'the step must be inside Phase 5 — somewhere in the file is not the same property');
|
|
48
|
+
const step = block.indexOf('СВЕРКА С ПСЕВДОКОДОМ');
|
|
49
|
+
const checkpoint = block.indexOf('CHECKPOINT 5');
|
|
50
|
+
assert.ok(checkpoint > 0, 'Phase 5 must still carry its checkpoint');
|
|
51
|
+
assert.ok(step < checkpoint,
|
|
52
|
+
'reconciling AFTER the user has already approved the phase reconciles nothing');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('P1b — the step is OUTSIDE the [MANUAL] CP5 block, so AUTO mode cannot skip it', () => {
|
|
56
|
+
// Cross-family QE (Codex gpt-5.6-sol) caught this: the first placement landed the step INSIDE
|
|
57
|
+
// the fenced `**[MANUAL] CP5:**` block, which AUTO mode is entitled to skip — a mandatory
|
|
58
|
+
// reconciliation that only happens in one of two run modes is not mandatory. P1's before/after
|
|
59
|
+
// ordering was true and still missed it, because "before the checkpoint text" and "outside the
|
|
60
|
+
// MANUAL-only block" are two different facts.
|
|
61
|
+
const block = phase5(read());
|
|
62
|
+
const step = block.indexOf('СВЕРКА С ПСЕВДОКОДОМ');
|
|
63
|
+
const manual = block.indexOf('**[MANUAL] CP5:**');
|
|
64
|
+
assert.ok(manual > 0, 'the MANUAL checkpoint marker must still exist');
|
|
65
|
+
assert.ok(step < manual,
|
|
66
|
+
'the step must precede the [MANUAL] marker — inside it, an AUTO run reconciles nothing');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('P2 — it names BOTH artifacts to re-read, not "check for consistency"', () => {
|
|
70
|
+
const block = phase5(read());
|
|
71
|
+
assert.match(block, /Pseudocode\.md/, 'the step must name the file it re-reads');
|
|
72
|
+
assert.match(block, /## Data Structures/, 'and the data-model section within it');
|
|
73
|
+
// Codex: "an algorithm uses a missing column" cannot be detected from the type list alone.
|
|
74
|
+
assert.match(block, /## Core Algorithms/,
|
|
75
|
+
'the missing-column kind is undetectable without the algorithms — naming only the data '
|
|
76
|
+
+ 'structures would promise a check the step cannot perform');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('P3 — all three measured discrepancy kinds are named verbatim', () => {
|
|
80
|
+
const block = phase5(read());
|
|
81
|
+
// Each of these is a real symptom that reached code before this step existed. A generic
|
|
82
|
+
// "look for inconsistencies" would satisfy a reviewer and catch none of them.
|
|
83
|
+
// Checked in BOLD form, i.e. where the kinds are DEFINED. A first draft matched the bare
|
|
84
|
+
// phrase and did not discriminate: the same words also appear inside the example table row, so
|
|
85
|
+
// deleting a kind from the definition list left the assertion green. A phrase appearing in an
|
|
86
|
+
// example is not the same fact as a phrase defining a kind — the fifth instance of that trap
|
|
87
|
+
// in one day, so it is closed by construction here.
|
|
88
|
+
for (const kind of ['смена типа', 'отсутствующая колонка', 'несовпадение набора значений']) {
|
|
89
|
+
assert.ok(block.includes(`**${kind}**`),
|
|
90
|
+
`discrepancy kind must be DEFINED (bold) in the step, not merely mentioned: ${kind}`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('P4 — a written outcome is required in EVERY case, including no-change', () => {
|
|
95
|
+
const block = phase5(read());
|
|
96
|
+
assert.match(block, /## Reconciliation with Pseudocode/,
|
|
97
|
+
'the step must name the block it writes into Architecture.md');
|
|
98
|
+
assert.match(block, /Расхождений с/,
|
|
99
|
+
'the no-change case needs its own stated sentence — silence cannot distinguish '
|
|
100
|
+
+ '"reconciled and clean" from "never reconciled"');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('P5 — SPARC order is unchanged: the refused decision stays refused', () => {
|
|
104
|
+
const src = read();
|
|
105
|
+
const order = ['### Phase 3: SPECIFICATION', '### Phase 4: PSEUDOCODE',
|
|
106
|
+
'### Phase 5: ARCHITECTURE', '### Phase 6: REFINEMENT', '### Phase 7: COMPLETION'];
|
|
107
|
+
const at = order.map((h) => {
|
|
108
|
+
const i = src.indexOf(h);
|
|
109
|
+
assert.ok(i > 0, `heading must exist: ${h}`);
|
|
110
|
+
return i;
|
|
111
|
+
});
|
|
112
|
+
for (let i = 1; i < at.length; i += 1) {
|
|
113
|
+
assert.ok(at[i] > at[i - 1],
|
|
114
|
+
`phases must stay in S-P-A-R-C order — the acronym IS the method's name; ${order[i]} moved`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -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
|
+
});
|