@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.
- package/.dz-manifest.json +173 -65
- package/CHANGELOG.md +135 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +2 -2
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/05_architecture.md +6 -2
- package/README/eng/README.md +1 -1
- package/README/ru/01_quickstart.md +2 -2
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +1 -1
- package/README/ru/html/index.html +7 -7
- package/README.md +41 -18
- package/bin/cli.js +0 -0
- package/package.json +11 -10
- package/sbom.json +347 -77
- package/src/utils.js +2 -0
- package/templates/.claude/agents/doc-validator.md +2 -1
- package/templates/.claude/agents/product-discoverer.md +1 -1
- package/templates/.claude/commands/next.md +16 -0
- package/templates/.claude/commands/replicate.md +126 -8
- package/templates/.claude/commands/start.md +19 -1
- package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
- package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
- package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
- package/templates/.claude/hooks/check-ports.cjs +232 -0
- package/templates/.claude/hooks/session-insights.cjs +13 -1
- package/templates/.claude/hooks/state-update.cjs +13 -1
- package/templates/.claude/hooks/statusline.cjs +145 -18
- package/templates/.claude/rules/docker-ports.md +123 -0
- package/templates/.claude/rules/replicate-pipeline.md +5 -2
- package/templates/.claude/settings.json +5 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
- package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
- package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
- package/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
- package/tests/e2e/lifecycle.test.js +21 -10
- package/tests/snapshot/baseline.json +51 -34
- package/tests/snapshot/update-baseline.js +2 -1
- package/tests/unit/adr-decision-coverage.test.js +137 -0
- package/tests/unit/adr-scanner-contract.test.js +108 -0
- package/tests/unit/autocommit-deletion.test.js +242 -0
- package/tests/unit/check-ports.test.js +184 -0
- package/tests/unit/db-port-rule.test.js +216 -0
- package/tests/unit/detect-parse-anchor.test.js +109 -0
- package/tests/unit/external-dependency-check.test.js +209 -0
- package/tests/unit/growth-module-b2b-gate.test.js +104 -0
- package/tests/unit/hooks-project-anchored.test.js +223 -0
- package/tests/unit/hooks-report-failures.test.js +207 -0
- package/tests/unit/pipeline-file-ownership.test.js +95 -0
- package/tests/unit/roadmap-one-schema.test.js +179 -0
- package/tests/unit/sparc-reconciliation.test.js +117 -0
- package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
- package/tests/unit/statusline-honest-labels.test.js +178 -0
- package/tests/unit/statusline-two-roots.test.js +237 -0
- package/tests/unit/sync-templates-guard.test.js +209 -0
- package/tests/unit/utils.test.js +2 -2
- package/tests/unit/validation-gate-teeth.test.js +158 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The status line answered TWO questions with ONE anchor, so it told half the truth from every
|
|
4
|
+
// directory. MEASURED before the fix, with the toolkit at a course root and a 13-feature roadmap one
|
|
5
|
+
// level down in projects/01-app:
|
|
6
|
+
//
|
|
7
|
+
// from the course root: "Roadmap — no roadmap yet" Toolkit Skills 10/10, Hooks 6/6
|
|
8
|
+
// from projects/01-app: "Roadmap Done 0/13" Toolkit Skills 0/10, Hooks 0/6, Settings missing
|
|
9
|
+
//
|
|
10
|
+
// WHERE THE INSTRUMENTS ARE is settled by the hook's own location and is correct from any cwd.
|
|
11
|
+
// WHICH PROJECT'S ROADMAP TO SHOW is a different question, answered by surveying projects/*.
|
|
12
|
+
//
|
|
13
|
+
// The report also proposed walking UPWARD to find the project. Measured: the roadmap lies BELOW, so
|
|
14
|
+
// an upward walk returns the same root and the line does not change — the remedy does not treat its
|
|
15
|
+
// own symptom. Not implemented; the backlog item orders it third.
|
|
16
|
+
//
|
|
17
|
+
// These tests run the REAL hook as a real subprocess and read its REAL rendered output.
|
|
18
|
+
|
|
19
|
+
const { test, describe } = require('node:test');
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
const { execFileSync } = require('node:child_process');
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const PKG_DIR = path.resolve(__dirname, '..', '..');
|
|
27
|
+
const TPL = path.join(PKG_DIR, 'templates', '.claude');
|
|
28
|
+
const HOOK_SRC = path.join(TPL, 'hooks', 'statusline.cjs');
|
|
29
|
+
|
|
30
|
+
function writeRoadmap(dotClaudeDir, spec) {
|
|
31
|
+
const total = spec.total;
|
|
32
|
+
const done = spec.done || 0;
|
|
33
|
+
const mvp = spec.mvp || 0;
|
|
34
|
+
const features = [];
|
|
35
|
+
for (let i = 0; i < total; i++) {
|
|
36
|
+
features.push({
|
|
37
|
+
id: 'f' + i,
|
|
38
|
+
status: i < done ? 'done' : 'next',
|
|
39
|
+
// 'priority' is the package's own documented key — commands/next.md:66 — NOT 'tags'. The field
|
|
40
|
+
// report claimed the reader was wrong here; measuring the schema refuted that, and rewriting
|
|
41
|
+
// the reader toward 'tags' would break the documented contract. Filed separately: 4ca0970c.
|
|
42
|
+
// 'low', not 'later': the priority set is CLOSED (mvp|high|medium|low) and this fixture
|
|
43
|
+
// used a value in no enum — an off-schema roadmap of my own making, which the schema
|
|
44
|
+
// marker correctly flagged the moment it existed.
|
|
45
|
+
priority: i < mvp ? 'mvp' : 'low',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
fs.writeFileSync(path.join(dotClaudeDir, 'feature-roadmap.json'),
|
|
49
|
+
JSON.stringify({ version: '1.0', features }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A course-shaped fixture: the toolkit at the root, sub-projects below. */
|
|
53
|
+
function course(opts) {
|
|
54
|
+
const o = opts || {};
|
|
55
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-2roots-')));
|
|
56
|
+
fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true });
|
|
57
|
+
fs.copyFileSync(HOOK_SRC, path.join(root, '.claude', 'hooks', 'statusline.cjs'));
|
|
58
|
+
if (o.toolkit !== false) {
|
|
59
|
+
for (let i = 1; i <= 10; i++) {
|
|
60
|
+
fs.mkdirSync(path.join(root, '.claude', 'skills', 's' + i), { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
fs.copyFileSync(path.join(TPL, 'settings.json'), path.join(root, '.claude', 'settings.json'));
|
|
63
|
+
}
|
|
64
|
+
if (o.rootRoadmap) writeRoadmap(path.join(root, '.claude'), o.rootRoadmap);
|
|
65
|
+
for (const [name, spec] of Object.entries(o.subs || {})) {
|
|
66
|
+
const dir = path.join(root, 'projects', name, '.claude');
|
|
67
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
68
|
+
if (spec === 'malformed') fs.writeFileSync(path.join(dir, 'feature-roadmap.json'), '{not json');
|
|
69
|
+
else writeRoadmap(dir, spec);
|
|
70
|
+
}
|
|
71
|
+
return root;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const STRIP = new RegExp('\\x1b\\[[0-9;]*m', 'g');
|
|
75
|
+
|
|
76
|
+
/** Run the hook from `cwd`, with the host naming `projectRoot`. Returns plain text. */
|
|
77
|
+
function render(root, opts) {
|
|
78
|
+
const o = opts || {};
|
|
79
|
+
const cwd = o.cwd || root;
|
|
80
|
+
const env = Object.assign({}, process.env, { CLAUDE_PROJECT_DIR: o.projectRoot || root });
|
|
81
|
+
try {
|
|
82
|
+
const out = execFileSync(process.execPath,
|
|
83
|
+
[path.join(root, '.claude', 'hooks', 'statusline.cjs')],
|
|
84
|
+
{ cwd, env, encoding: 'utf8', stdio: 'pipe' });
|
|
85
|
+
return { code: 0, text: (out || '').replace(STRIP, '') };
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return { code: err.status == null ? 1 : err.status,
|
|
88
|
+
text: (err.stdout ? err.stdout.toString() : '').replace(STRIP, '') };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const line = (text, marker) => (text.split('\n').find((l) => l.includes(marker)) || '');
|
|
93
|
+
const cleanup = (d) => fs.rmSync(d, { recursive: true, force: true });
|
|
94
|
+
|
|
95
|
+
describe('the status line answers two questions with two anchors (PR-011)', () => {
|
|
96
|
+
test('P1 — from a subdirectory, the toolkit counts are the ROOT\'s, not zeros', () => {
|
|
97
|
+
const root = course({ subs: { '01-app': { total: 13 } } });
|
|
98
|
+
try {
|
|
99
|
+
const r = render(root, { cwd: path.join(root, 'projects', '01-app') });
|
|
100
|
+
const toolkit = line(r.text, 'Toolkit');
|
|
101
|
+
assert.match(toolkit, /Skills\s+.?10\/10/,
|
|
102
|
+
'the toolkit lives at the root and must be counted there from any cwd: ' + toolkit);
|
|
103
|
+
// Derived, not typed: the expected total is whatever the package registers, so adding a hook
|
|
104
|
+
// moves this with it instead of turning it red for a correct reason.
|
|
105
|
+
const expectedHooks = Object.keys(
|
|
106
|
+
require(path.join(PKG_DIR, 'src', 'utils.js')).COMPONENTS.hooks.items).length;
|
|
107
|
+
assert.match(toolkit, new RegExp('Hooks\\s+.?1\\/' + expectedHooks),
|
|
108
|
+
'and the hook count too — the fixture ships exactly one hook: ' + toolkit);
|
|
109
|
+
// Cross-family QE: `line()` returns '' when the segment is absent, and '' trivially satisfies
|
|
110
|
+
// doesNotMatch — so dropping the Settings segment entirely would have passed. Presence first.
|
|
111
|
+
const settings = line(r.text, 'Settings');
|
|
112
|
+
assert.notEqual(settings, '', 'the Settings segment must be rendered at all');
|
|
113
|
+
assert.doesNotMatch(settings, /missing/,
|
|
114
|
+
'settings.json is at the root; reporting it missing from a subdirectory is the old defect');
|
|
115
|
+
} finally { cleanup(root); }
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('P2 — root has no roadmap, a sub-project has 13: the line reports those 13', () => {
|
|
119
|
+
// The exact measured symptom: "no roadmap yet" while thirteen features sat one level down.
|
|
120
|
+
const root = course({ subs: { '01-app': { total: 13, done: 4 } } });
|
|
121
|
+
try {
|
|
122
|
+
const roadmap = line(render(root).text, 'Roadmap');
|
|
123
|
+
assert.doesNotMatch(roadmap, /no roadmap yet/,
|
|
124
|
+
'a roadmap exists one level down; claiming none is the defect: ' + roadmap);
|
|
125
|
+
assert.match(roadmap, /4\/13/, 'the sub-project figures must appear: ' + roadmap);
|
|
126
|
+
} finally { cleanup(root); }
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('P3 — sub-project figures are LABELLED as theirs, never presented as the root\'s own', () => {
|
|
130
|
+
// A number that means something other than it appears to is worse than a missing number.
|
|
131
|
+
const root = course({ subs: { '01-app': { total: 13, done: 4 } } });
|
|
132
|
+
try {
|
|
133
|
+
const roadmap = line(render(root).text, 'Roadmap');
|
|
134
|
+
assert.match(roadmap, /in\s+01-app/,
|
|
135
|
+
'the line must say WHERE the figures come from: ' + roadmap);
|
|
136
|
+
assert.doesNotMatch(roadmap, /mvp/,
|
|
137
|
+
'the root has no roadmap, so it has no mvp counter to report: ' + roadmap);
|
|
138
|
+
} finally { cleanup(root); }
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('P4 — when the root HAS its own roadmap, the primary counters are the root\'s', () => {
|
|
142
|
+
const root = course({
|
|
143
|
+
rootRoadmap: { total: 5, done: 2, mvp: 3 },
|
|
144
|
+
subs: { '01-app': { total: 13, done: 4 } },
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
const roadmap = line(render(root).text, 'Roadmap');
|
|
148
|
+
assert.match(roadmap, /Done\s+2\/5/,
|
|
149
|
+
'the root\'s own 2/5 must be the headline, not the sub-project\'s 4/13: ' + roadmap);
|
|
150
|
+
// 2/3, not 0/3: the fixture's first two features are both done AND mvp. The code was right
|
|
151
|
+
// and the first version of this expectation was wrong.
|
|
152
|
+
assert.match(roadmap, /mvp\s+2\/3/, 'and its own mvp counter: ' + roadmap);
|
|
153
|
+
// Sub-projects are still surfaced — as a separate, labelled segment. Merging them into
|
|
154
|
+
// "Done x/y" would silently change what that fraction means.
|
|
155
|
+
assert.match(roadmap, /\+1 sub\s+13/,
|
|
156
|
+
'sub-projects must appear as their own segment: ' + roadmap);
|
|
157
|
+
assert.doesNotMatch(roadmap, /Done\s+6\/18/,
|
|
158
|
+
'totals must NOT be merged into the root\'s counters: ' + roadmap);
|
|
159
|
+
} finally { cleanup(root); }
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('P8 — several sub-projects, arbitrary names: the aggregate and the count are real', () => {
|
|
163
|
+
// Cross-family QE: every fixture was named '01-app', so the "N projects" branch had never been
|
|
164
|
+
// rendered and a survey that only recognised that one name would have passed the whole suite.
|
|
165
|
+
const root = course({ subs: { 'zeta-svc': { total: 4, done: 1 }, 'alpha-web': { total: 6, done: 2 } } });
|
|
166
|
+
try {
|
|
167
|
+
const roadmap = line(render(root).text, 'Roadmap');
|
|
168
|
+
assert.match(roadmap, /in\s+2 projects/,
|
|
169
|
+
'more than one sub-project must be reported as a count, not as one name: ' + roadmap);
|
|
170
|
+
assert.match(roadmap, /3\/10/,
|
|
171
|
+
'and the aggregate must be the real sum of both, 1+2 of 4+6: ' + roadmap);
|
|
172
|
+
} finally { cleanup(root); }
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('P9 — the survey is BOUNDED: it runs on every prompt', () => {
|
|
176
|
+
// An unbounded scan adds its cost to every keystroke. 40 entries against a limit of 24.
|
|
177
|
+
const subs = {};
|
|
178
|
+
for (let i = 0; i < 40; i++) subs['p' + String(i).padStart(2, '0')] = { total: 1 };
|
|
179
|
+
const root = course({ subs });
|
|
180
|
+
try {
|
|
181
|
+
const r = render(root);
|
|
182
|
+
assert.equal(r.code, 0, 'the status line must never fail the session');
|
|
183
|
+
const roadmap = line(r.text, 'Roadmap');
|
|
184
|
+
assert.match(roadmap, /in\s+24 projects/,
|
|
185
|
+
'the scan must stop at its stated limit rather than walking all 40: ' + roadmap);
|
|
186
|
+
} finally { cleanup(root); }
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('P5 — no roadmap anywhere: the legacy line is untouched', () => {
|
|
190
|
+
const root = course({});
|
|
191
|
+
try {
|
|
192
|
+
assert.match(line(render(root).text, 'Roadmap'),
|
|
193
|
+
/no roadmap yet \(run \/next or \/replicate\)/,
|
|
194
|
+
'the empty case must stay byte-identical — this feature adds a survey, it does not '
|
|
195
|
+
+ 'rewrite the line everyone already knows');
|
|
196
|
+
} finally { cleanup(root); }
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('P6 — a file named projects/x and a malformed roadmap are skipped, never thrown on', () => {
|
|
200
|
+
const root = course({ subs: { '01-app': { total: 3 }, '02-bad': 'malformed' } });
|
|
201
|
+
try {
|
|
202
|
+
fs.writeFileSync(path.join(root, 'projects', 'notadir'), 'x');
|
|
203
|
+
const r = render(root);
|
|
204
|
+
assert.equal(r.code, 0, 'the status line must never fail the session');
|
|
205
|
+
const roadmap = line(r.text, 'Roadmap');
|
|
206
|
+
assert.match(roadmap, /0\/3/,
|
|
207
|
+
'the good sub-project still counts; the bad ones are skipped: ' + roadmap);
|
|
208
|
+
} finally { cleanup(root); }
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('P7 — the toolkit questions name TOOLKIT_ROOT, not a cwd expression', () => {
|
|
212
|
+
// Guards the split itself: re-point either reader at a cwd and this goes red. Comments are
|
|
213
|
+
// stripped first — a mention in prose is not a use in code.
|
|
214
|
+
// Line-wise, not regex-wise: a /\*...\*/ stripper over this file removed 12 158 of its 18 153
|
|
215
|
+
// characters and lost every function name, so the assertions below would have been searching an
|
|
216
|
+
// empty string. Dropping comment LINES is boring, and it is right. A trailing same-line comment
|
|
217
|
+
// survives, which cannot realistically fake a whole path.join expression.
|
|
218
|
+
const src = fs.readFileSync(HOOK_SRC, 'utf-8').split('\n')
|
|
219
|
+
.filter((l) => !/^\s*(\/\/|\/\*|\*)/.test(l)).join('\n');
|
|
220
|
+
assert.doesNotMatch(src, /process\.cwd\(\)/, 'the anchor must not come from the drifting cwd');
|
|
221
|
+
const toolkit = src.slice(src.indexOf('function parseToolkit'),
|
|
222
|
+
src.indexOf('function parseExpectedToolkit'));
|
|
223
|
+
assert.match(toolkit, /TOOLKIT_ROOT/,
|
|
224
|
+
'parseToolkit must name the root it means, so re-pointing it is a visible change');
|
|
225
|
+
const settingsAt = src.indexOf('function parseSettingsStatus');
|
|
226
|
+
assert.match(src.slice(settingsAt, settingsAt + 400), /TOOLKIT_ROOT/,
|
|
227
|
+
'and so must parseSettingsStatus');
|
|
228
|
+
assert.match(src, /function parseSubProjects/,
|
|
229
|
+
'the survey is what replaces guessing which project is meant');
|
|
230
|
+
// Cross-family QE: a defined-but-never-called function satisfies the line above. The behavioural
|
|
231
|
+
// tests are the real proof; this makes the dead-code case red here too.
|
|
232
|
+
assert.ok(src.split('parseSubProjects').length - 1 >= 2,
|
|
233
|
+
'parseSubProjects must be CALLED, not merely defined');
|
|
234
|
+
assert.match(src, /SUB_SCAN_LIMIT/,
|
|
235
|
+
'the survey must carry its own bound — it renders on every prompt');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/sync-templates.js copies <found root>/.claude/{skills,commands,agents,rules,hooks} over
|
|
4
|
+
// templates/.claude/ — the directory that becomes the npm tarball. It finds its source by walking up
|
|
5
|
+
// to five parents and taking the FIRST one containing .claude/skills, which inside a monorepo is the
|
|
6
|
+
// MONOREPO, whose .claude/skills holds a whole different toolkit.
|
|
7
|
+
//
|
|
8
|
+
// Nothing had fired only because package.json's prepublishOnly points at the publish gate, not here.
|
|
9
|
+
// "Safe because dead" is not a safety property: a dead script can be revived by anyone who does not
|
|
10
|
+
// know what it does — and two places used to invite exactly that, a test helper printing
|
|
11
|
+
// "Run prepublishOnly first: node scripts/sync-templates.js" and an architecture doc calling it the
|
|
12
|
+
// prepublishOnly hook.
|
|
13
|
+
//
|
|
14
|
+
// These tests run the REAL script as a real subprocess against real fixtures and compare the tree
|
|
15
|
+
// before and after, because a script that exits 0 having copied two hundred files and one that exits
|
|
16
|
+
// 0 having copied nothing look identical from the outside.
|
|
17
|
+
|
|
18
|
+
const { test, describe } = require('node:test');
|
|
19
|
+
const assert = require('node:assert/strict');
|
|
20
|
+
const { execFileSync } = require('node:child_process');
|
|
21
|
+
const crypto = require('node:crypto');
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const PKG_DIR = path.resolve(__dirname, '..', '..');
|
|
27
|
+
const SCRIPT_SRC = path.join(PKG_DIR, 'scripts', 'sync-templates.js');
|
|
28
|
+
const MARKER = '.p-replicator-sync-source';
|
|
29
|
+
const DECLARATION = 'p-replicator-sync-source: v1';
|
|
30
|
+
|
|
31
|
+
/** A fixture shaped like the real geometry: <root>/.claude/skills, and the package one level down
|
|
32
|
+
* with its own templates/.claude tree. `marker` decides whether the root claims to be the source. */
|
|
33
|
+
function fixture(opts) {
|
|
34
|
+
const o = opts || {};
|
|
35
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-sync-')));
|
|
36
|
+
if (o.rootHasClaude !== false) {
|
|
37
|
+
fs.mkdirSync(path.join(root, '.claude', 'skills', 'intruder'), { recursive: true });
|
|
38
|
+
fs.writeFileSync(path.join(root, '.claude', 'skills', 'intruder', 'SKILL.md'),
|
|
39
|
+
'# a skill that belongs to the monorepo, not to this package\n');
|
|
40
|
+
}
|
|
41
|
+
// A VALID marker is a regular file carrying the declaration. Cross-family QE: the first fixture
|
|
42
|
+
// wrote an EMPTY file, which institutionalised exactly the weakest form of the authorization.
|
|
43
|
+
if (o.marker === true) fs.writeFileSync(path.join(root, MARKER), DECLARATION + '\n');
|
|
44
|
+
if (o.marker === 'empty') fs.writeFileSync(path.join(root, MARKER), '');
|
|
45
|
+
if (o.marker === 'dir') fs.mkdirSync(path.join(root, MARKER));
|
|
46
|
+
if (o.marker === 'symlink') {
|
|
47
|
+
const real = path.join(root, 'elsewhere');
|
|
48
|
+
fs.writeFileSync(real, DECLARATION + '\n');
|
|
49
|
+
fs.symlinkSync(real, path.join(root, MARKER));
|
|
50
|
+
}
|
|
51
|
+
if (o.marker === 'wrong') fs.writeFileSync(path.join(root, MARKER), 'yes please\n');
|
|
52
|
+
|
|
53
|
+
const pkg = path.join(root, 'packages', 'p-replicator');
|
|
54
|
+
fs.mkdirSync(path.join(pkg, 'scripts'), { recursive: true });
|
|
55
|
+
fs.copyFileSync(SCRIPT_SRC, path.join(pkg, 'scripts', 'sync-templates.js'));
|
|
56
|
+
fs.mkdirSync(path.join(pkg, 'templates', '.claude', 'skills', 'shipped'), { recursive: true });
|
|
57
|
+
fs.writeFileSync(path.join(pkg, 'templates', '.claude', 'skills', 'shipped', 'SKILL.md'),
|
|
58
|
+
'# the package\'s own shipped skill\n');
|
|
59
|
+
return { root, pkg };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** SHA-256 of every file under a directory, keyed by relative path. */
|
|
63
|
+
function treeHash(dir) {
|
|
64
|
+
const out = {};
|
|
65
|
+
const walk = (d, base) => {
|
|
66
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
67
|
+
const full = path.join(d, e.name);
|
|
68
|
+
const rel = path.join(base, e.name);
|
|
69
|
+
if (e.isDirectory()) walk(full, rel);
|
|
70
|
+
else out[rel] = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex');
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
if (fs.existsSync(dir)) walk(dir, '');
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function runScript(pkgDir) {
|
|
78
|
+
try {
|
|
79
|
+
const stdout = execFileSync(process.execPath, [path.join(pkgDir, 'scripts', 'sync-templates.js')],
|
|
80
|
+
{ cwd: pkgDir, encoding: 'utf8', stdio: 'pipe' });
|
|
81
|
+
return { code: 0, out: stdout || '' };
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return {
|
|
84
|
+
code: err.status == null ? 1 : err.status,
|
|
85
|
+
out: (err.stdout ? err.stdout.toString() : '') + (err.stderr ? err.stderr.toString() : ''),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const cleanup = (d) => fs.rmSync(d, { recursive: true, force: true });
|
|
91
|
+
const readPkg = (rel) => fs.readFileSync(path.join(PKG_DIR, rel), 'utf-8');
|
|
92
|
+
|
|
93
|
+
describe('a publish-time copy that was safe only because nobody called it', () => {
|
|
94
|
+
test('P1 — an unmarked root is REFUSED, and not one byte of templates/ changes', () => {
|
|
95
|
+
const f = fixture({});
|
|
96
|
+
try {
|
|
97
|
+
const templates = path.join(f.pkg, 'templates');
|
|
98
|
+
const before = treeHash(templates);
|
|
99
|
+
const r = runScript(f.pkg);
|
|
100
|
+
const after = treeHash(templates);
|
|
101
|
+
assert.notEqual(r.code, 0,
|
|
102
|
+
'a refusal must exit non-zero; exit 0 is how a guard becomes invisible to a caller');
|
|
103
|
+
assert.deepEqual(after, before,
|
|
104
|
+
'the refusal path must touch nothing — the check has to precede every write, not follow it');
|
|
105
|
+
assert.ok(!fs.existsSync(path.join(templates, '.claude', 'skills', 'intruder')),
|
|
106
|
+
'the monorepo skill must not have arrived in what becomes the tarball');
|
|
107
|
+
} finally { cleanup(f.root); }
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('P2 — the refusal says what it found and why, not just that it stopped', () => {
|
|
111
|
+
const f = fixture({});
|
|
112
|
+
try {
|
|
113
|
+
const out = runScript(f.pkg).out;
|
|
114
|
+
assert.match(out, /REFUSING to sync/, 'it must say it refused');
|
|
115
|
+
assert.ok(out.includes(f.root),
|
|
116
|
+
'and name the root it found, or the reader cannot tell which directory it objected to');
|
|
117
|
+
assert.match(out, /becomes the npm tarball/,
|
|
118
|
+
'and say what was at stake, or the refusal reads as a configuration nag');
|
|
119
|
+
assert.ok(out.includes(DECLARATION),
|
|
120
|
+
'and say how to opt in — quoting the exact declaration, or someone will delete the check');
|
|
121
|
+
assert.match(out, /a directory, a symlink, or an empty file is NOT a declaration/,
|
|
122
|
+
'and say which near-misses do not count, since those are the accidental ones');
|
|
123
|
+
} finally { cleanup(f.root); }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('P3 — a MARKED root still syncs: the guard must not break the legitimate path', () => {
|
|
127
|
+
// A guard that also blocks the intended use is a deletion wearing a disguise.
|
|
128
|
+
const f = fixture({ marker: true });
|
|
129
|
+
try {
|
|
130
|
+
const r = runScript(f.pkg);
|
|
131
|
+
assert.equal(r.code, 0, 'an opted-in root must proceed: ' + r.out);
|
|
132
|
+
assert.ok(fs.existsSync(path.join(f.pkg, 'templates', '.claude', 'skills', 'intruder', 'SKILL.md')),
|
|
133
|
+
'and the source files must actually arrive');
|
|
134
|
+
assert.ok(fs.existsSync(path.join(f.pkg, 'templates', '.claude', 'skills', 'shipped', 'SKILL.md')),
|
|
135
|
+
'while MERGE mode preserves what the package already shipped');
|
|
136
|
+
} finally { cleanup(f.root); }
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('P8 — a near-miss marker does NOT authorize: empty, directory, symlink, wrong text', () => {
|
|
140
|
+
// Cross-family QE: any node satisfies existsSync, so an empty file committed by accident, or a
|
|
141
|
+
// stray directory, would have permanently authorized overwriting the tarball. lstat keeps a
|
|
142
|
+
// symlink from vouching for a tree this script cannot see, and the declaration is content nobody
|
|
143
|
+
// types by accident.
|
|
144
|
+
for (const kind of ['empty', 'dir', 'symlink', 'wrong']) {
|
|
145
|
+
const f = fixture({ marker: kind });
|
|
146
|
+
try {
|
|
147
|
+
const templates = path.join(f.pkg, 'templates');
|
|
148
|
+
const before = treeHash(templates);
|
|
149
|
+
const r = runScript(f.pkg);
|
|
150
|
+
assert.notEqual(r.code, 0, 'a ' + kind + ' marker must not authorize the copy');
|
|
151
|
+
assert.deepEqual(treeHash(templates), before,
|
|
152
|
+
'and must touch nothing: ' + kind);
|
|
153
|
+
} finally { cleanup(f.root); }
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('P9 — the script header does not claim to be the prepublishOnly hook', () => {
|
|
158
|
+
// The same false claim lived in three places. Fixing the doc and the helper while leaving the
|
|
159
|
+
// script's own docblock saying it "runs automatically before npm publish" would have left the
|
|
160
|
+
// lie in the most authoritative one.
|
|
161
|
+
const src = readPkg(path.join('scripts', 'sync-templates.js'));
|
|
162
|
+
const header = src.slice(0, src.indexOf('const fs ='));
|
|
163
|
+
assert.ok(!/prepublishOnly hook/.test(header),
|
|
164
|
+
'the header must not call it the prepublishOnly hook');
|
|
165
|
+
assert.ok(!/Run automatically before/.test(header),
|
|
166
|
+
'nor say it runs automatically — it does not, and that belief is what gets it run');
|
|
167
|
+
assert.match(header, /OPT-IN, and NOT part of publishing/,
|
|
168
|
+
'it must say what it actually is, at the top where a reader looks first');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('P4 — no candidate root at all: the pre-existing skip is unchanged', () => {
|
|
172
|
+
const f = fixture({ rootHasClaude: false });
|
|
173
|
+
try {
|
|
174
|
+
const r = runScript(f.pkg);
|
|
175
|
+
assert.equal(r.code, 0, 'this path was always benign and must stay exit 0: ' + r.out);
|
|
176
|
+
assert.match(r.out, /Not in repo context — skipping sync/,
|
|
177
|
+
'and keep its existing message — this feature adds a guard, it does not rewrite the script');
|
|
178
|
+
} finally { cleanup(f.root); }
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('P5 — no helper tells a reader to run it', () => {
|
|
182
|
+
// The helper printed "Run prepublishOnly first: node scripts/sync-templates.js" when templates/
|
|
183
|
+
// was missing: a live invitation to the hazardous command, in the one situation where a reader
|
|
184
|
+
// is already confused.
|
|
185
|
+
assert.ok(!readPkg(path.join('tests', 'snapshot', 'update-baseline.js')).includes('sync-templates'),
|
|
186
|
+
'update-baseline.js must not name the script as a recovery step');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('P6 — the architecture doc no longer calls it the prepublishOnly hook', () => {
|
|
190
|
+
// Being false in THIS direction is the worst one: it makes the script sound sanctioned and
|
|
191
|
+
// current, which is exactly the belief that gets it run.
|
|
192
|
+
const doc = readPkg(path.join('README', 'eng', '05_architecture.md'));
|
|
193
|
+
assert.ok(!/`scripts\/sync-templates\.js` — runs as `prepublishOnly` hook/.test(doc),
|
|
194
|
+
'the doc must not claim it runs as prepublishOnly — it does not');
|
|
195
|
+
assert.match(doc, /refuses to run unless the root it finds carries a `\.p-replicator-sync-source` marker/,
|
|
196
|
+
'and must record what it actually is now');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('P7 — prepublishOnly does not point at it', () => {
|
|
200
|
+
// The regression guard on the fact that kept it dormant. If someone wires it into publishing,
|
|
201
|
+
// this goes red — and after this feature the guard would stop it anyway, which is the point:
|
|
202
|
+
// reviving it is now safe rather than catastrophic.
|
|
203
|
+
const pkg = JSON.parse(readPkg('package.json'));
|
|
204
|
+
const hook = (pkg.scripts && pkg.scripts.prepublishOnly) || '';
|
|
205
|
+
assert.ok(!hook.includes('sync-templates'),
|
|
206
|
+
'publishing must not run the template overwrite: ' + hook);
|
|
207
|
+
assert.match(hook, /prepublish-gate\.mjs/, 'publishing runs the gate');
|
|
208
|
+
});
|
|
209
|
+
});
|
package/tests/unit/utils.test.js
CHANGED
|
@@ -341,11 +341,11 @@ describe('COMPONENTS.items SSOT', () => {
|
|
|
341
341
|
}
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
-
test('item counts: 10 skills, 11 commands, 4 agents,
|
|
344
|
+
test('item counts: 10 skills, 11 commands, 4 agents, 6 rules, 1 settings', () => {
|
|
345
345
|
assert.equal(Object.keys(utils.COMPONENTS.skills.items).length, 10, 'skills count');
|
|
346
346
|
assert.equal(Object.keys(utils.COMPONENTS.commands.items).length, 11, 'commands count (post v1.4: + 9 generic commands)');
|
|
347
347
|
assert.equal(Object.keys(utils.COMPONENTS.agents.items).length, 4, 'agents count');
|
|
348
|
-
assert.equal(Object.keys(utils.COMPONENTS.rules.items).length,
|
|
348
|
+
assert.equal(Object.keys(utils.COMPONENTS.rules.items).length, 6, 'rules count (post v1.4: + 3 generic rules; + docker-ports)');
|
|
349
349
|
assert.ok(utils.COMPONENTS.settings, 'settings component group added in v1.4');
|
|
350
350
|
assert.equal(Object.keys(utils.COMPONENTS.settings.items).length, 1, 'settings.json count');
|
|
351
351
|
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The Phase-2 quality gate existed but could not block on the thing it exists to measure.
|
|
4
|
+
//
|
|
5
|
+
// MEASURED from the weight tables in scoring-system.md: a user story with NO acceptance criteria and
|
|
6
|
+
// NO test links loses exactly Testable (8), Completeness (10) and Traceability (10), keeps every
|
|
7
|
+
// other criterion legitimately full, and totals 72/100 — above the 70 READY line, filed by the
|
|
8
|
+
// interpretation table under "fix minor issues, then proceed". The skill whose stated job is "Block
|
|
9
|
+
// untestable requirements" passed it.
|
|
10
|
+
//
|
|
11
|
+
// The field report named a different pair — Measurable (8) + Traceability (10) = 82. Same arithmetic
|
|
12
|
+
// hole, wrong pair: Measurable scores 0 for ANY requirement without a number, and plenty of correct
|
|
13
|
+
// functional requirements have none, so a floor there converts a false PASS into a false BLOCK. The
|
|
14
|
+
// floor is closed at Testable/Completeness, and P2 below is what keeps it closed.
|
|
15
|
+
//
|
|
16
|
+
// Second half: replicate.md said "Never skip validation" and nothing checked it. Phase 3 now carries
|
|
17
|
+
// a precondition naming the file, both admissible verdicts, and the action on failure.
|
|
18
|
+
//
|
|
19
|
+
// These are PROMPT files a model executes, so the deterministic layer available is their content.
|
|
20
|
+
// Every assertion below is DISCRIMINATING: remove what it names and it goes red.
|
|
21
|
+
|
|
22
|
+
const { test, describe } = require('node:test');
|
|
23
|
+
const assert = require('node:assert/strict');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
|
|
27
|
+
const T = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
28
|
+
const read = (rel) => fs.readFileSync(path.join(T, rel), 'utf-8');
|
|
29
|
+
|
|
30
|
+
const SCORING = 'skills/requirements-validator/references/scoring-system.md';
|
|
31
|
+
const VALIDATOR = 'skills/requirements-validator/SKILL.md';
|
|
32
|
+
const REPLICATE = 'commands/replicate.md';
|
|
33
|
+
|
|
34
|
+
/** A named section of a markdown file: the heading and everything up to the next same-level one.
|
|
35
|
+
* Cross-family QE: an assertion that searches a WHOLE file passes on explanatory prose elsewhere,
|
|
36
|
+
* which is exactly the "a mention is not a rule" failure this suite exists to prevent. */
|
|
37
|
+
function section(src, heading, nextHeading) {
|
|
38
|
+
const start = src.indexOf(heading);
|
|
39
|
+
const end = src.indexOf(nextHeading, start + 1);
|
|
40
|
+
assert.ok(start >= 0, 'missing section: ' + heading);
|
|
41
|
+
assert.ok(end > start, 'missing section terminator: ' + nextHeading);
|
|
42
|
+
return src.slice(start, end);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Phase 3 of replicate.md — placement is the property: a precondition stated in a later phase
|
|
46
|
+
* guards nothing, because the toolkit is already generated by then. */
|
|
47
|
+
function phase3(src) {
|
|
48
|
+
const start = src.indexOf('### Phase 3: TOOLKIT GENERATION');
|
|
49
|
+
const end = src.indexOf('### Phase 4');
|
|
50
|
+
assert.ok(start > 0, 'replicate.md must have a Phase 3');
|
|
51
|
+
assert.ok(end > start, 'replicate.md must have a Phase 4 after it');
|
|
52
|
+
return src.slice(start, end);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('the Phase-2 gate must be able to block (PR-009 + PR-004)', () => {
|
|
56
|
+
test('P1 — the blocking floor exists and names BOTH vetoing criteria', () => {
|
|
57
|
+
const src = read(SCORING);
|
|
58
|
+
assert.match(src, /Blocking floor/, 'the floor must be stated where the arithmetic lives');
|
|
59
|
+
assert.match(src, /weakest link decides, never the average/,
|
|
60
|
+
'the family phrase — goap-research-ed25519/SKILL.md already states this rule in these words, '
|
|
61
|
+
+ 'so the two skills read as one doctrine rather than two');
|
|
62
|
+
assert.match(src, /`Testable`[^\n]*\|[^\n]*no acceptance criteria/,
|
|
63
|
+
'Testable = 0 must veto: it is the criterion that asks whether AC exist at all');
|
|
64
|
+
assert.match(src, /`Completeness`[^\n]*\|[^\n]*No AC/,
|
|
65
|
+
'Completeness = 0 must veto too — the rubric\'s own "No AC" row');
|
|
66
|
+
assert.match(src, /72\/100/,
|
|
67
|
+
'the worked case must carry its number, or a later reader cannot check the claim');
|
|
68
|
+
// Cross-family QE, the load-bearing finding: the SAME agent assigns these scores and is bound by
|
|
69
|
+
// the floor, so the floor read a number the floor's subject had written. Reporting Testable = 4
|
|
70
|
+
// ("vague AC") on a story with no AC evades the veto without contradicting the rubric. The floor
|
|
71
|
+
// must therefore key on the artifact — a non-zero score has to quote the AC it is scoring.
|
|
72
|
+
assert.match(src, /REQUIRES quoting the acceptance criteria/,
|
|
73
|
+
'a non-zero Testable/Completeness must be evidenced by the AC text, not asserted');
|
|
74
|
+
assert.match(src, /No quote ⇒ the score is 0/,
|
|
75
|
+
'and the consequence of having no quote must be stated, or the requirement is decorative');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('P2 — the floor is CLOSED, and says so with its reason', () => {
|
|
79
|
+
// Without this, the next editor "strengthens" the gate by adding Measurable, and every
|
|
80
|
+
// non-numeric functional requirement starts failing. The refusal has to be as visible as the rule.
|
|
81
|
+
const src = read(SCORING);
|
|
82
|
+
assert.match(src, /floor is CLOSED/, 'the list must declare itself closed');
|
|
83
|
+
assert.match(src, /`Measurable`[\s\S]{0,80}deliberately NOT/,
|
|
84
|
+
'Measurable must be named as excluded — naming it is what makes the exclusion survive an edit');
|
|
85
|
+
assert.match(src, /false pass into a false block/,
|
|
86
|
+
'the REASON must be recorded, not just the exclusion');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('P3 — the skill\'s own verdict table carries the floor too', () => {
|
|
90
|
+
// One document fixed and the other not is worse than neither: a reader who stops at SKILL.md
|
|
91
|
+
// gets a verdict table that still says 72 is a minor fix.
|
|
92
|
+
// Scoped to the section that CARRIES the verdict table. Searching the whole file would pass on
|
|
93
|
+
// any prose that happens to mention the floor — the finding cross-family QE raised here.
|
|
94
|
+
const block = section(read(VALIDATOR), '## Scoring System', '## Output Format');
|
|
95
|
+
assert.match(block, /Blocking floor/, 'the floor must sit beside the score table it overrides');
|
|
96
|
+
assert.match(block, /Testable = 0/, 'and name the first vetoing criterion');
|
|
97
|
+
assert.match(block, /Completeness = 0/, 'and the second');
|
|
98
|
+
assert.match(block, /REQUIRES quoting/, 'and carry the evidence rule, not only the veto');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('P4 — Phase 3 states a checkable precondition: file, verdicts, action', () => {
|
|
102
|
+
const block = phase3(read(REPLICATE));
|
|
103
|
+
assert.match(block, /docs\/validation-report\.md/,
|
|
104
|
+
'the precondition must name the FILE — "validated docs" is not checkable');
|
|
105
|
+
assert.match(block, /must EXIST/, 'existence is the first check');
|
|
106
|
+
// Cross-family QE: naming the three verdict emoji ANYWHERE in the phase is satisfied by prose.
|
|
107
|
+
// The property is a single anchored contract — WHICH line carries the verdict, and that a
|
|
108
|
+
// verdict word found elsewhere does not count.
|
|
109
|
+
assert.match(block, /\*\*first line\*\* must be `\*\*Verdict:\*\*` followed by 🟢 READY or 🟡 CAVEATS/,
|
|
110
|
+
'the admissible verdicts must be bound to the ONE authoritative line, not merely mentioned');
|
|
111
|
+
assert.match(block, /is NOT the verdict/,
|
|
112
|
+
'a verdict word elsewhere in the report must be explicitly rejected');
|
|
113
|
+
assert.match(block, /more than one line starting\s+with `\*\*Verdict:\*\*`/,
|
|
114
|
+
'uniqueness must be required, or two verdicts leave the reader to choose');
|
|
115
|
+
assert.match(block, /do NOT generate anything/,
|
|
116
|
+
'a precondition with no stated consequence is advice; the action is the rule');
|
|
117
|
+
assert.match(block, /return to Phase 2/, 'and where to go instead');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('P5 — the Phase-2 exit table carries the same floor, so both ends agree', () => {
|
|
121
|
+
const src = read(REPLICATE);
|
|
122
|
+
const start = src.indexOf('### Phase 2: VALIDATION');
|
|
123
|
+
const end = src.indexOf('### Phase 3');
|
|
124
|
+
const block = src.slice(start, end);
|
|
125
|
+
assert.ok(start > 0 && end > start, 'replicate.md must have a Phase 2 before Phase 3');
|
|
126
|
+
assert.match(block, /blocking floor/,
|
|
127
|
+
'the phase that DECIDES the verdict must state the floor, not only the phase that consumes it');
|
|
128
|
+
assert.match(block, /`Testable = 0`/, 'named in the NEEDS WORK row');
|
|
129
|
+
assert.match(block, /`Completeness = 0`/, 'both of them');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('P7 — Phase 2 EMITS the anchored verdict line Phase 3 reads', () => {
|
|
133
|
+
// A reader-side contract with no writer-side obligation is half a contract: Phase 3 would demand
|
|
134
|
+
// a first-line **Verdict:** that Phase 2 was never told to write, and every run would bounce.
|
|
135
|
+
const src = read(REPLICATE);
|
|
136
|
+
const block = src.slice(src.indexOf('### Phase 2: VALIDATION'), src.indexOf('### Phase 3'));
|
|
137
|
+
assert.match(block, /\*\*first line\*\* must be exactly one of/,
|
|
138
|
+
'Phase 2 must be told the exact form Phase 3 will read');
|
|
139
|
+
assert.match(block, /\*\*Verdict:\*\* 🟢 READY/, 'and shown it verbatim');
|
|
140
|
+
assert.match(block, /no other line in the file may begin with `\*\*Verdict:\*\*`/,
|
|
141
|
+
'and told to keep it unique, which is what makes Phase 3\'s uniqueness check satisfiable');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('P6 — the precondition is imperative and file-naming, not advisory', () => {
|
|
145
|
+
const block = phase3(read(REPLICATE));
|
|
146
|
+
assert.ok(!/make sure|try to|ideally|should probably/i.test(block),
|
|
147
|
+
'advisory wording would pass a reviewer and instruct nothing');
|
|
148
|
+
// "Never skip validation" was TRUE, imperative, and unenforceable — it named no file to read.
|
|
149
|
+
// The NEVER line must now point at the check rather than stand alone.
|
|
150
|
+
// Cross-family QE was right that the first version accepted a self-attestation: a line reading
|
|
151
|
+
// "this is CHECKED, not assumed" is a CLAIM about the rule, and a claim is what the old advisory
|
|
152
|
+
// line already was. What makes the NEVER line non-advisory is that it POINTS at a named,
|
|
153
|
+
// readable artifact — so that is what this asserts.
|
|
154
|
+
const src = read(REPLICATE);
|
|
155
|
+
assert.match(src, /Never skip validation[^\n]*\n?[^\n]*`\*\*Verdict:\*\*` line of `docs\/validation-report\.md`/,
|
|
156
|
+
'the NEVER line must point at the exact line of the exact file that enforces it');
|
|
157
|
+
});
|
|
158
|
+
});
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 dzhechko
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|