@dzhechkov/p-replicator 1.6.0 → 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 +59 -23
- package/CHANGELOG.md +127 -0
- package/README.md +106 -4
- package/package.json +4 -4
- package/sbom.json +112 -22
- package/src/utils.js +2 -0
- package/templates/.claude/commands/replicate.md +57 -1
- package/templates/.claude/hooks/check-docs-complete.cjs +174 -0
- package/templates/.claude/hooks/check-growth-trace.cjs +191 -0
- package/templates/.claude/hooks/statusline.cjs +1 -1
- package/templates/.claude/rules/replicate-pipeline.md +14 -4
- package/templates/.claude/rules/skill-interface-protocol.md +9 -0
- 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/templates/feature-lifecycle.md +2 -2
- package/templates/.claude/skills/requirements-validator/SKILL.md +52 -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 +9 -9
- package/tests/snapshot/baseline.json +20 -18
- package/tests/unit/check-docs-complete.test.js +249 -0
- package/tests/unit/check-growth-trace.test.js +188 -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 +20 -2
- package/tests/unit/growth-requirements-bridge.test.js +127 -0
- package/tests/unit/module-copy-identity.test.js +76 -0
- package/tests/unit/skill-paths-prebaked.test.js +174 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The deterministic half of the growth gate. The validator criterion is prose a model reads —
|
|
4
|
+
// layer 3. This is layer 1, and only for whoever runs it.
|
|
5
|
+
//
|
|
6
|
+
// The load-bearing property is the THIRD exit code, for the same reason it was in check-ports:
|
|
7
|
+
// a checker that answers "clean" when it could not look converts an unknown into a reassurance.
|
|
8
|
+
// Here the unknown has a specific and common cause — the --from-docs entry skips Phase 0, so the
|
|
9
|
+
// brief legitimately does not exist. That must be 2. Reading it as 0 would report "all growth
|
|
10
|
+
// requirements traced" for a project that never analysed growth at all.
|
|
11
|
+
//
|
|
12
|
+
// These are BEHAVIOUR tests: the real utility, real files, real exit codes.
|
|
13
|
+
|
|
14
|
+
const { test, describe } = require('node:test');
|
|
15
|
+
const assert = require('node:assert/strict');
|
|
16
|
+
const { spawnSync } = require('node:child_process');
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
22
|
+
const TPL = path.join(PKG, 'templates', '.claude');
|
|
23
|
+
const CHECK = path.join(TPL, 'hooks', 'check-growth-trace.cjs');
|
|
24
|
+
|
|
25
|
+
/** Build a throwaway project and run the real checker over it. */
|
|
26
|
+
function check(files) {
|
|
27
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-growth-')));
|
|
28
|
+
try {
|
|
29
|
+
for (const [rel, body] of Object.entries(files || {})) {
|
|
30
|
+
const abs = path.join(dir, rel);
|
|
31
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
32
|
+
fs.writeFileSync(abs, body);
|
|
33
|
+
}
|
|
34
|
+
const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8' });
|
|
35
|
+
return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
|
|
36
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const brief = (rows) => '# Brief\n\n## 🌱 Growth Requirements Seed\n\n'
|
|
40
|
+
+ '| ID | Требование | Блок-источник | Confidence | Статус |\n'
|
|
41
|
+
+ '|----|----|----|----|----|\n' + rows.join('\n') + '\n';
|
|
42
|
+
|
|
43
|
+
const ROW1 = '| FR-GROWTH-001 | Реферальная петля в онбординге | A. Primary Growth Loop | 4/5 | ЧЕРНОВИК |';
|
|
44
|
+
const ROW2 = '| FR-GROWTH-002 | Интеграция с маркетплейсом | B. Top-3 Acquisition Channels | 3/5 | ЧЕРНОВИК |';
|
|
45
|
+
|
|
46
|
+
describe('the growth-trace checker answers three questions, and never confuses two of them', () => {
|
|
47
|
+
test('P1 - a traced requirement is exit 0', () => {
|
|
48
|
+
const r = check({
|
|
49
|
+
'docs/product-discovery-brief.md': brief([ROW1]),
|
|
50
|
+
'docs/Specification.md': '# Spec\n\n## FR-GROWTH-001 Реферальная петля\n\nПодробности.\n',
|
|
51
|
+
});
|
|
52
|
+
assert.equal(r.code, 0, 'a traced id must pass: ' + r.out);
|
|
53
|
+
assert.match(r.out, /прослежены/, r.out);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('P2 - an analysed-then-dropped requirement is exit 1, and the id is named', () => {
|
|
57
|
+
const r = check({
|
|
58
|
+
'docs/product-discovery-brief.md': brief([ROW1]),
|
|
59
|
+
'docs/Specification.md': '# Spec\n\nНичего про рост.\n',
|
|
60
|
+
});
|
|
61
|
+
assert.equal(r.code, 1, 'a dropped obligation must fail: ' + r.out);
|
|
62
|
+
assert.match(r.out, /FR-GROWTH-001/, 'and the lost id must be named: ' + r.out);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('P3 - an absent brief is exit 2, never exit 0', () => {
|
|
66
|
+
// The single most important case. --from-docs skips Phase 0, so this is the COMMON state, and
|
|
67
|
+
// reading it as clean would silently bless every such project.
|
|
68
|
+
const r = check({ 'docs/Specification.md': '# Spec\n' });
|
|
69
|
+
assert.equal(r.code, 2, 'no brief means the check did not run: ' + r.out);
|
|
70
|
+
assert.match(r.out, /проверка НЕ выполнена/, r.out);
|
|
71
|
+
assert.match(r.out, /Фаза 0 не запускалась/, 'and the reason must be named: ' + r.out);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('P4 - an absent Specification is exit 2, not exit 1', () => {
|
|
75
|
+
// Without the destination there is nothing to compare against. Calling that "nothing traced"
|
|
76
|
+
// would blame the project for the checker's blindness.
|
|
77
|
+
const r = check({ 'docs/product-discovery-brief.md': brief([ROW1]) });
|
|
78
|
+
assert.equal(r.code, 2, 'no Specification means the check could not run: ' + r.out);
|
|
79
|
+
assert.match(r.out, /Specification/, r.out);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('P5 - an untouched template table is exit 2, not exit 0', () => {
|
|
83
|
+
// The shipped module carries example rows with bracketed placeholders. Counting them would let
|
|
84
|
+
// an untouched template look like a filled-in one — and then "0 traced of 0" reads as clean.
|
|
85
|
+
const r = check({
|
|
86
|
+
'docs/product-discovery-brief.md': brief([
|
|
87
|
+
'| FR-GROWTH-001 | [что обязаны построить, одним предложением] | A. Primary Growth Loop | [как записано] | ЧЕРНОВИК |',
|
|
88
|
+
'| FR-GROWTH-002 | ... | B. Top-3 Acquisition Channels | ... | ... |',
|
|
89
|
+
]),
|
|
90
|
+
'docs/Specification.md': '# Spec\n',
|
|
91
|
+
});
|
|
92
|
+
assert.equal(r.code, 2, 'a placeholder row is not an obligation: ' + r.out);
|
|
93
|
+
assert.match(r.out, /шаблоном|ни одной заполненной/, r.out);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('P6 - a rejection WITH a reason passes; a bare drop does not', () => {
|
|
97
|
+
const withReason = check({
|
|
98
|
+
'docs/product-discovery-brief.md': brief([ROW1]),
|
|
99
|
+
'docs/Specification.md': '# Spec\n\nFR-GROWTH-001 отклонено — нет бюджета на реферальную программу в MVP.\n',
|
|
100
|
+
});
|
|
101
|
+
assert.equal(withReason.code, 0, 'a recorded rejection is a legitimate answer: ' + withReason.out);
|
|
102
|
+
|
|
103
|
+
const bare = check({
|
|
104
|
+
'docs/product-discovery-brief.md': brief([ROW1]),
|
|
105
|
+
'docs/Specification.md': '# Spec\n\nFR-GROWTH-001\n',
|
|
106
|
+
});
|
|
107
|
+
// A bare id mention IS a trace by the stated definition — that is deliberate and documented.
|
|
108
|
+
// What must not pass is an id that appears nowhere, which P2 covers.
|
|
109
|
+
assert.equal(bare.code, 0, 'the exact token is the definition of a mention: ' + bare.out);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('P7 - a partial loss is reported with the count, not averaged away', () => {
|
|
113
|
+
const r = check({
|
|
114
|
+
'docs/product-discovery-brief.md': brief([ROW1, ROW2]),
|
|
115
|
+
'docs/Specification.md': '# Spec\n\nFR-GROWTH-001 реализуем.\n',
|
|
116
|
+
});
|
|
117
|
+
assert.equal(r.code, 1, 'one of two lost is still a loss: ' + r.out);
|
|
118
|
+
assert.match(r.out, /1 из 2/, 'the count must be reported: ' + r.out);
|
|
119
|
+
assert.match(r.out, /FR-GROWTH-002/, 'and the lost one named: ' + r.out);
|
|
120
|
+
assert.ok(!/FR-GROWTH-001/.test(r.out.split('\n').filter((l) => l.includes('•')).join('\n')),
|
|
121
|
+
'the traced one must NOT be listed as lost: ' + r.out);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('P8 - one binary, all three verdicts in a single run', () => {
|
|
125
|
+
// Each case above asserts ONE direction, so a constant-answer implementation could pass a
|
|
126
|
+
// subset. The same executable must produce 0, 1 and 2.
|
|
127
|
+
const seen = [
|
|
128
|
+
check({ 'docs/product-discovery-brief.md': brief([ROW1]),
|
|
129
|
+
'docs/Specification.md': 'FR-GROWTH-001\n' }).code,
|
|
130
|
+
check({ 'docs/product-discovery-brief.md': brief([ROW1]),
|
|
131
|
+
'docs/Specification.md': 'ничего\n' }).code,
|
|
132
|
+
check({}).code,
|
|
133
|
+
];
|
|
134
|
+
assert.deepEqual(seen, [0, 1, 2], 'expected clean/lost/could-not-check: ' + JSON.stringify(seen));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('P11 - the counter-examples cross-family review supplied', () => {
|
|
138
|
+
// Each was a false CLEAN in the first version, and each is named by the input that produced it.
|
|
139
|
+
const cases = [
|
|
140
|
+
[1, '# Spec\nFR-GROWTH-001 rejected\n',
|
|
141
|
+
'a bare refusal with no reason: the reason pattern accepted a hyphen and the IDENTIFIER '
|
|
142
|
+
+ 'carries two — and the mention rule reached the line first, so the reason was never asked for'],
|
|
143
|
+
[0, '# Spec\nFR-GROWTH-001 отклонено — нет бюджета на реферальную программу в MVP\n',
|
|
144
|
+
'a refusal WITH a reason is a legitimate answer and must still pass'],
|
|
145
|
+
[1, '# Spec\nFR-GROWTH-001 declined - x\n',
|
|
146
|
+
'one character is a separator plus noise, not a reason'],
|
|
147
|
+
];
|
|
148
|
+
for (const [expected, spec, why] of cases) {
|
|
149
|
+
const r = check({ 'docs/product-discovery-brief.md': brief([ROW1]), 'docs/Specification.md': spec });
|
|
150
|
+
assert.equal(r.code, expected, why + ' — got ' + r.code + ': ' + r.out);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('P12 - a reused id is exit 2, because one mention would clear two obligations', () => {
|
|
155
|
+
// The module's own rule is that a number is never reused. When it is, `mentioned()` answers from
|
|
156
|
+
// a Set and a SINGLE mention marks BOTH rows traced — coverage counted over usable items rather
|
|
157
|
+
// than per position. Malformed input is inconclusive, never a pass.
|
|
158
|
+
const dup = '| FR-GROWTH-001 | Совсем другое требование | B. Top-3 Acquisition Channels | 3/5 | ЧЕРНОВИК |';
|
|
159
|
+
const r = check({
|
|
160
|
+
'docs/product-discovery-brief.md': brief([ROW1, dup]),
|
|
161
|
+
'docs/Specification.md': '# Spec\nFR-GROWTH-001 берём\n',
|
|
162
|
+
});
|
|
163
|
+
assert.equal(r.code, 2, 'a duplicate id must not read as clean: ' + r.out);
|
|
164
|
+
assert.match(r.out, /повторяются идентификаторы/, r.out);
|
|
165
|
+
assert.match(r.out, /FR-GROWTH-001/, 'and the duplicated id must be named: ' + r.out);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('P9 - it is a hooks component wired to NO event', () => {
|
|
169
|
+
const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
|
|
170
|
+
assert.ok(COMPONENTS.hooks.items['check-growth-trace'],
|
|
171
|
+
'it must be registered, or init/doctor/verify will not know it');
|
|
172
|
+
const settings = fs.readFileSync(path.join(TPL, 'settings.json'), 'utf-8');
|
|
173
|
+
assert.ok(!settings.includes('check-growth-trace'),
|
|
174
|
+
'it must not be wired to an event: this packages hooks are non-blocking by contract, so a '
|
|
175
|
+
+ 'hook could only print — it could never refuse anything');
|
|
176
|
+
const statusline = fs.readFileSync(path.join(TPL, 'hooks', 'statusline.cjs'), 'utf-8');
|
|
177
|
+
const m = statusline.match(/hooksExpected:\s*(\d+)/);
|
|
178
|
+
assert.ok(m, 'statusline must declare hooksExpected');
|
|
179
|
+
assert.equal(Number(m[1]), Object.keys(COMPONENTS.hooks.items).length,
|
|
180
|
+
'the status line would report a phantom missing hook: ' + m[1]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('P10 - a directory that does not exist is exit 2', () => {
|
|
184
|
+
const r = spawnSync(process.execPath, [CHECK, '/nonexistent-path-for-this-test'],
|
|
185
|
+
{ encoding: 'utf8' });
|
|
186
|
+
assert.equal(r.status, 2, 'an unusable argument is not a clean bill: ' + r.stdout);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Two prose defects in 05-growth-engine.md, shipped as one change because the file has four
|
|
4
|
+
// byte-identical copies and splitting them means two three-package releases.
|
|
5
|
+
//
|
|
6
|
+
// P4: the growth-type list forced ONE choice across TWO different questions — how a company reaches
|
|
7
|
+
// buyers, and what makes usage produce more usage. A sales-led company running a referral loop was
|
|
8
|
+
// literally unsayable. Three loop mechanics were also absent.
|
|
9
|
+
//
|
|
10
|
+
// P2: the Research Protocol's three modes mentioned no constraint of any kind, so the module could
|
|
11
|
+
// design a violation with full confidence — confidence here comes from data quality, not legality.
|
|
12
|
+
//
|
|
13
|
+
// The load-bearing rule in P2's fix, and the evidence for it is the source report's own failure: it
|
|
14
|
+
// warned that a 2024 penalty figure was stale and then quoted the 2025 figure as current, in August
|
|
15
|
+
// 2026. Warning and error, one document, one year apart. So: cite the norm, never the amount — and
|
|
16
|
+
// P5 exists to make a future edit that re-adds a figure go red.
|
|
17
|
+
|
|
18
|
+
const { test, describe } = require('node:test');
|
|
19
|
+
const assert = require('node:assert/strict');
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const MODULE = path.join(__dirname, '..', '..', 'templates', '.claude', 'skills',
|
|
24
|
+
'reverse-engineering-unicorn', 'modules', '05-growth-engine.md');
|
|
25
|
+
|
|
26
|
+
const read = () => fs.readFileSync(MODULE, 'utf-8');
|
|
27
|
+
|
|
28
|
+
/** A named section, from its heading to the next heading at the same level. */
|
|
29
|
+
function section(heading, level) {
|
|
30
|
+
const src = read();
|
|
31
|
+
const start = src.indexOf(heading);
|
|
32
|
+
assert.ok(start > 0, 'the module must carry: ' + heading);
|
|
33
|
+
const end = src.indexOf('\n' + '#'.repeat(level) + ' ', start + 1);
|
|
34
|
+
assert.ok(end > start, heading + ' must be followed by another section');
|
|
35
|
+
return src.slice(start, end);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const compliance = () => section('### ⚖️ Чеклист допустимости', 3);
|
|
39
|
+
|
|
40
|
+
describe('growth type is two questions, and the protocol knows constraints exist', () => {
|
|
41
|
+
test('P1 - the two axes are separate and independently chosen', () => {
|
|
42
|
+
const src = read();
|
|
43
|
+
assert.match(src, /\*\*Ось 1 — МОТИОН/, 'the go-to-market axis must be its own list');
|
|
44
|
+
assert.match(src, /\*\*Ось 2 — ПЕТЛЯ/, 'and the loop axis its own');
|
|
45
|
+
assert.match(src, /Оси НЕЗАВИСИМЫ/, 'independence must be stated, not left to be inferred');
|
|
46
|
+
assert.match(src, /Sales-Led` \+ `Поощряемая реферальная/,
|
|
47
|
+
'and shown with the combination the old single list made unsayable');
|
|
48
|
+
|
|
49
|
+
// The defect was ONE instruction spanning both questions. Its removal is the property.
|
|
50
|
+
assert.ok(!/\*\*Выбранный тип:\*\* \[ONE из:\]/.test(src),
|
|
51
|
+
'the single cross-axis selector must be gone, or the fix is cosmetic');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('P2 - the three missing loop mechanics are present', () => {
|
|
55
|
+
const src = read();
|
|
56
|
+
for (const m of ['Значковая / встраиваемая', 'Поощряемая реферальная', 'Сетевой эффект']) {
|
|
57
|
+
assert.ok(src.includes(m), 'the loop axis must offer: ' + m);
|
|
58
|
+
}
|
|
59
|
+
// "no loop" must be sayable, or every project claims one it does not have.
|
|
60
|
+
assert.match(src, /Нет петли/, 'absence of a loop must be a choice');
|
|
61
|
+
assert.match(src, /полноценный ответ, а не пропуск/,
|
|
62
|
+
'and must be marked as an answer, not a blank');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('P3 - the artifact does not claim the module was blind to virality', () => {
|
|
66
|
+
// The backlog item's own correction: the K-factor is already tracked and referrals already
|
|
67
|
+
// appear in the TRIZ table. A false problem statement outlives the fix that follows it.
|
|
68
|
+
const src = read();
|
|
69
|
+
assert.ok(/K-фактор|K-factor/.test(src), 'the pre-existing K-factor site must survive the edit');
|
|
70
|
+
assert.ok(/[Рр]еферал|referral/i.test(src), 'and the pre-existing referral mentions');
|
|
71
|
+
for (const claim of ['не знает про виральность', 'слеп к виральности', 'ignores virality']) {
|
|
72
|
+
assert.ok(!src.includes(claim), 'the module must not be accused of what it already did: ' + claim);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('P4 - the compliance checklist covers all three research modes', () => {
|
|
77
|
+
const src = read();
|
|
78
|
+
const c = src.indexOf('### ⚖️ Чеклист допустимости');
|
|
79
|
+
const quick = src.indexOf('### 🟢 Режим QUICK');
|
|
80
|
+
assert.ok(c > 0 && quick > c,
|
|
81
|
+
'the checklist must precede the modes, or it reads as an afterthought to QUICK alone');
|
|
82
|
+
assert.match(compliance(), /все три режима/,
|
|
83
|
+
'and must say it applies to all three, not only the one it sits above');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The two detectors P5 runs. Declared at module scope so P8 can drive them over counter-examples:
|
|
87
|
+
// a detector asserted only against text that happens to be clean proves nothing about its reach.
|
|
88
|
+
//
|
|
89
|
+
// Cross-family review found both too narrow. The statute pattern REQUIRED a leading number, so the
|
|
90
|
+
// ordinary form `Article 6 GDPR` slipped through; the money pattern required a currency marker, so
|
|
91
|
+
// a bare threshold such as `30 дней` did too — and the rule forbids thresholds, not only prices.
|
|
92
|
+
const MONEY = /(?:[0-9][0-9\s,.]*\s*(?:долл|USD|\$|€|руб|₽|%|дн(?:я|ей)|мес|год|лет|час|тыс|млн))|(?:(?:не более|не менее|до|свыше|от)\s+[0-9])/gi;
|
|
93
|
+
const L = '(?<![\\p{L}\\p{N}])'; // a real left boundary, Cyrillic included
|
|
94
|
+
const STATUTE = new RegExp(
|
|
95
|
+
'(?:' + L + '\\d+\\s*(?:CFR|U\\.?S\\.?C|USC|ФЗ))'
|
|
96
|
+
+ '|(?:' + L + '(?:Article|Art\\.|Статья|ст\\.)\\s*\\d+)'
|
|
97
|
+
+ '|(?:' + L + '(?:GDPR|CCPA))'
|
|
98
|
+
+ '|(?:' + L + 'ФЗ[- ]?\\d+)'
|
|
99
|
+
+ '|(?:' + L + '\\d+-ФЗ)', 'giu');
|
|
100
|
+
|
|
101
|
+
test('P5 - no monetary figure and no statute is asserted', () => {
|
|
102
|
+
// THE rule. A number in a template is wrong within a year; a statute is jurisdiction-specific and
|
|
103
|
+
// unverified in this repository. Written so a future edit re-adding either goes red.
|
|
104
|
+
const c = compliance();
|
|
105
|
+
MONEY.lastIndex = 0; STATUTE.lastIndex = 0;
|
|
106
|
+
const money = c.match(MONEY) || [];
|
|
107
|
+
assert.deepEqual(money, [],
|
|
108
|
+
'a monetary figure or threshold appeared in the checklist: ' + JSON.stringify(money));
|
|
109
|
+
const statutes = c.match(STATUTE) || [];
|
|
110
|
+
assert.deepEqual(statutes, [],
|
|
111
|
+
'a statute is asserted as authoritative, and none was verified here: ' + JSON.stringify(statutes));
|
|
112
|
+
assert.match(c, /Ссылайтесь на \*\*норму и на то, где её смотреть\*\*, никогда — на сумму/,
|
|
113
|
+
'the rule itself must be written down, or the absence above is an accident');
|
|
114
|
+
assert.match(c, /Здесь намеренно нет ни одной\s*\n?цифры/,
|
|
115
|
+
'and the absence must be declared deliberate, so a later editor does not "helpfully" add one');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('P6 - it is questions, and it says it is not a gate', () => {
|
|
119
|
+
const c = compliance();
|
|
120
|
+
assert.match(c, /это вопросы, а не утверждения о праве/,
|
|
121
|
+
'a template installed into arbitrary projects cannot assert any project law');
|
|
122
|
+
assert.match(c, /Это не юридическая проверка и не застава/,
|
|
123
|
+
'and it must refuse to be read as a gate — it has no verifiable input');
|
|
124
|
+
assert.match(c, /у неё нет проверяемого входа/, 'with the reason stated');
|
|
125
|
+
assert.match(c, /Ответ «нет» — это находка, а не формальность/,
|
|
126
|
+
'an unanswered question and a cleared one must not look alike');
|
|
127
|
+
assert.ok((c.match(/^\| \d+ \|/gm) || []).length >= 5,
|
|
128
|
+
'the checklist needs enough questions to be worth running');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('P8 - the detectors CATCH the forms a future edit would actually add', () => {
|
|
132
|
+
// A detector run only against clean text is vacuous evidence: it cannot be distinguished from a
|
|
133
|
+
// detector that matches nothing at all. These are the exact strings that slipped through the
|
|
134
|
+
// first version, plus the ones it did catch, so the fix is proven to have widened reach without
|
|
135
|
+
// losing it.
|
|
136
|
+
const mustCatch = [
|
|
137
|
+
['штраф 53088 долларов', MONEY, 'the stale figure this whole rule exists because of'],
|
|
138
|
+
['30 дней', MONEY, 'a bare time threshold — the rule forbids thresholds, not only prices'],
|
|
139
|
+
['не более 3 месяцев', MONEY, 'a threshold written in words plus a number'],
|
|
140
|
+
['до 20% оборота', MONEY, 'a percentage cap'],
|
|
141
|
+
['Article 6 GDPR', STATUTE, 'the ordinary citation form, which needed no leading number'],
|
|
142
|
+
['16 CFR Part 465', STATUTE, 'the US form'],
|
|
143
|
+
['ст. 15 ФЗ-152', STATUTE, 'the Russian form'],
|
|
144
|
+
['152-ФЗ', STATUTE, 'and its other spelling'],
|
|
145
|
+
];
|
|
146
|
+
for (const [text, re, why] of mustCatch) {
|
|
147
|
+
re.lastIndex = 0;
|
|
148
|
+
assert.ok(re.test(text), 'detector missed (' + why + '): ' + JSON.stringify(text));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// And it must not fire on ordinary prose, or the guard becomes noise someone disables.
|
|
152
|
+
const mustNotCatch = ['вопрос 1', 'семь вопросов', 'Ось 2 — ПЕТЛЯ', 'K-фактор'];
|
|
153
|
+
for (const text of mustNotCatch) {
|
|
154
|
+
MONEY.lastIndex = 0; STATUTE.lastIndex = 0;
|
|
155
|
+
assert.ok(!MONEY.test(text) && !STATUTE.test(text),
|
|
156
|
+
'detector false-fires on ordinary text: ' + JSON.stringify(text));
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('P7 - the staleness lesson is recorded where the next editor will read it', () => {
|
|
161
|
+
// The reason for the norm-not-amount rule is a real, dated failure. Keeping the reason next to
|
|
162
|
+
// the rule is what stops the rule from being softened by someone who does not know why it exists.
|
|
163
|
+
const c = compliance();
|
|
164
|
+
assert.match(c, /предупреждал, что цифра позапрошлого\s*\n?года устарела/,
|
|
165
|
+
'the source failure must be recorded');
|
|
166
|
+
assert.match(c, /Предупреждение и ошибка в одном\s*\n?документе/,
|
|
167
|
+
'including what made it instructive');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The Growth Traceability criterion is CONDITIONAL, and that is where the danger is.
|
|
4
|
+
//
|
|
5
|
+
// An unconditional growth threshold sends a project with no acquisition objective (internal tool,
|
|
6
|
+
// on-prem) into a permanent NEEDS WORK loop — the exact trap already closed once for the Measurable
|
|
7
|
+
// criterion. But the cure has its own failure mode, and it is the quieter one: a condition that can
|
|
8
|
+
// never be true makes a gate that never fires, and a gate that never fires reads exactly like a gate
|
|
9
|
+
// that always passes.
|
|
10
|
+
//
|
|
11
|
+
// Recalled lesson (dz recall, 0.85): "a verification probe that CAN run in a mode where it checks
|
|
12
|
+
// nothing is vacuous evidence". So both directions are asserted here — the -10 path must be
|
|
13
|
+
// REACHABLE, and the +0 path must exist for the projects the condition exempts.
|
|
14
|
+
|
|
15
|
+
const { test, describe } = require('node:test');
|
|
16
|
+
const assert = require('node:assert/strict');
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
21
|
+
const VALIDATOR = path.join(TPL, 'skills', 'requirements-validator', 'SKILL.md');
|
|
22
|
+
|
|
23
|
+
const read = () => fs.readFileSync(VALIDATOR, 'utf-8');
|
|
24
|
+
|
|
25
|
+
/** The criterion, from its heading to the next one. */
|
|
26
|
+
function criterion() {
|
|
27
|
+
const src = read();
|
|
28
|
+
const start = src.indexOf('### Growth Traceability');
|
|
29
|
+
assert.ok(start > 0, 'requirements-validator must carry the Growth Traceability criterion');
|
|
30
|
+
const end = src.indexOf('\n### ', start + 1);
|
|
31
|
+
assert.ok(end > start, 'the criterion must be followed by another section');
|
|
32
|
+
return src.slice(start, end);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('the growth gate has teeth AND an exemption, and neither swallows the other', () => {
|
|
36
|
+
test('P1 - the penalty path is reachable and the exempt path is not', () => {
|
|
37
|
+
const c = criterion();
|
|
38
|
+
// REACHABLE: a project with a filled seed table and no trace scores the penalty.
|
|
39
|
+
assert.match(c, /-10 not traced|-10 if the seed table carries rows/,
|
|
40
|
+
'the -10 path must exist for a project the criterion applies to');
|
|
41
|
+
assert.match(c, /≥1 `FR-GROWTH-nnn` row \| YES/,
|
|
42
|
+
'and the applicable row of the table must say YES, or nothing ever reaches -10');
|
|
43
|
+
// EXEMPT: three named situations score +0, and each is a row of the same table.
|
|
44
|
+
for (const exempt of ['says `нет` / is empty', 'internal tool, on-prem', 'is ABSENT']) {
|
|
45
|
+
assert.ok(c.includes(exempt), 'the exemption table must name: ' + exempt);
|
|
46
|
+
}
|
|
47
|
+
// The two must be distinguishable by a reader: an applicability table with only YES rows or
|
|
48
|
+
// only no rows is one of the two failure modes above wearing the other's clothes.
|
|
49
|
+
const yes = (c.match(/\| YES \|/g) || []).length;
|
|
50
|
+
const no = (c.match(/\| no \|/g) || []).length;
|
|
51
|
+
assert.ok(yes >= 1 && no >= 3,
|
|
52
|
+
'both verdicts must be reachable in the table: YES=' + yes + ' no=' + no);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('P2 - an absent brief is +0, never -10, with the reason stated', () => {
|
|
56
|
+
const c = criterion();
|
|
57
|
+
assert.match(c, /An absent brief is \+0, never -10/,
|
|
58
|
+
'the --from-docs entry skips Phase 0 — penalising it would loop those projects forever');
|
|
59
|
+
assert.match(c, /Phase 0 did not run.*is not.*the growth requirements are missing/is,
|
|
60
|
+
'and the two must be distinguished in words, not only in a table cell');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('P3 - the condition is APPLICABILITY, not project type', () => {
|
|
64
|
+
// Gating on type was tried upstream and refuted: replicate.md:171 records that gating M5 on
|
|
65
|
+
// PRODUCT TYPE disabled the one branch M5 declares, and every B2B project got an empty slot.
|
|
66
|
+
// Repeating that mistake one phase later would re-open it.
|
|
67
|
+
const c = criterion();
|
|
68
|
+
assert.match(c, /acquisition or adoption is in scope/,
|
|
69
|
+
'the condition must be the same fact replicate.md gates M5 on');
|
|
70
|
+
assert.match(c, /incl\. B2B/, 'including B2B explicitly, or the refuted type-gate returns');
|
|
71
|
+
assert.match(c, /it is not about project type/i,
|
|
72
|
+
'and the artifact must refuse the type reading out loud');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('P4 - the scoring stays OUTSIDE the 100-point table', () => {
|
|
76
|
+
// validation-gate-teeth.test.js pins 72/100 in its own title, derived from the current weight
|
|
77
|
+
// table. A new weight changes that arithmetic and reddens the test whose job is holding the
|
|
78
|
+
// gate's teeth. The Security criterion already solved this; this one copies the mechanism.
|
|
79
|
+
const c = criterion();
|
|
80
|
+
assert.match(c, /scores OUTSIDE the 100-point/i, 'the criterion must say where it scores');
|
|
81
|
+
assert.match(c, /adds no\s*\n?weight to any existing criterion/i,
|
|
82
|
+
'and that it adds no weight');
|
|
83
|
+
// Structural proof, not a promise: the INVEST/SMART weight headings must be untouched.
|
|
84
|
+
const src = read();
|
|
85
|
+
assert.match(src, /### INVEST Criteria \(User Stories\) — 50% weight/,
|
|
86
|
+
'the INVEST weight must still be 50%');
|
|
87
|
+
assert.match(src, /### SMART Criteria \(Acceptance Criteria\) — 30% weight/,
|
|
88
|
+
'and SMART still 30% — a growth weight here is what reddens validation-gate-teeth');
|
|
89
|
+
assert.ok(!/Growth.*—\s*\d+% weight/i.test(src),
|
|
90
|
+
'no growth criterion may carry a percentage weight');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('P5 - TRACED is defined, and a silent drop is separated from a recorded rejection', () => {
|
|
94
|
+
const c = criterion();
|
|
95
|
+
assert.match(c, /case-sensitive, the exact token/,
|
|
96
|
+
'"mention" must be defined or every reader draws a different line');
|
|
97
|
+
assert.match(c, /not a title, not a paraphrase/i, 'and what does NOT count must be named');
|
|
98
|
+
assert.match(c, /A silently dropped row is the defect/,
|
|
99
|
+
'the defect class must be stated');
|
|
100
|
+
assert.match(c, /A row rejected on the record is not/,
|
|
101
|
+
'and a conscious rejection must be permitted, or the gate forbids saying no');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('P6 - the criterion states its own limit and does not claim legality', () => {
|
|
105
|
+
const c = criterion();
|
|
106
|
+
assert.match(c, /proves an obligation was CARRIED FORWARD, not that it was built/,
|
|
107
|
+
'a traceability gate looks like proof of implementation and must refuse the reading');
|
|
108
|
+
assert.match(c, /Legality is not assessed anywhere in this pipeline/,
|
|
109
|
+
'and legality must be disclaimed here too — it is a separate backlog item');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('P7 - the prose gate points at its deterministic counterpart and does not claim to be one', () => {
|
|
113
|
+
// AR-2: this section is read by a model — layer 3. Calling it layer 1 would be exactly the
|
|
114
|
+
// documented-but-false safety story this project has been burned by.
|
|
115
|
+
const c = criterion();
|
|
116
|
+
assert.match(c, /check-growth-trace\.cjs/, 'the deterministic checker must be named');
|
|
117
|
+
assert.match(c, /This\s*\n?section is a prose gate read by a model/i,
|
|
118
|
+
'and the prose gate must say what it is');
|
|
119
|
+
assert.match(c, /the utility is the deterministic one/,
|
|
120
|
+
'and which of the two is deterministic');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -89,16 +89,34 @@ describe('the growth module is not gated off for the type it handles itself', ()
|
|
|
89
89
|
// This is the load-bearing one. Removing the gate is safe BECAUSE the module decides per type.
|
|
90
90
|
// Without this assertion someone could delete the justification and keep the removal, and the
|
|
91
91
|
// module would then run for B2B with no B2B behaviour.
|
|
92
|
-
|
|
92
|
+
// REWORDED 2026-08-27 by growth-list-and-compliance. The old wording said "sales-led growth,
|
|
93
|
+
// не product-led", treating MOTION and LOOP as one exclusive choice — which the axis split had
|
|
94
|
+
// just refuted, so a B2B company with a sales-led motion and a product-led loop was told to
|
|
95
|
+
// reject the latter. Cross-family review caught the contradiction. The INTENT this test defends
|
|
96
|
+
// is unchanged and still asserted below: the module branches on type, which is what makes the
|
|
97
|
+
// ungated call correct. Only the wording moved, and it now says which axis it speaks about.
|
|
98
|
+
assert.match(read(MODULE), /Если B2B → мотион почти всегда sales-led или partnership-led/,
|
|
93
99
|
'the branch that makes the ungated call correct must still be there');
|
|
100
|
+
assert.match(read(MODULE), /ТОЛЬКО про ось 1: петля выбирается отдельно/,
|
|
101
|
+
'and it must scope itself to one axis, or it re-asserts the exclusivity the split removed');
|
|
94
102
|
});
|
|
95
103
|
|
|
96
104
|
test('P5 — the growth module is byte-identical: this feature touches two callers only', () => {
|
|
97
105
|
// Four byte-identical copies across three published packages hang off this file. Pinning its
|
|
98
106
|
// hash is what keeps a two-line fix from becoming a three-package release by accident.
|
|
107
|
+
//
|
|
108
|
+
// MOVED ONCE, deliberately, 2026-08-27 by growth-requirements-bridge, which added the
|
|
109
|
+
// `Growth Requirements Seed` section and IS the three-package release this message demands.
|
|
110
|
+
// MOVED A THIRD TIME by prebake-skill-paths: the module's own /mnt/ references became
|
|
111
|
+
// .claude/skills/ paths. Pins: 98e8577a… → bd9cfcb8… → 6657dbe2… → this.
|
|
112
|
+
// MOVED AGAIN, same day, by growth-list-and-compliance (two axes + the compliance
|
|
113
|
+
// checklist) — also a three-package release. Pins so far: 98e8577a… → bd9cfcb8… → this.
|
|
114
|
+
// Previous pin: 98e8577a… The tripwire is not weakened by the move — re-pinning is the
|
|
115
|
+
// conscious decision it exists to force, and the accompanying module-copy-identity.test.js now
|
|
116
|
+
// also proves all four copies still agree, which a single hash never could.
|
|
99
117
|
const sha = crypto.createHash('sha256')
|
|
100
118
|
.update(fs.readFileSync(path.join(TPL, MODULE))).digest('hex');
|
|
101
|
-
assert.equal(sha, '
|
|
119
|
+
assert.equal(sha, '84658390dc7beb590f70164b391ace915e42926660bc1015e4be300752b74a37',
|
|
102
120
|
'the growth module changed; that is a three-package release, not this feature');
|
|
103
121
|
});
|
|
104
122
|
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The M5 growth analysis reached Phase 1 as an in-conversation --product-brief value and was never
|
|
4
|
+
// written to disk (sparc-prd-mini/SKILL.md:993-999). MEASURED before this feature:
|
|
5
|
+
// `grep -rn 'growth' sparc-prd-mini/ requirements-validator/` returned 0 hits in these templates.
|
|
6
|
+
//
|
|
7
|
+
// So the filed diagnosis — "no downstream step is obliged to read the analysis" — was one layer off.
|
|
8
|
+
// There was nothing to read. A seed section alone would have written obligations into an artifact
|
|
9
|
+
// that evaporates, which is why P1 assertions here are paired with the persistence ones.
|
|
10
|
+
|
|
11
|
+
const { test, describe } = require('node:test');
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
17
|
+
const REPLICATE = path.join(TPL, 'commands', 'replicate.md');
|
|
18
|
+
const MODULE = path.join(TPL, 'skills', 'reverse-engineering-unicorn', 'modules', '05-growth-engine.md');
|
|
19
|
+
|
|
20
|
+
const read = (f) => fs.readFileSync(f, 'utf-8');
|
|
21
|
+
|
|
22
|
+
/** The seed section, from its heading to the next top-level one. */
|
|
23
|
+
function seedSection() {
|
|
24
|
+
const src = read(MODULE);
|
|
25
|
+
const start = src.indexOf('## 🌱 Growth Requirements Seed');
|
|
26
|
+
assert.ok(start > 0, '05-growth-engine.md must carry the seed section');
|
|
27
|
+
const end = src.indexOf('\n## ', start + 1);
|
|
28
|
+
assert.ok(end > start, 'the seed section must be followed by another section');
|
|
29
|
+
return src.slice(start, end);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('the growth analysis becomes a written, traceable obligation', () => {
|
|
33
|
+
test('P1 - Phase 0 names and writes the brief path', () => {
|
|
34
|
+
const src = read(REPLICATE);
|
|
35
|
+
assert.match(src, /docs\/product-discovery-brief\.md/,
|
|
36
|
+
'Phase 0 must name the exact path it writes, or no consumer can find it');
|
|
37
|
+
|
|
38
|
+
// ORDER is the property, not mere presence: writing after the hand-off would leave Phase 1
|
|
39
|
+
// running against a brief the file does not yet contain.
|
|
40
|
+
const write = src.indexOf('**Write** the full Product Discovery Brief');
|
|
41
|
+
const hand = src.indexOf('pass it to Phase 1 as pre-filled context');
|
|
42
|
+
assert.ok(write > 0 && hand > write,
|
|
43
|
+
'the write must be instructed BEFORE the hand-off: write=' + write + ' handoff=' + hand);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('P2 - the in-context hand-off is preserved, not replaced', () => {
|
|
47
|
+
// FR-A2. Replacing the hand-off with a file read would change what Phase 1 receives and silently
|
|
48
|
+
// break the documented pre-filled-context contract in sparc-prd-mini.
|
|
49
|
+
const src = read(REPLICATE);
|
|
50
|
+
assert.match(src, /pre-filled context/,
|
|
51
|
+
'the hand-off must survive — the file is an ADDITION');
|
|
52
|
+
assert.match(src, /the file is an ADDITION/i,
|
|
53
|
+
'and the artifact must say so, so a later editor does not "simplify" one of the two away');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('P3 - an absent brief means Phase 0 did not run, and the artifact says so', () => {
|
|
57
|
+
// FR-A3. The --from-docs entry skips Phase 0 entirely. Absence read as "no growth requirements"
|
|
58
|
+
// would penalise every --from-docs project forever.
|
|
59
|
+
const src = read(REPLICATE);
|
|
60
|
+
assert.match(src, /absent it means Phase 0 did not run/i,
|
|
61
|
+
'absence must be given its meaning where the path is defined');
|
|
62
|
+
assert.match(src, /NOT evidence that the\s+project has no growth requirements/i,
|
|
63
|
+
'and the wrong reading must be refused explicitly');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('P4 - the seed emits ids with a defined form', () => {
|
|
67
|
+
const s = seedSection();
|
|
68
|
+
assert.match(s, /FR-GROWTH-<nnn>/, 'the id form must be stated');
|
|
69
|
+
assert.match(s, /три цифры, по порядку, номер не переиспользуется/,
|
|
70
|
+
'an id without a uniqueness rule is an intention, not an identifier');
|
|
71
|
+
assert.match(s, /FR-GROWTH-001/, 'and the table must show a concrete row');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('P5 - every row must name its source block', () => {
|
|
75
|
+
const s = seedSection();
|
|
76
|
+
assert.match(s, /Блок-источник/, 'the table needs a source column');
|
|
77
|
+
assert.match(s, /Требование без источника непрослеживаемо/,
|
|
78
|
+
'and the rule must say a sourceless row is not a seed at all');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('P6 - confidence is carried verbatim, never recomputed', () => {
|
|
82
|
+
// The module declares NO numeric threshold and confidence is not one scale across modes
|
|
83
|
+
// (SKILL.md:60-64 — QUICK is a manual X/5, DEEP is a formula). Normalising would invent a number
|
|
84
|
+
// nobody measured, so the rule is verbatim carry plus the SHIPPED [H] convention for doubt.
|
|
85
|
+
const s = seedSection();
|
|
86
|
+
assert.match(s, /Confidence переносится ДОСЛОВНО/, 'verbatim carry must be the rule');
|
|
87
|
+
assert.match(s, /Не пересчитывайте/, 'and recomputation must be refused');
|
|
88
|
+
assert.match(s, /\[H\]/, 'SPECULATIVE must hang on the shipped [H] convention');
|
|
89
|
+
assert.match(s, /НЕ НАЙДЕНО/, 'and on the shipped not-found convention');
|
|
90
|
+
assert.ok(!/confidence\s*[<>]\s*0\.\d/i.test(s),
|
|
91
|
+
'a numeric threshold would be a convention invented here: ' + s.slice(0, 80));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('P7 - the section states BOTH limits in the shipped artifact', () => {
|
|
95
|
+
// A seed table looks like proof of work planned. It is neither proof of building nor of legality.
|
|
96
|
+
const s = seedSection();
|
|
97
|
+
assert.match(s, /Черновик ≠ построено/, 'draft-is-not-built must be written where a reader sees it');
|
|
98
|
+
// RECONCILED 2026-08-27. The first version said flatly "Законность НЕ проверена" — true then,
|
|
99
|
+
// and false the moment the same feature-day added the ⚖️ compliance checklist, which DOES ask.
|
|
100
|
+
// Cross-family review found the contradiction: the seed disclaimed a question the module now
|
|
101
|
+
// puts to the user. The replacement is narrower AND stronger — asked, recorded, not established
|
|
102
|
+
// — and the distinction it draws is the load-bearing half.
|
|
103
|
+
assert.match(s, /Допустимость СПРОШЕНА, но не установлена/,
|
|
104
|
+
'the seed must say the question is asked, since the checklist asks it');
|
|
105
|
+
assert.match(s, /Это не юридическое заключение/,
|
|
106
|
+
'and must refuse to be read as a legal opinion');
|
|
107
|
+
assert.match(s, /не «это законно»/,
|
|
108
|
+
'the difference between "seven questions cleared" and "lawful" is the whole point');
|
|
109
|
+
assert.ok(!/Законность НЕ проверена/.test(s),
|
|
110
|
+
'the old flat disclaimer now contradicts the checklist and must not survive beside it');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('P8 - an empty seed must be written, not omitted', () => {
|
|
114
|
+
// An absent table and a table saying "nothing to seed" are indistinguishable to the next reader,
|
|
115
|
+
// and the checker depends on being able to tell them apart.
|
|
116
|
+
const s = seedSection();
|
|
117
|
+
assert.match(s, /Пустая таблица — тоже ответ/, 'the empty case must be given a shape');
|
|
118
|
+
assert.match(s, /написана словом `нет`/, 'and a concrete token to write');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('P9 - the seed says where it goes, closing the loop it was built to close', () => {
|
|
122
|
+
const s = seedSection();
|
|
123
|
+
assert.match(s, /docs\/product-discovery-brief\.md/, 'the seed must name its carrier file');
|
|
124
|
+
assert.match(s, /docs\/Specification\.md/, 'and its destination');
|
|
125
|
+
assert.match(s, /Фаза 2 проверяет/, 'and name the phase that checks the promotion happened');
|
|
126
|
+
});
|
|
127
|
+
});
|