@dzhechkov/p-replicator 1.6.0 → 1.10.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 +92 -32
- package/CHANGELOG.md +176 -0
- package/README.md +106 -4
- package/package.json +4 -4
- package/sbom.json +181 -31
- package/src/commands/doctor.js +43 -31
- package/src/commands/verify.js +27 -2
- package/src/utils.js +27 -0
- package/templates/.claude/commands/myinsights.md +22 -5
- package/templates/.claude/commands/replicate.md +57 -1
- package/templates/.claude/hooks/check-docs-complete.cjs +202 -0
- package/templates/.claude/hooks/check-growth-trace.cjs +191 -0
- package/templates/.claude/hooks/check-ports.cjs +36 -4
- package/templates/.claude/hooks/statusline.cjs +16 -5
- package/templates/.claude/rules/replicate-pipeline.md +14 -4
- package/templates/.claude/rules/skill-interface-protocol.md +9 -0
- package/templates/.claude/skills/brutal-honesty-review/scripts/assess-code.sh +40 -7
- package/templates/.claude/skills/brutal-honesty-review/scripts/assess-tests.sh +38 -11
- 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/ddd-hooks-commands.md +40 -4
- 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 +25 -23
- package/tests/unit/absence-is-not-emptiness.test.js +255 -0
- package/tests/unit/assess-scripts.test.js +150 -0
- package/tests/unit/check-docs-complete.test.js +292 -0
- package/tests/unit/check-growth-trace.test.js +188 -0
- package/tests/unit/check-ports.test.js +99 -0
- package/tests/unit/generated-guard-templates.test.js +134 -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/guard-forms.test.js +302 -0
- package/tests/unit/insights-docs-tell-the-truth.test.js +84 -0
- package/tests/unit/module-copy-identity.test.js +106 -0
- package/tests/unit/shipped-suite-context.test.js +142 -0
- package/tests/unit/skill-paths-prebaked.test.js +174 -0
- package/tests/unit/sync-templates-guard.test.js +31 -1
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 2 launched a swarm of validation agents over whatever Phase 1 produced, with nothing in
|
|
4
|
+
// between. Existence, emptiness and unfilled placeholders are decidable by forty lines of code, so
|
|
5
|
+
// sending a swarm to discover them is a layer-1 check living at layer 3.
|
|
6
|
+
//
|
|
7
|
+
// The trap this suite exists to hold: an eager placeholder pattern. A bracketed token is also how
|
|
8
|
+
// markdown writes a link and how a citation looks, so a naive /\[.*\]/ refuses legitimate documents
|
|
9
|
+
// — the same eager-gate failure this repo closed for the Measurable criterion and twice today for
|
|
10
|
+
// the growth criterion. P4 is the guard on it.
|
|
11
|
+
|
|
12
|
+
const { test, describe } = require('node:test');
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const { spawnSync } = require('node:child_process');
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
20
|
+
const TPL = path.join(PKG, 'templates', '.claude');
|
|
21
|
+
const CHECK = path.join(TPL, 'hooks', 'check-docs-complete.cjs');
|
|
22
|
+
|
|
23
|
+
const REQUIRED = ['PRD.md', 'Solution_Strategy.md', 'Specification.md', 'Pseudocode.md',
|
|
24
|
+
'Architecture.md', 'Refinement.md', 'Completion.md', 'Research_Findings.md', 'Final_Summary.md'];
|
|
25
|
+
|
|
26
|
+
/** Real prose, comfortably over the emptiness threshold, with no placeholder in it. */
|
|
27
|
+
const REAL = '# Документ\n\n' + 'Содержательный абзац про предметную область проекта. '.repeat(12);
|
|
28
|
+
|
|
29
|
+
/** Build a project and run the real checker over it. */
|
|
30
|
+
function check(files, opts) {
|
|
31
|
+
const o = opts || {};
|
|
32
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-docs-')));
|
|
33
|
+
try {
|
|
34
|
+
if (!o.noDocs) {
|
|
35
|
+
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
|
|
36
|
+
for (const [name, body] of Object.entries(files || {})) {
|
|
37
|
+
fs.writeFileSync(path.join(dir, 'docs', name), body);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8' });
|
|
41
|
+
return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
|
|
42
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const complete = (over) => Object.assign(
|
|
46
|
+
Object.fromEntries(REQUIRED.map((f) => [f, REAL])), over || {});
|
|
47
|
+
|
|
48
|
+
describe('the cheap question is asked before the swarm is spent', () => {
|
|
49
|
+
test('P1 - a complete document set exits 0', () => {
|
|
50
|
+
const r = check(complete());
|
|
51
|
+
assert.equal(r.code, 0, 'a full set must pass: ' + r.out);
|
|
52
|
+
assert.match(r.out, /9 проверено/, r.out);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('P2 - a missing required document exits 1 and is NAMED', () => {
|
|
56
|
+
const files = complete();
|
|
57
|
+
delete files['Specification.md'];
|
|
58
|
+
const r = check(files);
|
|
59
|
+
assert.equal(r.code, 1, r.out);
|
|
60
|
+
assert.match(r.out, /Specification\.md: отсутствует/, 'the missing one must be named: ' + r.out);
|
|
61
|
+
assert.ok(!/PRD\.md/.test(r.out), 'and the present ones must not be: ' + r.out);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('P3 - a document that exists but says nothing exits 1', () => {
|
|
65
|
+
// The case `test -f` misses, and the reason this check is not a one-liner.
|
|
66
|
+
const r = check(complete({ 'Refinement.md': '# Refinement\n' }));
|
|
67
|
+
assert.equal(r.code, 1, r.out);
|
|
68
|
+
assert.match(r.out, /Refinement\.md: пуст или почти пуст/, r.out);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('P4 - markdown links and citations are not placeholders', () => {
|
|
72
|
+
// A naive bracket pattern refuses this document, which is entirely legitimate. An eager gate is
|
|
73
|
+
// not a stricter gate — it is a gate people turn off.
|
|
74
|
+
const withLinks = '# Архитектура\n\n'
|
|
75
|
+
+ 'Смотри [документацию Postgres](https://www.postgresql.org/docs/) и [ADR-001](docs/ADR.md). '
|
|
76
|
+
+ 'Подход описан в [Fowler 2019](https://martinfowler.com/articles/). '.repeat(6);
|
|
77
|
+
const r = check(complete({ 'Architecture.md': withLinks }));
|
|
78
|
+
assert.equal(r.code, 0,
|
|
79
|
+
'markdown links must not read as unfilled placeholders: ' + r.out);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('P5 - a project missing only optional documents passes', () => {
|
|
83
|
+
// ADR.md and C4_Diagrams.md are "(if applicable)" in replicate.md. Demanding them would refuse
|
|
84
|
+
// every project without DDD, forever — the trap already closed for Measurable and for growth.
|
|
85
|
+
const r = check(complete()); // neither optional file is present
|
|
86
|
+
assert.equal(r.code, 0, 'optional absence is a legitimate answer: ' + r.out);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('P13 - the counter-examples cross-family review supplied', () => {
|
|
90
|
+
// Both REPRODUCED as exit 1 before the fix, and both would have been catastrophic:
|
|
91
|
+
// the first blocks EVERY normally generated project, the second deadlocks a documented flow.
|
|
92
|
+
const mermaid = '# Architecture\n\n```mermaid\ngraph TD\n A[Web App] --> B[API]\n'
|
|
93
|
+
+ ' B --> F[(Database)]\n```\n\n' + REAL;
|
|
94
|
+
assert.equal(check(complete({ 'Architecture.md': mermaid })).code, 0,
|
|
95
|
+
'the BUNDLED sparc-prd-mini skill REQUIRES mermaid (SKILL.md:570-583) — blocking on its node '
|
|
96
|
+
+ 'labels would refuse every normally generated Architecture.md');
|
|
97
|
+
|
|
98
|
+
const gap = '# Specification\n\n[GAP: needs performance targets from the client]\n\n' + REAL;
|
|
99
|
+
assert.equal(check(complete({ 'Specification.md': gap })).code, 0,
|
|
100
|
+
'Phase 1 writes [GAP: ...] deliberately in --from-docs mode for Phase 2 to resolve '
|
|
101
|
+
+ '(replicate.md:82-94). Blocking deadlocks it: the only step that can clear the marker is '
|
|
102
|
+
+ 'the one this gate would refuse to start');
|
|
103
|
+
|
|
104
|
+
const citations = '# Research_Findings\n\nПо данным [1] и [^2] рынок растёт.\n\n' + REAL;
|
|
105
|
+
assert.equal(check(complete({ 'Research_Findings.md': citations })).code, 0,
|
|
106
|
+
'citations and footnotes are not placeholders');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('P14 - bracketed prose WARNS but never blocks', () => {
|
|
110
|
+
// The design change the review forced. This script cannot tell `[описание продукта]` from a
|
|
111
|
+
// diagram label without understanding the document, and a false block stops the whole pipeline —
|
|
112
|
+
// strictly worse than a missed placeholder the Phase-2 swarm would catch anyway. So the two
|
|
113
|
+
// confidence levels are separated, and the uncertain one is not given the power to refuse.
|
|
114
|
+
const r = check(complete({ 'PRD.md': '# PRD\n\n[описание продукта]\n\n' + REAL }));
|
|
115
|
+
assert.equal(r.code, 0, 'bracketed prose must not block: ' + r.out);
|
|
116
|
+
assert.match(r.out, /НЕ блокирует/, 'and must say plainly that it does not: ' + r.out);
|
|
117
|
+
assert.match(r.out, /PRD\.md: возможно незаполнено/, 'while still naming it: ' + r.out);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('P6 - an UNAMBIGUOUS placeholder is caught, in both blocking vocabularies', () => {
|
|
121
|
+
// Narrowed by the review: the bracketed-prose vocabulary moved to a warning (P14). What blocks
|
|
122
|
+
// is what cannot be anything else.
|
|
123
|
+
for (const [body, why] of [
|
|
124
|
+
['# PRD\n\n{{company_name}} строит продукт.\n\n' + REAL, 'a mustache placeholder'],
|
|
125
|
+
['# PRD\n\nTODO: дописать сегменты.\n\n' + REAL, 'a TODO marker'],
|
|
126
|
+
]) {
|
|
127
|
+
const r = check(complete({ 'PRD.md': body }));
|
|
128
|
+
assert.equal(r.code, 1, why + ' must block: ' + r.out);
|
|
129
|
+
assert.match(r.out, /PRD\.md: остались/, r.out);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('P16 - a markdown task-list checkbox is NOT a placeholder', () => {
|
|
134
|
+
// MEASURED against a real completed /replicate project: 17 lines like `- [ ] AC покрыты
|
|
135
|
+
// автотестами` were reported as "possibly unfilled". Worse, that project's own Completion.md
|
|
136
|
+
// TEACHES the notation — "флажки [ ] при каждом FR-GROWTH-00N" — so this warning fired on the
|
|
137
|
+
// convention the pipeline itself prescribes. Noise on a legitimate convention trains people to
|
|
138
|
+
// ignore warnings, which is the failure this checker exists to prevent, one level down.
|
|
139
|
+
const withBoxes = '# Specification\n\n'
|
|
140
|
+
+ '- [ ] AC покрыты автотестами\n'
|
|
141
|
+
+ '- [x] События пишутся в аналитику\n'
|
|
142
|
+
+ '* [ ] Anti-fraud сценарий задокументирован\n'
|
|
143
|
+
+ '| `invite_shown` | [ ] | Share-CTA показан в момент первого рендера |\n\n' + REAL;
|
|
144
|
+
const r = check(complete({ 'Specification.md': withBoxes }));
|
|
145
|
+
assert.equal(r.code, 0, 'checkboxes must not block: ' + r.out);
|
|
146
|
+
assert.ok(!/Specification\.md: возможно незаполнено/.test(r.out),
|
|
147
|
+
'and must not even WARN — a warning on the pipeline own convention is noise: ' + r.out);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('P17 - a real bracketed placeholder on a checkbox LINE is still seen', () => {
|
|
151
|
+
// The narrowing must not become an escape hatch: skipping the whole line would let a genuine
|
|
152
|
+
// placeholder hide behind a checkbox prefix.
|
|
153
|
+
const r = check(complete({
|
|
154
|
+
'Specification.md': '# Spec\n\n- [ ] [описание критерия приёмки]\n\n' + REAL,
|
|
155
|
+
}));
|
|
156
|
+
assert.match(r.out, /возможно незаполнено/,
|
|
157
|
+
'a bracketed placeholder AFTER a checkbox must still be reported: ' + r.out);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('P18 - Final_Summary.md is reported when absent, and does not block', () => {
|
|
161
|
+
// MEASURED: the pipeline promises it in three places — replicate.md and two sites in
|
|
162
|
+
// sparc-prd-mini, including a whole SYNTHESIS phase — and a real completed project produced
|
|
163
|
+
// 8 of 9 promised documents WITHOUT it. One project is not enough evidence to decide whether
|
|
164
|
+
// the pipeline is broken or the document is optional in practice, and BLOCKING on it would
|
|
165
|
+
// have refused every project that ran like that one.
|
|
166
|
+
// The shared fixture DOES write it, so this case removes it deliberately — the state a real
|
|
167
|
+
// completed project was measured in.
|
|
168
|
+
const files = complete();
|
|
169
|
+
delete files['Final_Summary.md'];
|
|
170
|
+
const r = check(files);
|
|
171
|
+
assert.equal(r.code, 0, 'its absence must not block: ' + r.out);
|
|
172
|
+
assert.match(r.out, /Final_Summary\.md: отсутствует, хотя конвейер его обещает/,
|
|
173
|
+
'but the discrepancy must be NAMED, not silently tolerated: ' + r.out);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('P7 - no docs directory exits 2, and an empty one does too', () => {
|
|
177
|
+
const none = check({}, { noDocs: true });
|
|
178
|
+
assert.equal(none.code, 2, 'nothing to check is not "complete": ' + none.out);
|
|
179
|
+
assert.match(none.out, /проверка НЕ выполнена/, none.out);
|
|
180
|
+
assert.match(none.out, /Фаза 1 ещё не отработала/, 'the reason must be named: ' + none.out);
|
|
181
|
+
|
|
182
|
+
const empty = check({});
|
|
183
|
+
assert.equal(empty.code, 2, 'an empty docs/ is not "complete" either: ' + empty.out);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('P10 - one binary, all three verdicts in a single run', () => {
|
|
187
|
+
const seen = [
|
|
188
|
+
check(complete()).code,
|
|
189
|
+
check(Object.assign(complete(), { 'PRD.md': '# PRD\n' })).code,
|
|
190
|
+
check({}, { noDocs: true }).code,
|
|
191
|
+
];
|
|
192
|
+
assert.deepEqual(seen, [0, 1, 2], 'expected complete/incomplete/could-not-check: '
|
|
193
|
+
+ JSON.stringify(seen));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('P11 - Phase 2 runs it BEFORE the swarm and refuses on exit 1', () => {
|
|
197
|
+
const src = fs.readFileSync(path.join(TPL, 'commands', 'replicate.md'), 'utf-8');
|
|
198
|
+
const start = src.indexOf('### Phase 2: VALIDATION');
|
|
199
|
+
const end = src.indexOf('\n### Phase 3', start + 1);
|
|
200
|
+
assert.ok(start > 0 && end > start, 'replicate.md must have a Phase 2');
|
|
201
|
+
const phase2 = src.slice(start, end);
|
|
202
|
+
|
|
203
|
+
const call = phase2.indexOf('check-docs-complete.cjs');
|
|
204
|
+
const swarm = phase2.indexOf('Swarm of Validation Agents');
|
|
205
|
+
assert.ok(call > 0, 'Phase 2 must invoke the check');
|
|
206
|
+
assert.ok(swarm > call,
|
|
207
|
+
'the check must precede the swarm, or it cannot save the swarm: call=' + call + ' swarm=' + swarm);
|
|
208
|
+
assert.match(phase2, /\*\*НЕ запускайте рой\.\*\*/,
|
|
209
|
+
'exit 1 must stop the swarm, or the check is advice');
|
|
210
|
+
assert.match(phase2, /это НЕ «всё в порядке»/,
|
|
211
|
+
'exit 2 must be refused as a pass, in the artifact a reader sees');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('P8 - prepublishOnly runs the snapshot test', () => {
|
|
215
|
+
// tests/ is in files[] (MEASURED: 29 test files in npm pack --dry-run), so baseline.json SHIPS.
|
|
216
|
+
// prepublishOnly ran only the signature gate, which signs whatever is present — dz sign prints
|
|
217
|
+
// the limit itself: "Ed25519 gives tamper-evidence, never truthfulness." A drifted baseline was
|
|
218
|
+
// therefore signed, shipped and wrong. The source backlog item claimed publish was already
|
|
219
|
+
// blocked; its own ground-check caught that this was false.
|
|
220
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf-8'));
|
|
221
|
+
const pre = pkg.scripts.prepublishOnly || '';
|
|
222
|
+
assert.match(pre, /tests\/snapshot\/templates\.test\.js/,
|
|
223
|
+
'a drifted baseline must not be publishable: ' + pre);
|
|
224
|
+
assert.ok(pre.indexOf('templates.test.js') < pre.indexOf('prepublish-gate'),
|
|
225
|
+
'the snapshot must run BEFORE signing, or a drifted baseline gets signed first: ' + pre);
|
|
226
|
+
assert.ok((pkg.files || []).includes('tests/'),
|
|
227
|
+
'this assertion only matters because tests/ ships — if that changes, revisit');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('P9 - it is a hooks component wired to NO event', () => {
|
|
231
|
+
const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
|
|
232
|
+
assert.ok(COMPONENTS.hooks.items['check-docs-complete'], 'it must be registered');
|
|
233
|
+
const settings = fs.readFileSync(path.join(TPL, 'settings.json'), 'utf-8');
|
|
234
|
+
assert.ok(!settings.includes('check-docs-complete'),
|
|
235
|
+
'this packages hooks are non-blocking by contract; a hook could print but never refuse');
|
|
236
|
+
const statusline = fs.readFileSync(path.join(TPL, 'hooks', 'statusline.cjs'), 'utf-8');
|
|
237
|
+
const m = statusline.match(/hooksExpected:\s*(\d+)/);
|
|
238
|
+
assert.equal(Number(m[1]), Object.keys(COMPONENTS.hooks.items).length,
|
|
239
|
+
'the status line would report a phantom missing hook: ' + m[1]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('P12 - the artifact states its own limit', () => {
|
|
243
|
+
const r = check(complete());
|
|
244
|
+
assert.match(r.out, /документы НАПИСАНЫ, а не что они верны/,
|
|
245
|
+
'a completeness pass looks like a correctness pass and must refuse the reading: ' + r.out);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ── Appended: the resume half of backlog 58575b07 ─────────────────────────────────────────────
|
|
250
|
+
// The item asked for automated resume detection. Evidence gathered 2026-08-27 says the automation
|
|
251
|
+
// is not the valuable half: /replicate has FOUR interactive checkpoints (MEASURED: `grep -c
|
|
252
|
+
// 'Checkpoint:'`), commits after EVERY phase (replicate-pipeline.md:134-137), and — since this same
|
|
253
|
+
// feature — a deterministic docs-completeness answer. The item's own verifier said the payoff of
|
|
254
|
+
// automation is "modest even post-demo" for a pipeline where a human sits at every checkpoint.
|
|
255
|
+
//
|
|
256
|
+
// So what shipped is the documentation of the three signals that already exist, plus the recorded
|
|
257
|
+
// decision NOT to add branching logic. This test holds that record: a section that says "we decided
|
|
258
|
+
// not to" is worth nothing if the next reader cannot find the reasoning.
|
|
259
|
+
|
|
260
|
+
const { test: resumeTest, describe: resumeDescribe } = require('node:test');
|
|
261
|
+
|
|
262
|
+
resumeDescribe('an interrupted run can be resumed from what already exists', () => {
|
|
263
|
+
resumeTest('P13 - the three existing resume signals are named, with their commands', () => {
|
|
264
|
+
const src = fs.readFileSync(path.join(TPL, 'commands', 'replicate.md'), 'utf-8');
|
|
265
|
+
const start = src.indexOf('### Прерванный прогон');
|
|
266
|
+
assert.ok(start > 0, 'replicate.md must tell a user how to resume');
|
|
267
|
+
const end = src.indexOf('\n### ', start + 1);
|
|
268
|
+
const sec = src.slice(start, end > start ? end : undefined);
|
|
269
|
+
|
|
270
|
+
assert.match(sec, /git log --oneline/, 'the per-phase commits are the phase marker');
|
|
271
|
+
assert.match(sec, /check-docs-complete\.cjs/, 'the deterministic completeness answer');
|
|
272
|
+
assert.match(sec, /p-replicator verify/, 'and the Phase-3 toolkit signal');
|
|
273
|
+
assert.match(sec, /продолжай с Фазы 3/,
|
|
274
|
+
'and a concrete sentence to say, not just a list of probes');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
resumeTest('P14 - the decision NOT to automate is recorded with its reasoning', () => {
|
|
278
|
+
// A skipped item leaves no trace unless the skip is written down. Without this the next reader
|
|
279
|
+
// re-derives the question from scratch, or builds the thing that was deliberately not built.
|
|
280
|
+
const src = fs.readFileSync(path.join(TPL, 'commands', 'replicate.md'), 'utf-8');
|
|
281
|
+
const start = src.indexOf('### Прерванный прогон');
|
|
282
|
+
const end = src.indexOf('\n### ', start + 1);
|
|
283
|
+
const sec = src.slice(start, end > start ? end : undefined);
|
|
284
|
+
|
|
285
|
+
assert.match(sec, /сознательно НЕ реализовано/, 'the decision must be stated');
|
|
286
|
+
assert.match(sec, /бэклог `58575b07`/, 'with the item it answers');
|
|
287
|
+
assert.match(sec, /свежая логика ветвления в\s*\n?интерактивном конвейере/,
|
|
288
|
+
'and the reason, so a future reader can disagree with the reason rather than guess it');
|
|
289
|
+
assert.match(sec, /начинайте с\s*\n?того, что перечисленного выше оказалось недостаточно/,
|
|
290
|
+
'and the condition under which revisiting it is warranted');
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -176,6 +176,105 @@ describe('the storage-port rule finally has a check that can fail', () => {
|
|
|
176
176
|
'a non-storage service with no proxy present is not this rule\'s business: ' + r.out);
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
test('P13 — every argument shape resolves in one frame', { skip: !DOCKER }, () => {
|
|
180
|
+
// The defect: `-f` stayed RELATIVE while the same spawnSync call overrode docker's cwd to the
|
|
181
|
+
// directory that relative path already named, so docker re-applied the directory component.
|
|
182
|
+
// MEASURED before the fix: `check-ports.cjs projects/01` from the parent reported
|
|
183
|
+
// "open /tmp/X/projects/01/projects/01/docker-compose.yml".
|
|
184
|
+
//
|
|
185
|
+
// PR-019 diagnosed a project-root join. There is no such join in the file — proven in pure shell:
|
|
186
|
+
// the SAME `-f projects/01/docker-compose.yml` succeeds from /tmp/X and doubles from
|
|
187
|
+
// /tmp/X/projects/01. The cwd override IS the mechanism.
|
|
188
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-frame-')));
|
|
189
|
+
try {
|
|
190
|
+
const proj = path.join(root, 'projects', '01');
|
|
191
|
+
fs.mkdirSync(proj, { recursive: true });
|
|
192
|
+
fs.writeFileSync(path.join(proj, 'docker-compose.yml'),
|
|
193
|
+
'services:\n db:\n image: postgres:16\n');
|
|
194
|
+
// Every shape in ONE run: a matrix asserted from one side only cannot be told from a constant.
|
|
195
|
+
const shapes = [
|
|
196
|
+
['projects/01', root, 'a bare relative directory — the shape that was broken'],
|
|
197
|
+
['./projects/01', root, 'the ./-prefixed form, which path.join normalises identically'],
|
|
198
|
+
['projects/01/docker-compose.yml', root, 'a relative path to the file itself'],
|
|
199
|
+
['.', proj, 'the documented invocation — worked before only because it is idempotent'],
|
|
200
|
+
[null, proj, 'no argument at all'],
|
|
201
|
+
['../01', proj, 'a sibling-relative path'],
|
|
202
|
+
[proj, root, 'absolute, from an unrelated cwd'],
|
|
203
|
+
[proj, proj, 'absolute, from inside'],
|
|
204
|
+
];
|
|
205
|
+
for (const [arg, cwd, why] of shapes) {
|
|
206
|
+
const argv = arg === null ? [CHECK] : [CHECK, arg];
|
|
207
|
+
const r = spawnSync(process.execPath, argv, { encoding: 'utf8', cwd });
|
|
208
|
+
assert.equal(r.status, 0,
|
|
209
|
+
'shape must resolve (' + why + '): arg=' + JSON.stringify(arg) + ' cwd=' + cwd
|
|
210
|
+
+ ' -> exit ' + r.status + ': ' + (r.stdout || '') + (r.stderr || ''));
|
|
211
|
+
}
|
|
212
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('P14 — run from a PARENT cwd with a relative argument', { skip: !DOCKER }, () => {
|
|
216
|
+
// The blind spot that let this ship green. Every other case here builds its fixture with
|
|
217
|
+
// realpathSync(mkdtempSync(...)) and passes it whole — ALWAYS ABSOLUTE — so all 12 tests passed
|
|
218
|
+
// against the live defect. A regression test that cannot reproduce the bug it guards is not a
|
|
219
|
+
// guard. This case is the one that could.
|
|
220
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-parent-')));
|
|
221
|
+
try {
|
|
222
|
+
const proj = path.join(root, 'projects', '01');
|
|
223
|
+
fs.mkdirSync(proj, { recursive: true });
|
|
224
|
+
fs.writeFileSync(path.join(proj, 'docker-compose.yml'),
|
|
225
|
+
'services:\n db:\n image: postgres:16\n ports: ["5432:5432"]\n');
|
|
226
|
+
const r = spawnSync(process.execPath, [CHECK, 'projects/01'], { encoding: 'utf8', cwd: root });
|
|
227
|
+
// A real violation must be REPORTED, not lost behind a path error.
|
|
228
|
+
assert.equal(r.status, 1, 'expected the published-storage violation: ' + r.stdout + r.stderr);
|
|
229
|
+
assert.match(r.stdout, /Правило №0/, r.stdout);
|
|
230
|
+
assert.ok(!/projects\/01\/projects\/01/.test(r.stdout + r.stderr),
|
|
231
|
+
'the doubled path must be gone: ' + r.stdout + r.stderr);
|
|
232
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('P15 — a required variable is reported as itself, not masked', { skip: !DOCKER }, () => {
|
|
236
|
+
// The defect fired BEFORE docker parsed the file, so a genuine config error came back as
|
|
237
|
+
// "no such file". Second harm, and the one a user would waste the most time on.
|
|
238
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-mask-')));
|
|
239
|
+
try {
|
|
240
|
+
const proj = path.join(root, 'projects', '04');
|
|
241
|
+
fs.mkdirSync(proj, { recursive: true });
|
|
242
|
+
fs.writeFileSync(path.join(proj, 'docker-compose.yml'),
|
|
243
|
+
'services:\n db:\n image: postgres:${PGTAG:?PGTAG must be set}\n');
|
|
244
|
+
const r = spawnSync(process.execPath, [CHECK, 'projects/04'], { encoding: 'utf8', cwd: root });
|
|
245
|
+
assert.equal(r.status, 2, 'an unreadable config is still exit 2: ' + r.stdout);
|
|
246
|
+
assert.match(r.stdout, /PGTAG/, 'the REAL cause must reach the user: ' + r.stdout);
|
|
247
|
+
assert.ok(!/no such file/i.test(r.stdout),
|
|
248
|
+
'the path error must not stand in for the config error: ' + r.stdout);
|
|
249
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('P16 — the spawn overrides no cwd, and the hint neither guesses nor refutes itself', () => {
|
|
253
|
+
// Structural, so the removed line cannot creep back. MEASURED on Compose v5.1.1: project name,
|
|
254
|
+
// relative build contexts, .env discovery and env_file are ALL derived from the first -f file's
|
|
255
|
+
// directory — a .env in the process cwd was not even used as a fallback. The override's only
|
|
256
|
+
// demonstrated effect in this file's history is the defect.
|
|
257
|
+
const src = fs.readFileSync(CHECK, 'utf-8');
|
|
258
|
+
const at = src.indexOf('spawnSync(');
|
|
259
|
+
const spawnCall = src.slice(at, at + 300);
|
|
260
|
+
assert.ok(!/cwd:/.test(spawnCall),
|
|
261
|
+
'the cwd override must be GONE, not neutralised — a provably-useless line still encodes the '
|
|
262
|
+
+ 'false premise that regrows the class: ' + spawnCall);
|
|
263
|
+
// The guessed cause named an UNREACHABLE case: a plain unset ${VAR} makes compose exit 0.
|
|
264
|
+
//
|
|
265
|
+
// Asserted on CODE, not on the file: the fix's own comment QUOTES the removed phrase to explain
|
|
266
|
+
// why it went, and a whole-file `includes` cannot tell a mention from a use. That is the same
|
|
267
|
+
// trap this repo has hit repeatedly, and it caught this test on its first run.
|
|
268
|
+
const code = src.split('\n')
|
|
269
|
+
.filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*') && !l.trim().startsWith('/*'))
|
|
270
|
+
.join('\n');
|
|
271
|
+
assert.ok(!code.includes('обычно это незаданная переменная'),
|
|
272
|
+
'the hint must not name a cause that cannot produce this exit');
|
|
273
|
+
assert.match(code, /повторить ровно то, что делали мы/,
|
|
274
|
+
'and the cure must be presented as a reproduction of OUR invocation');
|
|
275
|
+
assert.match(src, /path\.resolve\(process\.cwd\(\)/,
|
|
276
|
+
'the argument must be absolutised once, at the boundary');
|
|
277
|
+
});
|
|
179
278
|
test('P9 — a clean compose exits 0', { skip: !DOCKER }, () => {
|
|
180
279
|
const r = check('services:\n db:\n image: postgres:16\n'
|
|
181
280
|
+ ' web:\n image: node:22\n ports: ["3000:3000"]\n');
|