@dzhechkov/p-replicator 1.5.17 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.dz-manifest.json +173 -65
  2. package/CHANGELOG.md +135 -0
  3. package/MULTIPLATFORM_ROADMAP.md +1 -1
  4. package/README/eng/01_quickstart.md +2 -2
  5. package/README/eng/02_user_guide.md +1 -1
  6. package/README/eng/03_admin_guide.md +2 -2
  7. package/README/eng/05_architecture.md +6 -2
  8. package/README/eng/README.md +1 -1
  9. package/README/ru/01_quickstart.md +2 -2
  10. package/README/ru/02_user_guide.md +1 -1
  11. package/README/ru/03_admin_guide.md +2 -2
  12. package/README/ru/05_architecture.md +1 -1
  13. package/README/ru/README.md +1 -1
  14. package/README/ru/html/index.html +7 -7
  15. package/README.md +41 -18
  16. package/bin/cli.js +0 -0
  17. package/package.json +11 -10
  18. package/sbom.json +347 -77
  19. package/src/utils.js +2 -0
  20. package/templates/.claude/agents/doc-validator.md +2 -1
  21. package/templates/.claude/agents/product-discoverer.md +1 -1
  22. package/templates/.claude/commands/next.md +16 -0
  23. package/templates/.claude/commands/replicate.md +126 -8
  24. package/templates/.claude/commands/start.md +19 -1
  25. package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
  26. package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
  27. package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
  28. package/templates/.claude/hooks/check-ports.cjs +232 -0
  29. package/templates/.claude/hooks/session-insights.cjs +13 -1
  30. package/templates/.claude/hooks/state-update.cjs +13 -1
  31. package/templates/.claude/hooks/statusline.cjs +145 -18
  32. package/templates/.claude/rules/docker-ports.md +123 -0
  33. package/templates/.claude/rules/replicate-pipeline.md +5 -2
  34. package/templates/.claude/settings.json +5 -5
  35. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
  36. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
  37. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
  38. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
  39. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
  40. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
  41. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
  42. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
  43. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
  44. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
  45. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
  46. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
  47. package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
  48. package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
  49. package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
  50. package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
  51. package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
  52. package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
  53. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
  54. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
  55. package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
  56. package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
  57. package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
  58. package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
  59. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
  60. package/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
  61. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
  62. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
  63. package/tests/e2e/lifecycle.test.js +21 -10
  64. package/tests/snapshot/baseline.json +51 -34
  65. package/tests/snapshot/update-baseline.js +2 -1
  66. package/tests/unit/adr-decision-coverage.test.js +137 -0
  67. package/tests/unit/adr-scanner-contract.test.js +108 -0
  68. package/tests/unit/autocommit-deletion.test.js +242 -0
  69. package/tests/unit/check-ports.test.js +184 -0
  70. package/tests/unit/db-port-rule.test.js +216 -0
  71. package/tests/unit/detect-parse-anchor.test.js +109 -0
  72. package/tests/unit/external-dependency-check.test.js +209 -0
  73. package/tests/unit/growth-module-b2b-gate.test.js +104 -0
  74. package/tests/unit/hooks-project-anchored.test.js +223 -0
  75. package/tests/unit/hooks-report-failures.test.js +207 -0
  76. package/tests/unit/pipeline-file-ownership.test.js +95 -0
  77. package/tests/unit/roadmap-one-schema.test.js +179 -0
  78. package/tests/unit/sparc-reconciliation.test.js +117 -0
  79. package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
  80. package/tests/unit/statusline-honest-labels.test.js +178 -0
  81. package/tests/unit/statusline-two-roots.test.js +237 -0
  82. package/tests/unit/sync-templates-guard.test.js +209 -0
  83. package/tests/unit/utils.test.js +2 -2
  84. package/tests/unit/validation-gate-teeth.test.js +158 -0
  85. package/LICENSE +0 -21
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ // One file, two canonical schemas. MEASURED: commands/next.md:84-96 documents feature-roadmap.json
4
+ // with a `priority` field over the closed set mvp|high|medium|low, while
5
+ // skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md names the SAME
6
+ // path ("Generate as .claude/feature-roadmap.json") and documents a shape with NO priority at all —
7
+ // and calls itself the single source of truth while being one of two. A generator following the
8
+ // second produces a roadmap the package's own status line cannot read.
9
+ //
10
+ // The consumer was silent about it: statusline.cjs filters `f.priority === 'mvp'`, which is correct
11
+ // against the schema, so a roadmap using `critical` — a value in NO documented enum — or carrying MVP
12
+ // in `tags` rendered "mvp 0/0" and said nothing. The number was right and the reader learned nothing,
13
+ // which is how a divergence becomes permanent.
14
+ //
15
+ // NOT DONE, deliberately: `tags.includes('mvp')` as a "fix". It would bless a document that also used
16
+ // a value in no enum, and teach readers to accept whatever a generator emits. The counter matches the
17
+ // specification; the documents diverged.
18
+ //
19
+ // Also REFUTED while measuring: the report's second defect, a supposed contradiction about who
20
+ // creates the file. feature.md:44 sits under "### What does NOT happen in Mode 2" — it is scoped to
21
+ // the mode where /replicate never runs. Both statements are true in their own mode; what was missing
22
+ // is one place saying both, which P3 now requires.
23
+
24
+ const { test, describe } = require('node:test');
25
+ const assert = require('node:assert/strict');
26
+ const { execFileSync } = require('node:child_process');
27
+ const fs = require('node:fs');
28
+ const os = require('node:os');
29
+ const path = require('node:path');
30
+
31
+ const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
32
+ const read = (rel) => fs.readFileSync(path.join(TPL, rel), 'utf-8');
33
+
34
+ const NEXT = 'commands/next.md';
35
+ const SUGGESTIONS = 'skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md';
36
+
37
+ const STRIP = new RegExp('\\x1b\\[[0-9;]*m', 'g');
38
+
39
+ /** Run the real status line over a real roadmap and return its plain-text Roadmap line. */
40
+ function roadmapLine(features) {
41
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-schema-')));
42
+ try {
43
+ fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true });
44
+ fs.copyFileSync(path.join(TPL, 'hooks', 'statusline.cjs'),
45
+ path.join(root, '.claude', 'hooks', 'statusline.cjs'));
46
+ fs.writeFileSync(path.join(root, '.claude', 'feature-roadmap.json'),
47
+ JSON.stringify({ version: '1.0', features }));
48
+ let code = 0;
49
+ let out = '';
50
+ try {
51
+ out = execFileSync(process.execPath, [path.join(root, '.claude', 'hooks', 'statusline.cjs')],
52
+ { cwd: root, env: Object.assign({}, process.env, { CLAUDE_PROJECT_DIR: root }),
53
+ encoding: 'utf8', stdio: 'pipe' }) || '';
54
+ } catch (err) {
55
+ code = err.status == null ? 1 : err.status;
56
+ out = err.stdout ? err.stdout.toString() : '';
57
+ }
58
+ const text = out.replace(STRIP, '');
59
+ return { code, line: text.split('\n').find((l) => l.includes('Roadmap')) || '' };
60
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
61
+ }
62
+
63
+ describe('feature-roadmap.json has ONE schema, and the reader says when a file misses it', () => {
64
+ test('P1 — next.md declares itself canonical and closes the priority set', () => {
65
+ const src = read(NEXT);
66
+ assert.match(src, /\*\*This table is THE schema for `\.claude\/feature-roadmap\.json`\.\*\*/,
67
+ 'the schema must claim the role explicitly, or a second document can claim it too');
68
+ assert.match(src, /any other\s+document that shows the file's fields must point here/,
69
+ 'and say what other documents must do instead of restating it');
70
+ assert.match(src, /`priority` is a CLOSED set — `mvp`, `high`, `medium`, `low`/,
71
+ 'the enum must be stated as closed, in the canonical place');
72
+ });
73
+
74
+ test('P2 — feature-suggestions.md points at the schema instead of restating one', () => {
75
+ const src = read(SUGGESTIONS);
76
+ assert.match(src, /follow `\.claude\/commands\/next\.md`, which holds the single schema/,
77
+ 'the second document must defer, not describe');
78
+ // Cross-family QE: the first version was `!A || B`, which the new "single data file" phrase
79
+ // makes true whether or not the stale claim survives. The claim's ABSENCE is the property —
80
+ // but SCOPED: a later CLAUDE.md snippet in this same file calls the roadmap the source of truth
81
+ // for feature STATUS, which is both true and unrelated. Asserting over the whole file would have
82
+ // forbidden a correct sentence. The claim that mattered was the architecture line at the top.
83
+ const header = src.slice(0, src.indexOf('## 2.'));
84
+ assert.ok(!/single source of truth/.test(header),
85
+ 'the schema-level claim must be gone from the architecture line, not merely accompanied');
86
+ assert.match(header, /single data file/, 'replaced by what it actually is');
87
+ // Its own JSON sketch must not teach the incompatible shape either: an example is what a
88
+ // generator copies, and an example without `priority` is a second schema in disguise.
89
+ const sketch = src.slice(src.indexOf('"features": ['), src.indexOf('```', src.indexOf('"features": [')));
90
+ assert.match(sketch, /"priority": "mvp\|high\|medium\|low"/,
91
+ 'the example must carry the canonical priority field: ' + sketch.slice(0, 200));
92
+ });
93
+
94
+ test('P3 — one place answers who creates the file, for BOTH modes', () => {
95
+ // The report called this a contradiction. It is not: feature.md's claim is scoped to Mode 2.
96
+ // What was missing is a single answer, so a reader landing on one half does not conclude the
97
+ // other is wrong.
98
+ const src = read(NEXT);
99
+ assert.match(src, /Who creates the file/, 'the question must be answered where the schema lives');
100
+ assert.match(src, /Phase 3 generates it from the PRD MVP scope/, 'the /replicate half');
101
+ assert.match(src, /the user\s+writes it by hand/, 'and the Mode 2 half');
102
+ });
103
+
104
+ test('P4 — a priority outside the enum is MARKED, not silently zero', () => {
105
+ const r = roadmapLine([
106
+ { id: 'a', status: 'next', priority: 'critical' },
107
+ { id: 'b', status: 'done', priority: 'critical' },
108
+ ]);
109
+ assert.equal(r.code, 0, 'the status line must never fail the session');
110
+ assert.match(r.line, /⚠2 schema/,
111
+ 'an unreadable roadmap must say so, and say HOW MUCH: ' + r.line);
112
+ // Cross-family QE: the first version replaced the count with '?', discarding what WAS
113
+ // established. The known count is still knowledge; show both.
114
+ assert.match(r.line, /mvp 0\/0 ⚠2/,
115
+ 'the known count must survive beside the warning: ' + r.line);
116
+ });
117
+
118
+ test('P5 — MVP moved into tags, no priority at all: also MARKED', () => {
119
+ // The exact shape the field report was collected on.
120
+ const r = roadmapLine([
121
+ { id: 'a', status: 'next', tags: ['mvp'] },
122
+ { id: 'b', status: 'next', tags: ['mvp'] },
123
+ ]);
124
+ assert.equal(r.code, 0, 'the status line must never fail the session');
125
+ assert.match(r.line, /mvp 0\/0 ⚠2 schema/,
126
+ 'a roadmap with no priority field at all must be marked, with its count: ' + r.line);
127
+ });
128
+
129
+ test('P8 — PARTIAL population is caught: one valid priority beside a broken sibling', () => {
130
+ // Cross-family QE named this false negative exactly: the first detector only inspected features
131
+ // that already HAD a string priority, so a valid entry standing next to a missing or non-string
132
+ // one passed silently — and a half-migrated roadmap is the most likely real one.
133
+ const r = roadmapLine([
134
+ { id: 'a', status: 'done', priority: 'mvp' },
135
+ { id: 'b', status: 'next' }, // missing entirely
136
+ { id: 'c', status: 'next', priority: 3 }, // present, not a string
137
+ ]);
138
+ assert.equal(r.code, 0);
139
+ assert.match(r.line, /⚠2 schema/,
140
+ 'both broken siblings must be counted, not hidden by the valid one: ' + r.line);
141
+ assert.match(r.line, /mvp 1\/1/,
142
+ 'and what IS known must still be reported: ' + r.line);
143
+ });
144
+
145
+ test('P6 — a valid roadmap renders exactly as before, with no marker', () => {
146
+ // The guard on over-marking: a rule that fires on good input is worse than no rule.
147
+ const r = roadmapLine([
148
+ { id: 'a', status: 'done', priority: 'mvp' },
149
+ { id: 'b', status: 'next', priority: 'mvp' },
150
+ { id: 'c', status: 'next', priority: 'low' },
151
+ ]);
152
+ assert.equal(r.code, 0);
153
+ assert.match(r.line, /mvp 1\/2/, 'the real counts must still render: ' + r.line);
154
+ assert.doesNotMatch(r.line, /schema/, 'and no marker on a conforming file: ' + r.line);
155
+
156
+ // An EMPTY roadmap has nothing to be off-schema about; marking it would be a false alarm in the
157
+ // most common state of a fresh project.
158
+ const empty = roadmapLine([]);
159
+ assert.equal(empty.code, 0);
160
+ assert.doesNotMatch(empty.line, /schema/, 'an empty roadmap must not be marked: ' + empty.line);
161
+ });
162
+
163
+ test('P7 — every case exits 0: the marker informs, it never blocks', () => {
164
+ for (const features of [
165
+ [{ id: 'a', status: 'next', priority: 'critical' }],
166
+ [{ id: 'a', status: 'next', tags: ['mvp'] }],
167
+ [{ id: 'a', status: 'next', priority: 'mvp' }],
168
+ [],
169
+ ]) {
170
+ const r = roadmapLine(features);
171
+ assert.equal(r.code, 0,
172
+ 'the status line is advisory and must stay non-blocking: ' + JSON.stringify(features));
173
+ // Cross-family QE: exit 0 alone is satisfied by a program that prints nothing at all. The
174
+ // line must actually be rendered in every case.
175
+ assert.notEqual(r.line, '',
176
+ 'and must still RENDER a roadmap line: ' + JSON.stringify(features));
177
+ }
178
+ });
179
+ });
@@ -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
+ });