@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,216 @@
1
+ 'use strict';
2
+
3
+ // Nothing in this package said a database port is not published to the internet. MEASURED: the
4
+ // Architecture Constraints handed to every project (templates/.claude/rules/replicate-pipeline.md)
5
+ // carried exactly five items — pattern, containers, infrastructure, deploy, MCP — and nothing about
6
+ // storage; a sweep of templates/ for such a rule returned only unrelated hits.
7
+ //
8
+ // The working check already exists, in a course repository, and when it fails it prints
9
+ // "Правило: .claude/rules/docker-ports.md, «Правило №0»" — pointing at a file this package did not
10
+ // ship. The rule and the check are two different things, and only the rule ships here — ADR-001 D2,
11
+ // whose load-bearing reasons are that a shipped script is a new component KIND touching five
12
+ // consumers, and that a Node rewrite of a bash+awk compose parser is real work, not a transcription.
13
+ //
14
+ // P6 is the load-bearing one for the future: "5 rules" was a constant maintained BY HAND in seven
15
+ // places, and missing one leaves doctor or the status line reporting a rule missing that is not.
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 PKG_DIR = path.resolve(__dirname, '..', '..');
23
+ const read = (rel) => fs.readFileSync(path.join(PKG_DIR, rel), 'utf-8');
24
+
25
+ const RULE = path.join('templates', '.claude', 'rules', 'docker-ports.md');
26
+ const PIPELINE = path.join('templates', '.claude', 'rules', 'replicate-pipeline.md');
27
+
28
+ /** The Architecture Constraints block — the list actually handed to sparc-prd-mini. Scoped, because
29
+ * a constraint stated anywhere else in the file is not passed to anything. */
30
+ function constraints() {
31
+ const src = read(PIPELINE);
32
+ const start = src.indexOf('## Architecture Constraints');
33
+ const end = src.indexOf('\n## ', start + 1);
34
+ assert.ok(start > 0, 'replicate-pipeline.md must have an Architecture Constraints block');
35
+ assert.ok(end > start, 'and a following section');
36
+ return src.slice(start, end);
37
+ }
38
+
39
+ describe('a database port is not published to the internet (PR-012)', () => {
40
+ test('P1 — the constraints block carries the rule and names the file', () => {
41
+ const block = constraints();
42
+ // The INVARIANT, not one remedy. Cross-family QE: "does not publish outward" is too narrow —
43
+ // an explicit public address like 203.0.113.7:5432:5432 is not "all interfaces" and is exactly
44
+ // as reachable from the internet; and network_mode: host needs no ports: entry at all.
45
+ assert.match(block, /Storage: у баз и очередей НЕТ публикации на хост/,
46
+ 'the constraint must be IN the block that is passed to Phase 1, not elsewhere in the file');
47
+ assert.match(block, /кроме привязки к петле/, 'and carry the loopback exception');
48
+ assert.match(block, /network_mode: host/,
49
+ 'and name the escape path that needs no ports: entry at all');
50
+ assert.match(block, /docker-ports\.md/,
51
+ 'and name the rule file, so the constraint and the rule are one thing rather than two');
52
+ });
53
+
54
+ test('P2 — the rule file exists and carries the name the shipped check prints', () => {
55
+ // The check prints: Правило: .claude/rules/docker-ports.md, «Правило №0».
56
+ // If the heading is renamed, that pointer stops resolving and the reader is left at a dead end.
57
+ const src = read(RULE);
58
+ assert.match(src, /Правило №0/,
59
+ 'the rule must carry the exact name the existing check refers people to');
60
+ // The forbidden list is the security content: each row is a way a reader gets it wrong.
61
+ for (const bad of ['0.0.0.0:5432:5432', '\\[::\\]:5432:5432', '203.0.113.7:5432:5432',
62
+ 'network_mode: host']) {
63
+ assert.match(src, new RegExp(bad), 'the forbidden list must name: ' + bad);
64
+ }
65
+ assert.match(src, /убрать `ports:` целиком/,
66
+ 'the remedy must be stated as removing publication, not as adding expose:');
67
+ assert.match(src, /`expose:` в современном Compose ничего не открывает/,
68
+ 'and expose: must be described honestly — it documents, it does not grant access');
69
+ assert.match(src, /достучатся до\s+сервиса по его имени/,
70
+ 'and why the remedy suffices — otherwise it reads as a loss of capability');
71
+ });
72
+
73
+ test('P3 — the LOOPBACK exception is present, as an allowance and not a footnote', () => {
74
+ // Without it the rule forbids exactly what the same pipeline prescribes: a test environment that
75
+ // binds 127.0.0.1 so tests can run from the host. The field report's own pseudocode had this
76
+ // defect and would have failed the project's own compose.
77
+ const src = read(RULE);
78
+ assert.match(src, /Исключение: петля — это законно/,
79
+ 'the exception needs its own heading — a rule whose exception is buried gets applied without it');
80
+ assert.match(src, /127\.0\.0\.1:55432:5432/, 'shown as a concrete legal line');
81
+ assert.match(src, /ЗАКОННО/, 'and labelled as permitted, not merely described');
82
+ // Both loopback forms are equals — a rule that allows ::1 while its own self-check greps only
83
+ // for 127.0.0.1 contradicts itself, which is what the first version did.
84
+ assert.match(src, /Петлевых форм две и они равноправны/,
85
+ 'both loopback forms must be named as equals');
86
+ assert.match(src, /host_ip: ::1/,
87
+ 'and the self-check must accept the IPv6 one it allows');
88
+ assert.match(src, /исключение\s*\n?такая же часть правила, как и запрет/,
89
+ 'and its standing stated, so a later edit does not trim it as an aside');
90
+ });
91
+
92
+ test('P4 — the rule says what it is NOT, so nobody infers a guarantee', () => {
93
+ // A rule that reads like a gate is worse than no rule: it stops people looking for the gate.
94
+ const src = read(RULE);
95
+ // The property has MOVED, not weakened. When this test was written the package shipped the rule
96
+ // and no check, so the limit was "nobody may infer a guarantee". A check now ships — so the
97
+ // limit becomes what the check does NOT cover, and the fact that it must be invoked. A rule that
98
+ // reads like an automatic gate is still worse than no rule; only the shape of the honesty moved.
99
+ assert.match(src, /Чем это правило НЕ является|Чего она НЕ делает/,
100
+ 'the limit needs its own heading');
101
+ assert.match(src, /не привязана ни к какому событию — её надо позвать/,
102
+ 'a check nobody invokes guarantees nothing, and the rule must say so');
103
+ assert.match(src, /конфликты с портами, уже занятыми другими контейнерами, она не ищет/,
104
+ 'and name what it does not look at, so nobody infers a wider guarantee');
105
+ assert.match(src, /проверка НЕ\s*\n?ВЫПОЛНЕНА/,
106
+ 'and the third exit code must be documented where a reader will look for it');
107
+ });
108
+
109
+ test('P5 — the field report\'s non-verbatim compose fragment is not quoted', () => {
110
+ // The report quotes a postgres service carrying an `expose:` line. MEASURED: the real file has
111
+ // no such line. Copying it would put a fabricated quotation in a security rule.
112
+ const src = read(RULE);
113
+ assert.ok(!/image: postgres[\s\S]{0,200}expose:[\s\S]{0,40}5432[\s\S]{0,40}ports:/.test(src),
114
+ 'the rule must not reproduce the report\'s composite fragment');
115
+ });
116
+
117
+ test('P6 — every count site agrees with COMPONENTS.rules.items', () => {
118
+ // The property is AGREEMENT, not a particular number: the next rule added moves all seven or
119
+ // this fails. statusline.cjs is a TEMPLATE copied into other projects and cannot import src/,
120
+ // and the four prose sites are prose, so the constant is irreducibly distributed — what is
121
+ // removed here is the SILENCE when the sites disagree.
122
+ const utils = require(path.join(PKG_DIR, 'src', 'utils.js'));
123
+ const n = Object.keys(utils.COMPONENTS.rules.items).length;
124
+ assert.ok(n >= 6, 'the docker-ports rule must be registered as a component: got ' + n);
125
+
126
+ const statusline = read(path.join('templates', '.claude', 'hooks', 'statusline.cjs'));
127
+ const m = statusline.match(/rulesExpected:\s*(\d+)/);
128
+ assert.ok(m, 'statusline.cjs must declare rulesExpected');
129
+ assert.equal(Number(m[1]), n,
130
+ 'statusline would report a phantom missing rule: rulesExpected=' + m[1] + ' vs ' + n);
131
+
132
+ // SCAN, do not enumerate. The first version listed six prose sites by hand and MISSED SEVEN more,
133
+ // including templates/.claude/commands/replicate.md — the file that SHIPS INTO EVERY GENERATED
134
+ // PROJECT and names the rules a run must not overwrite. `docker-ports` was absent from that list,
135
+ // so the security rule this feature added could be silently regenerated away. Two independent
136
+ // reviewers found it; a hand-picked list is how it survived my own review.
137
+ //
138
+ // Historical documents are exempt BY NAME: a changelog entry describing what 1.4 shipped is a
139
+ // true statement about the past, and rewriting it would be falsifying a record.
140
+ const HISTORICAL = /changelog/i;
141
+ const COUNT_CLAIM = /(\d+)\s+rules\b|(\d+)\s+правил|Rules\s+●(\d+)|\*\*(\d+) rules\*\*/g;
142
+ const scanned = [];
143
+ const walk = (dir) => {
144
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
145
+ const full = path.join(dir, e.name);
146
+ if (e.isDirectory()) {
147
+ if (e.name === 'node_modules' || e.name === '.git') continue;
148
+ walk(full);
149
+ } else if (/\.(md|html)$/.test(e.name) && !HISTORICAL.test(full)) {
150
+ scanned.push(full);
151
+ }
152
+ }
153
+ };
154
+ walk(PKG_DIR);
155
+ assert.ok(scanned.length > 10, 'the scan must actually find documents: ' + scanned.length);
156
+
157
+ // `claimed`, not `n`. A rename made the inner variable shadow the authoritative one and the
158
+ // comparison became `n !== n` — a test that could never fail, caught by mutation and not by
159
+ // reading it. The same shape a reviewer caught in this suite's sibling yesterday.
160
+ const wrong = [];
161
+ for (const file of scanned) {
162
+ const text = fs.readFileSync(file, 'utf-8');
163
+ for (const hit of text.matchAll(COUNT_CLAIM)) {
164
+ // A release-notes SECTION inside an otherwise-current document is historical too — the README
165
+ // and the RU html bundle both embed one. The test is not a character window (which missed a
166
+ // line 1300 lines below its heading) but the NEAREST PRECEDING heading: a count written under
167
+ // a version heading is a statement about that version, and rewriting it would falsify a
168
+ // record.
169
+ const before = text.slice(0, hit.index);
170
+ const headings = before.match(/^#{1,3} .*$|^<h[1-3][^>]*>.*$/gm) || [];
171
+ const nearest = headings.length ? headings[headings.length - 1] : '';
172
+ const historicalSection = /changelog|release notes|version history|история версий|\[?v?\d+\.\d+/i;
173
+ if (historicalSection.test(nearest)) continue;
174
+ // and an explicit past-tense marker on the line itself
175
+ const lineStart = before.lastIndexOf('\n') + 1;
176
+ const line = text.slice(lineStart, text.indexOf('\n', hit.index));
177
+ if (/initial published version|первая опубликованная/i.test(line)) continue;
178
+ const claimed = Number(hit[1] || hit[2] || hit[3] || hit[4]);
179
+ if (claimed !== n) {
180
+ wrong.push(path.relative(PKG_DIR, file) + ': "' + hit[0].trim() + '"');
181
+ }
182
+ }
183
+ }
184
+ assert.deepEqual(wrong, [],
185
+ 'these say a rule count that is not ' + n + ': ' + JSON.stringify(wrong, null, 1));
186
+
187
+ // An EIGHTH site the survey missed // An EIGHTH site the survey missed, found by the suite going red: the e2e --help assertion,
188
+ // which is not a file constant but a re-typed expectation. cli.js:57 already derives its number
189
+ // from COMPONENTS, so a test that hardcodes one is a second source of truth — exactly the defect
190
+ // its own comment says it exists to catch ("cli.js used to say 1 rule while EXPECTED_RULES had 2
191
+ // entries"). It is now derived, and this asserts it stays derived.
192
+ const e2e = read(path.join('tests', 'e2e', 'lifecycle.test.js'));
193
+ const helpAt = e2e.indexOf("describe('e2e: --help shows correct component counts'");
194
+ assert.ok(helpAt > 0, 'the --help count test must exist');
195
+ const helpBlock = e2e.slice(helpAt, e2e.indexOf('});', e2e.indexOf('} finally', helpAt)));
196
+ assert.match(helpBlock, /Object\.keys\(COMPONENTS\[group\]\.items\)\.length/,
197
+ 'the --help count test must DERIVE from COMPONENTS, not re-type the numbers');
198
+ assert.ok(!/\/\d+\\s\+rules\//.test(helpBlock),
199
+ 'and must not carry a hardcoded rule count');
200
+ });
201
+
202
+ test('P7 — the HOOK surface stays Node-only', () => {
203
+ // ADR-001 D2, NARROWED by measurement during Step 7. The first version asserted that NO .sh
204
+ // ships anywhere in templates/, and that is FALSE: skills/brutal-honesty-review/scripts/
205
+ // assess-tests.sh and assess-code.sh already ship. The cross-platform promise in settings.json
206
+ // ("Cross-platform Node scripts (no bash dependencies)") is scoped to the HOOKS — all six are
207
+ // .cjs so Windows works — and a port check is hook-shaped, so that is the surface to guard.
208
+ // Asserting the broader claim would have been a test enforcing something untrue.
209
+ const hooks = path.join(PKG_DIR, 'templates', '.claude', 'hooks');
210
+ const files = fs.readdirSync(hooks);
211
+ assert.deepEqual(files.filter((f) => f.endsWith('.sh')), [],
212
+ 'a .sh among the hooks contradicts the cross-platform promise in settings.json');
213
+ assert.ok(files.filter((f) => f.endsWith('.cjs')).length >= 6,
214
+ 'and the six shipped hooks must still be .cjs: ' + JSON.stringify(files));
215
+ });
216
+ });
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ // The project-type scanner probed seven paths from one parameter, and the paths disagreed about what
4
+ // that parameter was. MEASURED before the fix:
5
+ //
6
+ // {docs_path}/docs/ddd/ · /.ai-context/ · /docs/tests/*.feature · /docs/adr/*.md · /docs/ADR.md
7
+ // → all five imply docs_path is the PROJECT ROOT
8
+ // {docs_path}/Architecture.md · {docs_path}/Solution_Strategy.md
9
+ // → these two imply docs_path is `docs/`
10
+ //
11
+ // And the pipeline writes those two files to docs/ (commands/replicate.md:234,237). So the SPARC
12
+ // probes looked exactly where the pipeline does not write: the scanner could not recognise its own
13
+ // output as SPARC-shaped.
14
+ //
15
+ // The parameter description was the root of it — one sentence naming two directories: "In Claude
16
+ // Code / replicate context this is `docs/` (project root)." A reader implementing a probe picked
17
+ // whichever half they read last. Nobody passes the parameter explicitly (a sweep of SKILL.md and
18
+ // commands/replicate.md for `docs_path` returns nothing), so that sentence IS the specification.
19
+ //
20
+ // Both the field report and the reviewing swarm got this wrong in opposite directions: the report
21
+ // said all six probes were wrong, the swarm said the table was wrong and the probes right. Five
22
+ // probes are right, two are wrong, and the table contradicts itself.
23
+
24
+ const { test, describe } = require('node:test');
25
+ const assert = require('node:assert/strict');
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ const MODULE = path.join(__dirname, '..', '..', 'templates', '.claude', 'skills',
30
+ 'cc-toolkit-generator-enhanced', 'modules', '01-detect-parse.md');
31
+ const read = () => fs.readFileSync(MODULE, 'utf-8');
32
+
33
+ /** Every path this module probes, as written. */
34
+ function probes() {
35
+ const src = read();
36
+ return [...src.matchAll(/\{docs_path\}\/([^"')\s]+)/g)].map((m) => m[1]);
37
+ }
38
+
39
+ describe('every probe resolves from ONE anchor, and the anchor is named once', () => {
40
+ test('P1 — no probe implies an anchor other than the project root', () => {
41
+ // The property is AGREEMENT across all of them, not the correctness of any single path. A
42
+ // top-level SPARC artifact would mean docs_path is `docs/` for that probe and the root for the
43
+ // others — which is the state this feature removes.
44
+ // DERIVED, not typed. Cross-family QE: the first version hard-coded seven basenames, so a probe
45
+ // for Research_Findings.md — a real pipeline document I had not listed — would have escaped it.
46
+ // The authoritative list is the pipeline's own: replicate.md writes every SPARC document to docs/.
47
+ const replicate = fs.readFileSync(path.join(__dirname, '..', '..', 'templates', '.claude',
48
+ 'commands', 'replicate.md'), 'utf-8');
49
+ const written = new Set([...replicate.matchAll(/^- `docs\/([A-Z][A-Za-z_]*\.md)`/gm)].map((m) => m[1]));
50
+ assert.ok(written.size >= 9,
51
+ 'the pipeline document list must be readable from replicate.md: ' + [...written]);
52
+
53
+ const disagreeing = probes().filter((p) => written.has(p));
54
+ assert.deepEqual(disagreeing, [],
55
+ 'these probe a document the pipeline writes to docs/, as if it were at the project root — '
56
+ + 'which only makes sense if docs_path is `docs/`, while every other probe treats it as the '
57
+ + 'root: ' + JSON.stringify(disagreeing));
58
+
59
+ // And the prose must not re-introduce the ambiguity the probes just lost. Five sites carried it.
60
+ const src = read();
61
+ assert.ok(!/top-level or docs\//.test(src),
62
+ 'the scan catalog still preserves the "top-level or docs/" reading this feature removes');
63
+ for (const doc of ['Architecture.md', 'Solution_Strategy.md', 'PRD.md', 'Specification.md']) {
64
+ const bare = new RegExp('(?<![/\\w])`' + doc.replace('.', '\\.') + '`');
65
+ assert.ok(!bare.test(src),
66
+ 'a pipeline document is named without its docs/ prefix, which reads as project-root: ' + doc);
67
+ }
68
+ });
69
+
70
+ test('P2 — the two SPARC probes look under docs/, where the pipeline writes', () => {
71
+ const src = read();
72
+ assert.match(src, /has_sparc_arch = exists\(f"\{docs_path\}\/docs\/Architecture\.md"\)/,
73
+ 'commands/replicate.md:237 writes docs/Architecture.md');
74
+ assert.match(src, /has_sparc_sol\s+= exists\(f"\{docs_path\}\/docs\/Solution_Strategy\.md"\)/,
75
+ 'commands/replicate.md:234 writes docs/Solution_Strategy.md');
76
+ });
77
+
78
+ test('P3 — the root-level probe stays at the root', () => {
79
+ // .ai-context/ sits beside .claude/, not under docs/. Moving it "for consistency" would break a
80
+ // probe that was correct all along — the failure mode the field report actually proposed.
81
+ assert.ok(probes().includes('.ai-context/'),
82
+ 'the .ai-context probe must remain anchored at the project root: ' + JSON.stringify(probes()));
83
+ assert.ok(!probes().includes('docs/.ai-context/'),
84
+ '.ai-context/ is a root-level directory; probing it under docs/ finds nothing');
85
+ });
86
+
87
+ test('P4 — the parameter names ONE directory', () => {
88
+ const src = read();
89
+ const row = src.split('\n').find((l) => l.startsWith('| `docs_path` |'));
90
+ assert.ok(row, 'the parameter table row must exist');
91
+ assert.ok(!/this is `docs\/` \(project root\)/.test(row),
92
+ 'the row still names two different directories in one breath — that sentence is what split '
93
+ + 'the probes in the first place: ' + row);
94
+ assert.match(row, /the PROJECT ROOT/,
95
+ 'it must name the anchor unambiguously');
96
+ assert.match(row, /not `docs\/`/,
97
+ 'and rule out the other reading explicitly, since that reading is what produced the split');
98
+ });
99
+
100
+ test('P5 — both ADR shapes are still collected', () => {
101
+ // This logic shipped in an earlier feature (one decision per file, and /replicate's single
102
+ // docs/ADR.md). Re-anchoring must not disturb it.
103
+ const src = read();
104
+ assert.match(src, /glob\(f"\{docs_path\}\/docs\/adr\/\*\.md"\)/, 'shape A: a directory of files');
105
+ assert.match(src, /f"\{docs_path\}\/docs\/ADR\.md"/, 'shape B: /replicate\'s single file');
106
+ assert.match(src, /has_adr = len\(collect_adrs\(docs_path\)\) > 0/,
107
+ 'and the one definition that unifies them');
108
+ });
109
+ });
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ // Every validation lens compared documents to documents. MEASURED — replicate.md's Phase-2 table
4
+ // listed five: stories→PRD, AC→stories, Architecture.md, Pseudocode.md, cross-document coherence.
5
+ // Nothing anywhere asked whether the outside world agrees, so a requirement could rest on a
6
+ // capability the provider does not have and reach code generation unchallenged. It is the only
7
+ // contact-with-reality defect in this pipeline; everything else it checks, it can check by reading
8
+ // its own output.
9
+ //
10
+ // TWO honesty constraints bind this suite, both from the field report:
11
+ // 1. check-pipeline-gaps.sh, which the report cites, DOES NOT EXIST. P7 keeps it uncited.
12
+ // 2. No external fact may be baked in. What an API can do DRIFTS: a fixture asserting one would
13
+ // fail when the world changes and pass when our own rule breaks. P6 asserts the ABSENCE of
14
+ // vendor names, and no assertion here mentions a capability of any real service.
15
+
16
+ const { test, describe } = require('node:test');
17
+ const assert = require('node:assert/strict');
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+
21
+ const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
22
+ const read = (rel) => fs.readFileSync(path.join(TPL, rel), 'utf-8');
23
+
24
+ const SPARC = 'skills/sparc-prd-mini/SKILL.md';
25
+ const REPLICATE = 'commands/replicate.md';
26
+
27
+ /** The External Dependencies section of the Phase-5 Architecture.md template. Scoped, because an
28
+ * assertion over a whole file passes on any prose that happens to mention the words. */
29
+ function inventory() {
30
+ const src = read(SPARC);
31
+ const start = src.indexOf('## External Dependencies\n');
32
+ assert.ok(start > 0, 'the Phase-5 template must carry an External Dependencies section');
33
+ const end = src.indexOf('## Data Architecture', start);
34
+ assert.ok(end > start, 'and it must sit before Data Architecture, where the stack is still in view');
35
+ return src.slice(start, end);
36
+ }
37
+
38
+ /** Phase 2 of replicate.md — a lens declared in a later phase validates nothing. */
39
+ function phase2() {
40
+ const src = read(REPLICATE);
41
+ const start = src.indexOf('### Phase 2: VALIDATION');
42
+ const end = src.indexOf('### Phase 3', start);
43
+ assert.ok(start > 0 && end > start, 'replicate.md must have a Phase 2 before Phase 3');
44
+ return src.slice(start, end);
45
+ }
46
+
47
+ describe('the pipeline asks whether the outside world can do what a requirement needs (PR-010)', () => {
48
+ test('P1 — the inventory exists and carries all five columns', () => {
49
+ const block = inventory();
50
+ // The HEADER ROW, not five words scattered through prose. Cross-family QE: a paragraph
51
+ // containing all five labels would have replaced the table without failing.
52
+ const header = block.split('\n').find((l) => l.startsWith('| Capability needed |')) || '';
53
+ assert.notEqual(header, '', 'the inventory must have a table header row starting with the '
54
+ + 'capability column');
55
+ for (const col of ['Capability needed', 'Provider / API', 'Evidence', 'Verdict',
56
+ 'Requirements relying on it']) {
57
+ assert.ok(header.includes(col), 'the header row must carry the column: ' + col);
58
+ }
59
+ // One row per CAPABILITY, not per vendor: a provider can do one of two things you need.
60
+ assert.match(block, /One row per capability, not one row\s+per vendor/,
61
+ 'the granularity must be stated, or one row hides two questions');
62
+ });
63
+
64
+ test('P2 — the evidence rule is CLOSED on both sides', () => {
65
+ // Saying what evidence IS leaves every reader to decide what it is not. The three ways this
66
+ // check gets faked are named, so a row using one of them is visibly not evidence.
67
+ const block = inventory();
68
+ assert.match(block, /link to the PROVIDER'S OWN documentation/,
69
+ 'what counts must be stated precisely — not "a source"');
70
+ assert.match(block, /plus the date it was checked/,
71
+ 'a citation without a date cannot be re-checked later');
72
+ // Cross-family QE, the load-bearing finding: CONFIRMED was self-attested, and a plausible URL is
73
+ // the cheapest forgery there is. A verbatim quote is what a link alone cannot fake.
74
+ assert.match(block, /verbatim QUOTE from\s+that page stating the capability/,
75
+ 'a non-fabricable artifact must be required, not just a link');
76
+ assert.match(block, /a URL nobody opened/,
77
+ 'and the link-alone forgery must be named as not-evidence');
78
+ assert.match(block, /do NOT count/, 'and the rule must be closed on the other side too');
79
+ for (const fake of ['landing page', 'pricing page', 'recollection']) {
80
+ assert.ok(block.includes(fake), 'the not-evidence list must name: ' + fake);
81
+ }
82
+ });
83
+
84
+ test('P3 — exactly three verdicts, and the empty case is explicit', () => {
85
+ const block = inventory();
86
+ // Each verdict must be a ROW of the verdict table, not a word somewhere in the prose. Found by
87
+ // mutation: renaming the UNCONFIRMED row to UNKNOWN left the vocabulary broken and the first
88
+ // version of this assertion green, because the word still appeared in the paragraph below it.
89
+ for (const v of ['CONFIRMED', 'UNCONFIRMED', 'CONTRADICTED']) {
90
+ assert.match(block, new RegExp('^\\| ' + v + ' \\|', 'm'),
91
+ 'the verdict must be a row of the verdict table, not merely mentioned: ' + v);
92
+ }
93
+ // Two values would collapse "we checked and it cannot" into "nobody checked", which overstates
94
+ // one and hides the other. The reason has to be written down or the third value looks redundant.
95
+ assert.match(block, /exactly three, because two would hide a difference that\s+matters/,
96
+ 'the reason for three values must be recorded, or a later edit will merge two of them');
97
+ // EXACTLY three: the first version asserted the three exist and let a fourth in silently.
98
+ const verdictRows = block.split('\n')
99
+ .filter((l) => /^\| [A-Z_]+ \|/.test(l) && !l.startsWith('| Verdict |'));
100
+ assert.equal(verdictRows.length, 3,
101
+ 'the verdict table must have exactly three rows, not merely contain the three: '
102
+ + JSON.stringify(verdictRows));
103
+ assert.match(block, /No external dependencies —\s+this product calls no third-party service/,
104
+ 'a project with none must say so: an empty section and an absent one look identical');
105
+ });
106
+
107
+ test('P4 — Phase 2 declares the sixth lens, inside Phase 2', () => {
108
+ const block = phase2();
109
+ const lensRow = block.split('\n').find((l) => l.startsWith('| `validator-dependencies` |')) || '';
110
+ assert.notEqual(lensRow, '',
111
+ 'the lens must be a ROW of the validator table, not a mention in prose');
112
+ assert.match(lensRow, /External Dependencies/,
113
+ 'and its scope column must name the section it reads: ' + lensRow);
114
+ assert.match(lensRow, /evidence that names that capability/,
115
+ 'and its criteria column must say what it checks for: ' + lensRow);
116
+ assert.match(block, /the only one that looks OUTSIDE the\s+documents/,
117
+ 'and say what makes it different from the other five, or it reads as a sixth of the same kind');
118
+ });
119
+
120
+ test('P5 — the exit criteria carry BOTH consequences, and UNCONFIRMED does not pass silently', () => {
121
+ // Anchored to the exit-criteria ROW each term belongs to. Cross-family QE: three independent
122
+ // phrase matches anywhere in Phase 2 would pass on explanatory prose that changed no verdict.
123
+ const rows = {};
124
+ for (const l of phase2().split('\n')) {
125
+ const m = l.match(/^\| (🟢 READY|🟡 CAVEATS|🔴 NEEDS WORK) \|/);
126
+ if (m) rows[m[1]] = l;
127
+ }
128
+ assert.ok(rows['🟢 READY'] && rows['🟡 CAVEATS'] && rows['🔴 NEEDS WORK'],
129
+ 'the exit-criteria table must carry all three verdict rows');
130
+ assert.match(rows['🔴 NEEDS WORK'], /any external dependency is `CONTRADICTED`/,
131
+ 'a contradicted dependency must send the run back, in the row that sends it back');
132
+ assert.match(rows['🟡 CAVEATS'], /every `UNCONFIRMED` dependency NAMED row by row/,
133
+ 'an unconfirmed dependency must be NAMED in the CAVEATS row — an unnamed caveat is one '
134
+ + 'nobody acts on');
135
+ assert.match(rows['🟢 READY'], /no external dependency `UNCONFIRMED` or `CONTRADICTED`/,
136
+ 'and READY must exclude both, or the strongest verdict is the one that checks least');
137
+ // And the consequence must be scoped to the REQUIREMENT: without that, an inventory of nothing
138
+ // but UNCONFIRMED rows reaches Phase 3 with a caveat and feasibility is never established.
139
+ assert.match(inventory(), /cannot enter Phase 3 — defer, remove or replace them/,
140
+ 'UNCONFIRMED must stop the requirements that rest on it, or the check is optional in practice');
141
+ });
142
+
143
+ test('P6 — NO vendor fact is baked in: external facts drift', () => {
144
+ // A fixture asserting what some API can do would fail when the world changes and pass when our
145
+ // own rule breaks. The template must teach the SHAPE and name nobody.
146
+ const block = inventory();
147
+ const vendors = ['Anthropic', 'OpenAI', 'Stripe', 'Twilio', 'SendGrid', 'AWS', 'Google Cloud',
148
+ 'Azure', 'Firebase', 'Supabase'];
149
+ for (const v of vendors) {
150
+ assert.ok(!block.includes(v),
151
+ 'the template must name no real provider — capabilities drift and a stale fact recorded as '
152
+ + 'evidence is worse than none. Found: ' + v);
153
+ }
154
+ // A denylist can only ever catch the names on it. The positive assertion is what actually holds:
155
+ // the example row's own cells must be bracketed placeholders, so there is no cell for a real
156
+ // vendor to sit in. The list above stays as a cheap second net.
157
+ const exampleRow = block.split('\n')
158
+ .find((l) => l.startsWith('| [') && l.includes('CONFIRMED')) || '';
159
+ assert.notEqual(exampleRow, '', 'the template must show one example row');
160
+ const cells = exampleRow.split('|').map((c) => c.trim()).filter(Boolean);
161
+ for (const cell of cells) {
162
+ assert.ok(/^\[|^CONFIRMED$/.test(cell) || cell.startsWith('['),
163
+ 'every example cell must be a bracketed placeholder or the verdict itself, so no real '
164
+ + 'provider can sit in the row: ' + cell);
165
+ }
166
+ assert.match(block, /are PLACEHOLDERS/, 'and it must say the example names are placeholders');
167
+ assert.match(block, /a stale fact recorded as evidence is worse than\s+none/,
168
+ 'with the reason, so a later editor does not helpfully fill in a real one');
169
+ });
170
+
171
+ test('P8 — the agent that DISPATCHES the swarm lists the same lenses as the command', () => {
172
+ // Found by an independent reviewer after the fact: commands/replicate.md gained the sixth lens
173
+ // and templates/.claude/agents/doc-validator.md did not. Two files describing one swarm, one of
174
+ // them a lens short — and the sha256 snapshot test cannot see it, because both files hash fine.
175
+ const cmd = read(REPLICATE);
176
+ const agent = fs.readFileSync(path.join(TPL, 'agents', 'doc-validator.md'), 'utf-8');
177
+ const names = (src) => new Set([...src.matchAll(/\| `(validator-[a-z-]+)`/g)].map((m) => m[1]));
178
+ const inCmd = names(cmd);
179
+ const inAgent = names(agent);
180
+ assert.ok(inCmd.size >= 6, 'the command must dispatch at least six lenses: ' + [...inCmd]);
181
+ assert.deepEqual([...inAgent].sort(), [...inCmd].sort(),
182
+ 'the dispatching agent and the command disagree about the swarm: '
183
+ + JSON.stringify({ agent: [...inAgent], command: [...inCmd] }));
184
+ // And the count in the agent's own prose must match the table it introduces.
185
+ const stated = agent.match(/Launch (\d+) parallel validation agents/);
186
+ assert.ok(stated, 'doc-validator.md must state how many it launches');
187
+ assert.equal(Number(stated[1]), inAgent.size,
188
+ 'doc-validator.md says ' + stated[1] + ' but tables ' + inAgent.size);
189
+ });
190
+
191
+ test('P7 — nothing cites check-pipeline-gaps.sh, which does not exist', () => {
192
+ // The report cites it. MEASURED — a repo-wide find returns nothing. Pointing a reader at a
193
+ // script that is not there is a worse failure than the gap it was cited to close.
194
+ for (const f of [SPARC, REPLICATE]) {
195
+ assert.ok(!read(f).includes('check-pipeline-gaps'),
196
+ f + ' references a script that does not exist in this repository');
197
+ }
198
+ // The premise, asserted rather than assumed: if someone ever ADDS the script, this goes red and
199
+ // whoever added it has to revisit the rule instead of leaving a stale prohibition behind.
200
+ const pkgRoot = path.join(__dirname, '..', '..');
201
+ const candidates = ['check-pipeline-gaps.sh', path.join('scripts', 'check-pipeline-gaps.sh'),
202
+ path.join('bin', 'check-pipeline-gaps.sh')];
203
+ for (const c of candidates) {
204
+ assert.ok(!fs.existsSync(path.join(pkgRoot, c)),
205
+ 'the script now exists at ' + c + ' — this rule was written on the premise that it does '
206
+ + 'not, so revisit it rather than deleting this assertion');
207
+ }
208
+ });
209
+ });
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ // Two callers disabled the exact branch the module they call declares. MEASURED:
4
+ //
5
+ // commands/replicate.md:169 | M5: Growth Engine | If B2C/PLG | …
6
+ // agents/product-discoverer.md:22 | M5: Growth Engine | If B2C/PLG | …
7
+ // modules/05-growth-engine.md:62 - Если B2B → sales-led growth, не product-led; переключи framework
8
+ // reverse-engineering-unicorn/SKILL.md:94 MODULE 5: GROWTH ENGINE → … (no condition)
9
+ //
10
+ // Three files disagreed about whether M5 runs, and the two that said "only for B2C" were the ones
11
+ // that decided. For every B2B project the one output slot — product-discoverer.md "### Growth
12
+ // Channels [From growth engine analysis — if applicable]" — arrived empty, not because there was
13
+ // nothing to say but because the gate stopped the module that would have said it.
14
+ //
15
+ // NOT done, deliberately: editing the growth module. It exists in four byte-identical copies across
16
+ // three published packages, kept in step by a --check gate that exits 1 on drift. A two-line fix in
17
+ // two callers touches ONE package; touching the module is a three-package release. P5 pins that.
18
+
19
+ const { test, describe } = require('node:test');
20
+ const assert = require('node:assert/strict');
21
+ const crypto = require('node:crypto');
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+
25
+ const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
26
+ const read = (rel) => fs.readFileSync(path.join(TPL, rel), 'utf-8');
27
+
28
+ const CALLERS = [
29
+ { file: 'commands/replicate.md', produces: 'Channels, integrations' },
30
+ { file: 'agents/product-discoverer.md', produces: 'Channels, integrations, viral loops' },
31
+ ];
32
+ const MODULE = 'skills/reverse-engineering-unicorn/modules/05-growth-engine.md';
33
+
34
+ /** EVERY table row that decides whether M5 runs. Cross-family QE: taking the FIRST match let a gate
35
+ * come back in a duplicate row further down, invisible to both helpers. */
36
+ function m5Rows(file) {
37
+ const rows = read(file).split('\n').filter((l) => l.startsWith('| M5: Growth Engine |'));
38
+ assert.equal(rows.length, 1,
39
+ file + ' must have exactly one M5 row; found ' + rows.length + ': ' + JSON.stringify(rows));
40
+ return rows;
41
+ }
42
+ const cells = (row) => row.split('|').map((c) => c.trim());
43
+
44
+ describe('the growth module is not gated off for the type it handles itself', () => {
45
+ test('P1 — neither caller conditions M5 on a product type', () => {
46
+ // Asserted on the ROW, not the file: the phrase "B2C" appears legitimately elsewhere (the
47
+ // sentence explaining WHY the gate went), and a whole-file check would forbid the explanation.
48
+ // The property is not "no condition" — cross-family QE was right that `Always` over-promises,
49
+ // because M5's outputs are CAC, channels and loops and an internal tool with no acquisition
50
+ // objective has nothing to put in them. The property is that the condition does not EXCLUDE a
51
+ // product type the module handles: if it names types at all, B2B must be among them.
52
+ for (const { file } of CALLERS) {
53
+ for (const row of m5Rows(file)) {
54
+ const condition = cells(row)[2];
55
+ assert.notEqual(condition, '', file + ': the M5 condition cell is empty');
56
+ const namesTypes = /B2C|B2B|PLG|product-led|sales-led/i.test(condition);
57
+ if (namesTypes) {
58
+ assert.match(condition, /B2B/i,
59
+ file + ' gates M5 on product type and leaves B2B out — the module handles B2B at '
60
+ + 'modules/05-growth-engine.md:62: ' + JSON.stringify(condition));
61
+ }
62
+ assert.ok(!/^If B2C/i.test(condition),
63
+ file + ' restored the original product-type gate: ' + JSON.stringify(condition));
64
+ }
65
+ }
66
+ });
67
+
68
+ test('P2 — the row keeps saying what M5 produces', () => {
69
+ // A removal that took the row with it would satisfy P1 and lose the module from the table.
70
+ // The OUTPUT COLUMN, not a substring of the row: cross-family QE noted a substring check passes
71
+ // if the text drifts into the condition cell.
72
+ for (const { file, produces } of CALLERS) {
73
+ for (const row of m5Rows(file)) {
74
+ assert.equal(cells(row)[3], produces,
75
+ file + ' lost or moved M5\'s output column: ' + row);
76
+ }
77
+ }
78
+ });
79
+
80
+ test('P3 — the orchestrator still runs M5 unconditionally: it is the fixed point', () => {
81
+ const skill = read('skills/reverse-engineering-unicorn/SKILL.md');
82
+ const line = skill.split('\n').find((l) => l.includes('MODULE 5: GROWTH ENGINE'));
83
+ assert.ok(line, 'SKILL.md must still list MODULE 5');
84
+ assert.ok(!/if |если |B2C|B2B|PLG/i.test(line),
85
+ 'the orchestrator must stay unconditional — all three files agree with IT: ' + line);
86
+ });
87
+
88
+ test('P4 — the module\'s own B2B branch survives the removal that it justifies', () => {
89
+ // This is the load-bearing one. Removing the gate is safe BECAUSE the module decides per type.
90
+ // Without this assertion someone could delete the justification and keep the removal, and the
91
+ // module would then run for B2B with no B2B behaviour.
92
+ assert.match(read(MODULE), /Если B2B → sales-led growth, не product-led/,
93
+ 'the branch that makes the ungated call correct must still be there');
94
+ });
95
+
96
+ test('P5 — the growth module is byte-identical: this feature touches two callers only', () => {
97
+ // Four byte-identical copies across three published packages hang off this file. Pinning its
98
+ // hash is what keeps a two-line fix from becoming a three-package release by accident.
99
+ const sha = crypto.createHash('sha256')
100
+ .update(fs.readFileSync(path.join(TPL, MODULE))).digest('hex');
101
+ assert.equal(sha, '98e8577aa5a5472574ac9bac590b76a4a3d16f99bc67ebec2b09b604c7c1faab',
102
+ 'the growth module changed; that is a three-package release, not this feature');
103
+ });
104
+ });