@dzhechkov/p-replicator 1.12.0 → 1.13.2
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 +281 -65
- package/CHANGELOG.md +148 -1
- package/LICENSE +21 -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 +1 -1
- package/README/eng/README.md +2 -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 +2 -1
- package/README/ru/html/index.html +8 -8
- package/README.md +150 -9
- package/bin/cli.js +0 -0
- package/package.json +10 -11
- package/sbom.json +604 -64
- package/scripts/check-pipeline-gaps.sh +510 -21
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +5 -1
- package/src/utils.js +33 -3
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +72 -6
- package/templates/.claude/commands/go.md +6 -8
- package/templates/.claude/commands/harvest.md +5 -7
- package/templates/.claude/commands/replicate.md +169 -44
- package/templates/.claude/commands/start.md +28 -7
- package/templates/.claude/hooks/capture-source-path.cjs +795 -0
- package/templates/.claude/hooks/check-canon.cjs +493 -0
- package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
- package/templates/.claude/hooks/check-external-deps.cjs +288 -0
- package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
- package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
- package/templates/.claude/hooks/check-job-contract.cjs +501 -0
- package/templates/.claude/hooks/check-look-origin.cjs +240 -0
- package/templates/.claude/hooks/check-look-trace.cjs +385 -0
- package/templates/.claude/hooks/check-metric-source.cjs +296 -0
- package/templates/.claude/hooks/check-model-cost.cjs +470 -0
- package/templates/.claude/hooks/check-ports.cjs +27 -6
- package/templates/.claude/hooks/check-review-contract.cjs +205 -0
- package/templates/.claude/hooks/check-source-version.cjs +312 -0
- package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
- package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +5 -6
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/long-running-job.md +73 -0
- package/templates/.claude/rules/model-call-cost.md +85 -0
- package/templates/.claude/rules/replicate-pipeline.md +122 -52
- package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
- package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
- package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
- package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
- package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
- package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
- package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
- package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
- package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
- package/templates/.claude/skills/requirements-validator/SKILL.md +18 -5
- package/templates/.claude/skills/requirements-validator/references/feature-report-contracts.md +67 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +15 -6
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +173 -725
- package/tests/e2e/feature-contour.test.js +176 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/01_specification.md +15 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/02_pseudocode.md +19 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/03_architecture.md +3 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/04_refinement.md +3 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/05_completion.md +7 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/review-report.md +11 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/validation-report.md +10 -0
- package/tests/fixtures/feature-contour/tests/demo.test.js +16 -0
- package/tests/snapshot/baseline.json +64 -40
- package/tests/unit/capture-source-path.test.js +492 -0
- package/tests/unit/check-canon.test.js +403 -0
- package/tests/unit/check-embed-contract.test.js +422 -0
- package/tests/unit/check-external-deps.test.js +363 -0
- package/tests/unit/check-file-ownership.test.js +388 -0
- package/tests/unit/check-handoff-manifest.test.js +410 -0
- package/tests/unit/check-job-contract.test.js +514 -0
- package/tests/unit/check-look-origin.test.js +180 -0
- package/tests/unit/check-look-trace.test.js +420 -0
- package/tests/unit/check-metric-source.test.js +325 -0
- package/tests/unit/check-model-cost.test.js +425 -0
- package/tests/unit/check-ports.test.js +46 -2
- package/tests/unit/check-review-contract.test.js +181 -0
- package/tests/unit/check-source-version.test.js +344 -0
- package/tests/unit/check-swarm-receipts.test.js +231 -0
- package/tests/unit/check-webhook-contract.test.js +536 -0
- package/tests/unit/db-port-rule.test.js +8 -2
- package/tests/unit/detection-ladder-registry.test.js +2 -2
- package/tests/unit/generator-swarm-contract.test.js +287 -0
- package/tests/unit/guard-honest-input-meta.test.js +64 -0
- package/tests/unit/honest-failure-rules.test.js +106 -9
- package/tests/unit/look-phase-contract.test.js +231 -0
- package/tests/unit/negative-conclusion-gate.test.js +300 -0
- package/tests/unit/quote-provenance.test.js +122 -0
- package/tests/unit/sync-templates-guard.test.js +46 -3
- package/tests/unit/traceability-completion-gate.test.js +267 -0
- package/tests/unit/utils.test.js +40 -3
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
|
|
11
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
12
|
+
const CHECK = path.join(PKG, 'templates', '.claude', 'hooks', 'check-review-contract.cjs');
|
|
13
|
+
const SLUG = 'traceability-floor';
|
|
14
|
+
const SPEC = [
|
|
15
|
+
'# Specification',
|
|
16
|
+
'',
|
|
17
|
+
'### AC-trace-1 First criterion',
|
|
18
|
+
'',
|
|
19
|
+
'The first contour is observable.',
|
|
20
|
+
'',
|
|
21
|
+
'```markdown',
|
|
22
|
+
'### AC-ignored-9 Example only',
|
|
23
|
+
'```',
|
|
24
|
+
'',
|
|
25
|
+
'### AC-trace-2 Second criterion',
|
|
26
|
+
'',
|
|
27
|
+
'The second contour is observable.',
|
|
28
|
+
'',
|
|
29
|
+
].join('\n');
|
|
30
|
+
|
|
31
|
+
function digest(body) {
|
|
32
|
+
return crypto.createHash('sha256').update(body).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function report(options) {
|
|
36
|
+
const o = options || {};
|
|
37
|
+
const rows = o.rows || [
|
|
38
|
+
'| AC-trace-1 | met | tests/trace.test.js — proves the first contour |',
|
|
39
|
+
'| AC-trace-2 | unverifiable | |',
|
|
40
|
+
];
|
|
41
|
+
return [
|
|
42
|
+
'# Review report',
|
|
43
|
+
...(o.family === null ? [] : ['Reviewer family: ' + (o.family || 'claude')]),
|
|
44
|
+
'Spec revision: sha256:' + (o.revision || digest(SPEC)),
|
|
45
|
+
'',
|
|
46
|
+
...(o.section === false ? [] : [
|
|
47
|
+
'## Spec conformance',
|
|
48
|
+
...(o.table === false ? [] : [
|
|
49
|
+
'| Criterion | Verdict | Evidence |',
|
|
50
|
+
'|-----------|---------|----------|',
|
|
51
|
+
...rows,
|
|
52
|
+
]),
|
|
53
|
+
]),
|
|
54
|
+
'',
|
|
55
|
+
].join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function check(options) {
|
|
59
|
+
const o = options || {};
|
|
60
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-review-')));
|
|
61
|
+
const feature = path.join(root, 'docs', 'features', SLUG);
|
|
62
|
+
fs.mkdirSync(feature, { recursive: true });
|
|
63
|
+
try {
|
|
64
|
+
const specPath = path.join(feature, '01_specification.md');
|
|
65
|
+
if (o.symlinkSpec) {
|
|
66
|
+
const target = path.join(root, 'spec-target.md');
|
|
67
|
+
fs.writeFileSync(target, SPEC);
|
|
68
|
+
fs.symlinkSync(target, specPath);
|
|
69
|
+
} else {
|
|
70
|
+
fs.writeFileSync(specPath, SPEC);
|
|
71
|
+
}
|
|
72
|
+
if (!o.missingReport) {
|
|
73
|
+
const reportPath = path.join(feature, 'review-report.md');
|
|
74
|
+
fs.writeFileSync(reportPath, o.body === undefined ? report() : o.body);
|
|
75
|
+
if (o.unreadableReport) fs.chmodSync(reportPath, 0o000);
|
|
76
|
+
}
|
|
77
|
+
const result = spawnSync(process.execPath, [CHECK, root, SLUG], { encoding: 'utf8' });
|
|
78
|
+
return { code: result.status, out: (result.stdout || '') + (result.stderr || '') };
|
|
79
|
+
} finally {
|
|
80
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function namedGap(options, pattern) {
|
|
85
|
+
const result = check(options);
|
|
86
|
+
assert.equal(result.code, 1, result.out);
|
|
87
|
+
assert.match(result.out, pattern, result.out);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test('honest input exits 0 and prints the measured counts', () => {
|
|
91
|
+
const result = check();
|
|
92
|
+
assert.equal(result.code, 0, result.out);
|
|
93
|
+
assert.match(result.out,
|
|
94
|
+
/PASS review-contract feature=traceability-floor AC-ids=2 rows=2/,
|
|
95
|
+
result.out);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('missing reviewer family is a named gap', () => {
|
|
99
|
+
namedGap({ body: report({ family: null }) }, /GAP Reviewer family line missing/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('reviewer family outside the vocabulary is a named gap', () => {
|
|
103
|
+
namedGap({ body: report({ family: 'gpt' }) }, /GAP Reviewer family invalid value=gpt/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('revision mismatch names both 12-character prefixes', () => {
|
|
107
|
+
const wrong = '0'.repeat(64);
|
|
108
|
+
const actual = digest(SPEC).slice(0, 12);
|
|
109
|
+
namedGap(
|
|
110
|
+
{ body: report({ revision: wrong }) },
|
|
111
|
+
new RegExp('GAP Spec revision mismatch report=000000000000 specification=' + actual),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('missing Spec conformance section is a named gap', () => {
|
|
116
|
+
namedGap({ body: report({ section: false }) }, /GAP Spec conformance section missing/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('missing Spec conformance table is a named gap', () => {
|
|
120
|
+
namedGap({ body: report({ table: false }) }, /GAP Spec conformance table missing or malformed/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('an AC id with no row is named', () => {
|
|
124
|
+
namedGap({ body: report({ rows: [
|
|
125
|
+
'| AC-trace-1 | met | tests/trace.test.js — proves the first contour |',
|
|
126
|
+
] }) }, /GAP AC-trace-2 has no Spec conformance row/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a row id absent from the specification is named', () => {
|
|
130
|
+
namedGap({ body: report({ rows: [
|
|
131
|
+
'| AC-trace-1 | met | tests/trace.test.js — proves the first contour |',
|
|
132
|
+
'| AC-trace-2 | unverifiable | |',
|
|
133
|
+
'| AC-invented-3 | met | tests/trace.test.js — invented contour |',
|
|
134
|
+
] }) }, /GAP AC-invented-3 row id is not in the specification/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a duplicate row is named', () => {
|
|
138
|
+
namedGap({ body: report({ rows: [
|
|
139
|
+
'| AC-trace-1 | met | tests/trace.test.js — first proof |',
|
|
140
|
+
'| AC-trace-1 | met | tests/trace.test.js — duplicate proof |',
|
|
141
|
+
'| AC-trace-2 | unverifiable | |',
|
|
142
|
+
] }) }, /GAP AC-trace-1 duplicate Spec conformance row/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('a verdict outside the vocabulary is named', () => {
|
|
146
|
+
namedGap({ body: report({ rows: [
|
|
147
|
+
'| AC-trace-1 | passed | tests/trace.test.js — first proof |',
|
|
148
|
+
'| AC-trace-2 | unverifiable | |',
|
|
149
|
+
] }) }, /GAP AC-trace-1 verdict invalid value=passed/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('empty evidence on met and not met rows is named', () => {
|
|
153
|
+
for (const verdict of ['met', 'not met']) {
|
|
154
|
+
namedGap({ body: report({ rows: [
|
|
155
|
+
'| AC-trace-1 | ' + verdict + ' | |',
|
|
156
|
+
'| AC-trace-2 | unverifiable | |',
|
|
157
|
+
] }) }, new RegExp('GAP AC-trace-1 evidence empty for verdict=' + verdict));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('unreadable or missing report → exit 2', () => {
|
|
162
|
+
for (const options of [{ missingReport: true }, { unreadableReport: true }]) {
|
|
163
|
+
const result = check(options);
|
|
164
|
+
assert.equal(result.code, 2, result.out);
|
|
165
|
+
assert.match(result.out, /NOT-ESTABLISHED review contract: review report/, result.out);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('symlinked spec → exit 2', () => {
|
|
170
|
+
const result = check({ symlinkSpec: true });
|
|
171
|
+
assert.equal(result.code, 2, result.out);
|
|
172
|
+
assert.match(result.out, /NOT-ESTABLISHED review contract: specification is a symlink/, result.out);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('the family line is disclosure only: every vocabulary value passes, gpt fails', () => {
|
|
176
|
+
for (const family of ['claude', 'codex', 'human', 'unknown']) {
|
|
177
|
+
const result = check({ body: report({ family }) });
|
|
178
|
+
assert.equal(result.code, 0, family + ': ' + result.out);
|
|
179
|
+
}
|
|
180
|
+
namedGap({ body: report({ family: 'gpt' }) }, /GAP Reviewer family invalid value=gpt/);
|
|
181
|
+
});
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The deterministic half of «правка объявляет ВЕРСИЮ источника, а не обещает перечитать». There is
|
|
4
|
+
// NO new always-loaded rule: the corpus budget is spent (measured 2026-09-01) and the owner refused
|
|
5
|
+
// to raise the threshold, so the doctrine lives in the checker's header, which is never loaded.
|
|
6
|
+
//
|
|
7
|
+
// WHAT IS ALREADY PRESCRIBED AND IS NOT RE-OPENED. «Take the status from the FILE, not from the
|
|
8
|
+
// worker's narrative» is already required: the coordinator checks each receipt for existence,
|
|
9
|
+
// non-emptiness and mtime freshness before merging. That half stands. This suite covers the one it
|
|
10
|
+
// does not reach.
|
|
11
|
+
//
|
|
12
|
+
// THE UNCOVERED HALF: freshness is checked on the RECEIPT, never on the SOURCE the receipt was
|
|
13
|
+
// derived from. A read copy is a snapshot of the MOMENT OF READING, not of the file, so an edit
|
|
14
|
+
// addressed by a literal string is a race BY CONSTRUCTION. The field case: an edit to Refinement.md
|
|
15
|
+
// died on an assertion because a worker had renumbered the guards between the read and the write.
|
|
16
|
+
// The symmetric case is quieter: three reports saying «the defect is still there» were written from
|
|
17
|
+
// stale copies — and each report was itself perfectly FRESH, so a freshness check on the receipt
|
|
18
|
+
// answered a neighbouring question and handed the answer over as the asked one.
|
|
19
|
+
//
|
|
20
|
+
// WHY A HASH AND NOT AN INSTRUCTION, asserted here rather than merely argued: a rule «re-read
|
|
21
|
+
// immediately before editing» cannot be checked by inspecting the finished file, so its failure is
|
|
22
|
+
// silent — the weakest layer of this package's own ladder. A declared version is checkable by
|
|
23
|
+
// anyone, later, without trusting anybody. P12 is the test that the tool embodies the other half of
|
|
24
|
+
// the same idea: a refusal does not mutate.
|
|
25
|
+
|
|
26
|
+
const { test, describe } = require('node:test');
|
|
27
|
+
const assert = require('node:assert/strict');
|
|
28
|
+
const { spawnSync } = require('node:child_process');
|
|
29
|
+
const crypto = require('node:crypto');
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const os = require('node:os');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
|
|
34
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
35
|
+
const TPL = path.join(PKG, 'templates', '.claude');
|
|
36
|
+
const CHECK = path.join(TPL, 'hooks', 'check-source-version.cjs');
|
|
37
|
+
const DECL = 'docs/source-versions.md';
|
|
38
|
+
|
|
39
|
+
const read = (rel) => fs.readFileSync(path.join(PKG, rel), 'utf8');
|
|
40
|
+
const sha256 = (s) => crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
|
|
41
|
+
|
|
42
|
+
/** Build a throwaway project, run the REAL checker, and return the verdict plus a tree fingerprint. */
|
|
43
|
+
function check(files) {
|
|
44
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-srcver-')));
|
|
45
|
+
const fingerprint = () => {
|
|
46
|
+
const out = [];
|
|
47
|
+
const walk = (d, rel) => {
|
|
48
|
+
for (const e of fs.readdirSync(d).sort()) {
|
|
49
|
+
const full = path.join(d, e);
|
|
50
|
+
const r = rel ? rel + '/' + e : e;
|
|
51
|
+
if (fs.statSync(full).isDirectory()) walk(full, r);
|
|
52
|
+
else out.push(r + ':' + crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex'));
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
walk(dir, '');
|
|
56
|
+
return out.join('\n');
|
|
57
|
+
};
|
|
58
|
+
try {
|
|
59
|
+
for (const [rel, body] of Object.entries(files || {})) {
|
|
60
|
+
const abs = path.join(dir, rel);
|
|
61
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
62
|
+
fs.writeFileSync(abs, body);
|
|
63
|
+
}
|
|
64
|
+
const before = fingerprint();
|
|
65
|
+
const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8' });
|
|
66
|
+
const after = fingerprint();
|
|
67
|
+
return { code: r.status, out: (r.stdout || '') + (r.stderr || ''), before, after };
|
|
68
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const REFINEMENT = '# Refinement\n\n1. Страж A\n2. Страж B\n3. Страж C\n';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A declaration in whatever state the case needs.
|
|
75
|
+
*
|
|
76
|
+
* Defaults are the HEALTHY ones so that every case stays about the single thing it changes; a case
|
|
77
|
+
* that wants silence passes `null` for that field, and silence is asserted as its own outcome.
|
|
78
|
+
*/
|
|
79
|
+
function decl({ work = 'да', run = 'ВЫПОЛНЕНА', reason = null, rows } = {}) {
|
|
80
|
+
const line = (label, value) => (value === null || value === undefined ? '' : '**' + label + ':** ' + value + '\n');
|
|
81
|
+
const body = rows === undefined
|
|
82
|
+
? [['перенумеровать стражей', 'правка', 'docs/Refinement.md', sha256(REFINEMENT)],
|
|
83
|
+
['дефект F1 всё ещё жив', 'вывод', 'docs/Refinement.md', sha256(REFINEMENT)]]
|
|
84
|
+
: rows;
|
|
85
|
+
return '# Source versions\n\n'
|
|
86
|
+
+ line('Правки и выводы', work)
|
|
87
|
+
+ line('Проверка версий', run)
|
|
88
|
+
+ line('Причина', reason)
|
|
89
|
+
+ '\n## Выводы и правки\n\n'
|
|
90
|
+
+ '| Что | Вид | Источник | Хеш источника |\n|---|---|---|---|\n'
|
|
91
|
+
+ body.map((r) => '| ' + r.join(' | ') + ' |').join('\n') + '\n';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const healthy = (over = {}) => ({ 'docs/Refinement.md': REFINEMENT, [DECL]: decl(over) });
|
|
95
|
+
|
|
96
|
+
describe('версия источника объявлена — the deterministic half', () => {
|
|
97
|
+
test('P1 - an edit and a verdict whose source still hashes to the declared value are CLEAN', () => {
|
|
98
|
+
const r = check(healthy());
|
|
99
|
+
assert.equal(r.code, 0, r.out);
|
|
100
|
+
assert.match(r.out, /✅/);
|
|
101
|
+
assert.match(r.out, /1 правк\(и\), 1 вывод\(ов\)/, r.out);
|
|
102
|
+
// The boundary is part of the verdict: a matching hash proves the ground did not move, never
|
|
103
|
+
// that the conclusion standing on it follows.
|
|
104
|
+
assert.match(r.out, /почва НЕ СДВИНУЛАСЬ/, r.out);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('P2 - no declaration at all is NOT clean', () => {
|
|
108
|
+
const r = check({ 'docs/Refinement.md': REFINEMENT });
|
|
109
|
+
assert.equal(r.code, 2, r.out);
|
|
110
|
+
assert.match(r.out, /нет файла docs\/source-versions\.md/);
|
|
111
|
+
assert.match(r.out, /НЕ ЗАДАВАЛСЯ/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('P3 - «no edits and no verdicts» is legitimate, and it is 2 rather than 0', () => {
|
|
115
|
+
const r = check({ [DECL]: decl({ work: 'нет' }) });
|
|
116
|
+
assert.equal(r.code, 2, r.out);
|
|
117
|
+
assert.doesNotMatch(r.out, /✅/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('P4 - an unrecognised closed value stops the check instead of guessing', () => {
|
|
121
|
+
const r = check(healthy({ work: 'частично' }));
|
|
122
|
+
assert.equal(r.code, 2, r.out);
|
|
123
|
+
assert.match(r.out, /нераспознанное значение/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('P5 - «НЕ ВЫПОЛНЕНА» needs a reason from the closed list, and then it is honest', () => {
|
|
127
|
+
const noReason = check(healthy({ run: 'НЕ ВЫПОЛНЕНА' }));
|
|
128
|
+
assert.equal(noReason.code, 2, noReason.out);
|
|
129
|
+
assert.match(noReason.out, /без строки `\*\*Причина:\*\*`/);
|
|
130
|
+
|
|
131
|
+
const freeText = check(healthy({ run: 'НЕ ВЫПОЛНЕНА', reason: 'позже' }));
|
|
132
|
+
assert.equal(freeText.code, 2, freeText.out);
|
|
133
|
+
assert.match(freeText.out, /не из закрытого списка/);
|
|
134
|
+
|
|
135
|
+
const named = check(healthy({ run: 'НЕ ВЫПОЛНЕНА', reason: 'решение-отложено' }));
|
|
136
|
+
assert.equal(named.code, 2, named.out);
|
|
137
|
+
assert.match(named.out, /решение-отложено/);
|
|
138
|
+
// The honest refusal names WHY it matters — a fresh receipt over a stale source.
|
|
139
|
+
assert.match(named.out, /устаревшего источника/);
|
|
140
|
+
assert.doesNotMatch(named.out, /✅/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('P6 - an empty table under «да» is a PROVEN gap', () => {
|
|
144
|
+
const r = check({ 'docs/Refinement.md': REFINEMENT, [DECL]: decl({ rows: [] }) });
|
|
145
|
+
assert.equal(r.code, 1, r.out);
|
|
146
|
+
assert.match(r.out, /ни один не назван/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('P7 - a misspelled Вид is reported, never silently dropped', () => {
|
|
150
|
+
// A row that falls out of the parser is indistinguishable from a row nobody wrote — the same
|
|
151
|
+
// substitution the whole file is about, one level down.
|
|
152
|
+
const r = check({ 'docs/Refinement.md': REFINEMENT, [DECL]: decl({
|
|
153
|
+
rows: [['перенумеровать', 'правочка', 'docs/Refinement.md', sha256(REFINEMENT)]],
|
|
154
|
+
}) });
|
|
155
|
+
assert.equal(r.code, 2, r.out);
|
|
156
|
+
assert.match(r.out, /нераспознанный `Вид`/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('P8 - a row with no source, or no hash, is a PROVEN defect', () => {
|
|
160
|
+
const noSource = check({ 'docs/Refinement.md': REFINEMENT, [DECL]: decl({
|
|
161
|
+
rows: [['перенумеровать', 'правка', '—', sha256(REFINEMENT)]],
|
|
162
|
+
}) });
|
|
163
|
+
assert.equal(noSource.code, 1, noSource.out);
|
|
164
|
+
assert.match(noSource.out, /источник не назван/);
|
|
165
|
+
|
|
166
|
+
const noHash = check({ 'docs/Refinement.md': REFINEMENT, [DECL]: decl({
|
|
167
|
+
rows: [['перенумеровать', 'правка', 'docs/Refinement.md', '—']],
|
|
168
|
+
}) });
|
|
169
|
+
assert.equal(noHash.code, 1, noHash.out);
|
|
170
|
+
assert.match(noHash.out, /версия источника не записана/);
|
|
171
|
+
// The load-bearing argument: a promise cannot be checked, a hash can.
|
|
172
|
+
assert.match(noHash.out, /Обещание перечитать проверить\s+нельзя/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('P9 - a malformed digest is «could not check», not «mismatch»', () => {
|
|
176
|
+
// Different repairs: a typo in the digest is not evidence that the source moved.
|
|
177
|
+
const r = check({ 'docs/Refinement.md': REFINEMENT, [DECL]: decl({
|
|
178
|
+
rows: [['перенумеровать', 'правка', 'docs/Refinement.md', 'deadbeef']],
|
|
179
|
+
}) });
|
|
180
|
+
assert.equal(r.code, 2, r.out);
|
|
181
|
+
assert.match(r.out, /не похож на sha256/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('P10 - a source that does not exist pins nothing', () => {
|
|
185
|
+
const r = check({ [DECL]: decl({
|
|
186
|
+
rows: [['перенумеровать', 'правка', 'docs/absent.md', sha256(REFINEMENT)]],
|
|
187
|
+
}) });
|
|
188
|
+
assert.equal(r.code, 1, r.out);
|
|
189
|
+
assert.match(r.out, /нет на диске/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('P11 - THE fixture: declared H1, live H2 — the edit is REFUSED', () => {
|
|
193
|
+
// The field case, reduced: the guards were renumbered between the read and the write.
|
|
194
|
+
const renumbered = REFINEMENT.replace('2. Страж B', '2. Страж B2');
|
|
195
|
+
const r = check({ 'docs/Refinement.md': renumbered, [DECL]: decl() });
|
|
196
|
+
assert.equal(r.code, 1, r.out);
|
|
197
|
+
assert.match(r.out, /источник изменился после чтения — правка ОТКЛОНЕНА, мутации нет/);
|
|
198
|
+
assert.match(r.out, /перенумеровать стражей/, 'the edit must be NAMED');
|
|
199
|
+
// Both halves of the mechanism must appear: the literal-string race AND the fresh report over a
|
|
200
|
+
// stale source, which is the one a receipt check cannot see.
|
|
201
|
+
assert.match(r.out, /снимок МОМЕНТА ЧТЕНИЯ/);
|
|
202
|
+
assert.match(r.out, /отвечает не на тот вопрос/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('P12 - the refusal does NOT mutate: the tree is byte-identical after the run', () => {
|
|
206
|
+
// «Отказ без мутации» is a property of the TOOL here, not an instruction to its user. Asserted
|
|
207
|
+
// over the whole tree rather than the named source, because a checker that repaired the
|
|
208
|
+
// declaration — or wrote a report beside it — would also be mutating on a refusal.
|
|
209
|
+
const renumbered = REFINEMENT.replace('2. Страж B', '2. Страж B2');
|
|
210
|
+
const r = check({ 'docs/Refinement.md': renumbered, [DECL]: decl() });
|
|
211
|
+
assert.equal(r.code, 1, r.out);
|
|
212
|
+
assert.equal(r.after, r.before, 'the checker changed the tree while refusing');
|
|
213
|
+
|
|
214
|
+
// And the same on the clean path, so the property is not accidentally about failure only.
|
|
215
|
+
const ok = check(healthy());
|
|
216
|
+
assert.equal(ok.code, 0, ok.out);
|
|
217
|
+
assert.equal(ok.after, ok.before, 'the checker changed the tree while passing');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('P13 - and the SAME declaration, re-pinned to the live file, is accepted', () => {
|
|
221
|
+
const renumbered = REFINEMENT.replace('2. Страж B', '2. Страж B2');
|
|
222
|
+
const r = check({ 'docs/Refinement.md': renumbered, [DECL]: decl({
|
|
223
|
+
rows: [['перенумеровать стражей', 'правка', 'docs/Refinement.md', sha256(renumbered)]],
|
|
224
|
+
}) });
|
|
225
|
+
assert.equal(r.code, 0, r.out);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('P14 - a VERDICT built on a stale source is refused exactly like an edit', () => {
|
|
229
|
+
// The symmetric half, and the quieter one: the report is fresh, its ground is not.
|
|
230
|
+
const renumbered = REFINEMENT.replace('3. Страж C', '3. Страж C — исправлен');
|
|
231
|
+
const r = check({ 'docs/Refinement.md': renumbered, [DECL]: decl({
|
|
232
|
+
rows: [['дефект F1 всё ещё жив', 'вывод', 'docs/Refinement.md', sha256(REFINEMENT)]],
|
|
233
|
+
}) });
|
|
234
|
+
assert.equal(r.code, 1, r.out);
|
|
235
|
+
assert.match(r.out, /\(verdict\)/, 'the report must say WHICH kind was refused');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('P15 - no non-zero exit ever prints the clean marker', () => {
|
|
239
|
+
const cases = [
|
|
240
|
+
{},
|
|
241
|
+
{ [DECL]: decl({ work: 'нет' }) },
|
|
242
|
+
{ [DECL]: decl({ rows: [] }) },
|
|
243
|
+
{ 'docs/Refinement.md': REFINEMENT + 'x', [DECL]: decl() },
|
|
244
|
+
{ 'docs/Refinement.md': REFINEMENT, [DECL]: decl({ rows: [['a', 'правка', 'docs/Refinement.md', 'zz']] }) },
|
|
245
|
+
];
|
|
246
|
+
for (const files of cases) {
|
|
247
|
+
const r = check(files);
|
|
248
|
+
assert.notEqual(r.code, 0, JSON.stringify(Object.keys(files)));
|
|
249
|
+
assert.doesNotMatch(r.out, /✅/, r.out);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
/** The seam's FORCE, separately from its vocabulary. */
|
|
255
|
+
function seamForceProblems(text) {
|
|
256
|
+
const problems = [];
|
|
257
|
+
if (!/Every EDIT and every VERDICT MUST declare the sha256 of the source it was built on/.test(text)) {
|
|
258
|
+
problems.push('the version precondition is advisory, not imperative');
|
|
259
|
+
}
|
|
260
|
+
if (!/refusal WITHOUT mutation/.test(text)) {
|
|
261
|
+
problems.push('a mismatch no longer refuses — applying the edit to the newer text is the defect');
|
|
262
|
+
}
|
|
263
|
+
if (!/snapshot of the moment\s*\n?\s*of reading, not of the file/.test(text)) {
|
|
264
|
+
problems.push('the mechanism is gone — without it the duty reads as bookkeeping');
|
|
265
|
+
}
|
|
266
|
+
if (!/node \.claude\/hooks\/check-source-version\.cjs \./.test(text)) {
|
|
267
|
+
problems.push('the deterministic half is not invoked at the seam');
|
|
268
|
+
}
|
|
269
|
+
return problems;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
describe('the seam still mandates, and every counter agrees', () => {
|
|
273
|
+
const SEAMS = ['templates/.claude/commands/start.md', 'templates/.claude/commands/feature.md'];
|
|
274
|
+
|
|
275
|
+
test('P16 - both WRITING dispatchers carry the obligation, and softening it fires', () => {
|
|
276
|
+
for (const rel of SEAMS) {
|
|
277
|
+
const source = read(rel);
|
|
278
|
+
assert.deepEqual(seamForceProblems(source), [], rel);
|
|
279
|
+
|
|
280
|
+
// The exact softening the backlog record forbids: a promise to re-read instead of a declared
|
|
281
|
+
// version. It is unverifiable by inspection, which is why it must not be able to ship.
|
|
282
|
+
const promise = source.replace(
|
|
283
|
+
'Every EDIT and every VERDICT MUST declare the sha256 of the source it was built on',
|
|
284
|
+
'Re-read the source immediately before editing it');
|
|
285
|
+
assert.notEqual(promise, source, rel + ': mutation fixture did not apply — the sentence moved');
|
|
286
|
+
assert.deepEqual(seamForceProblems(promise),
|
|
287
|
+
['the version precondition is advisory, not imperative'],
|
|
288
|
+
rel + ': replacing the version with a promise must fire EXACTLY the precondition predicate');
|
|
289
|
+
|
|
290
|
+
// Keep the declaration, drop the refusal: the edit gets applied to the newer text anyway,
|
|
291
|
+
// which is the defect with an extra field written next to it.
|
|
292
|
+
const applied = source.replace('refusal WITHOUT mutation', 'a warning worth noting');
|
|
293
|
+
assert.ok(seamForceProblems(applied).some((p) => p.includes('no longer refuses')), rel);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('P17 - it is a hooks component wired to NO event, and every counter agrees', () => {
|
|
298
|
+
const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
|
|
299
|
+
assert.ok(COMPONENTS.hooks.items['check-source-version'],
|
|
300
|
+
'it must be registered, or init/doctor/verify will not know it');
|
|
301
|
+
const settings = read('templates/.claude/settings.json');
|
|
302
|
+
assert.ok(!settings.includes('check-source-version'),
|
|
303
|
+
'it must not be wired to an event: this package\'s hooks are non-blocking by contract');
|
|
304
|
+
|
|
305
|
+
const statusline = read('templates/.claude/hooks/statusline.cjs');
|
|
306
|
+
const hooks = statusline.match(/hooksExpected:\s*(\d+)/);
|
|
307
|
+
assert.ok(hooks, 'statusline must declare hooksExpected');
|
|
308
|
+
const onDisk = fs.readdirSync(path.join(TPL, 'hooks')).filter((f) => f.endsWith('.cjs'));
|
|
309
|
+
assert.equal(Number(hooks[1]), Object.keys(COMPONENTS.hooks.items).length,
|
|
310
|
+
'the status line would report a phantom missing hook: ' + hooks[1]);
|
|
311
|
+
assert.equal(onDisk.length, Object.keys(COMPONENTS.hooks.items).length,
|
|
312
|
+
'the shipped directory and the declared contract disagree: ' + onDisk.join(', '));
|
|
313
|
+
|
|
314
|
+
const rule = read('templates/.claude/rules/replicate-pipeline.md');
|
|
315
|
+
assert.match(rule, /check-source-version\.cjs/, 'the hook inventory must list it');
|
|
316
|
+
const inventory = rule.match(/\*\*Hooks \((\d+) files/);
|
|
317
|
+
assert.ok(inventory, 'the rule must state the hook count it is inventorying');
|
|
318
|
+
assert.equal(Number(inventory[1]), onDisk.length,
|
|
319
|
+
'the rule prose and the shipped directory disagree — the exact split this family exists for');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('P18 - the mutation registry carries this feature\'s guards', () => {
|
|
323
|
+
const registry = JSON.parse(read('test/mutation-registry.json'));
|
|
324
|
+
const expected = {
|
|
325
|
+
'source-version-cannot-check-is-not-clean': 'templates/.claude/hooks/check-source-version.cjs',
|
|
326
|
+
'source-version-pin-must-still-match': 'templates/.claude/hooks/check-source-version.cjs',
|
|
327
|
+
'source-version-verdicts-age-like-edits': 'templates/.claude/hooks/check-source-version.cjs',
|
|
328
|
+
'source-version-seam-declares-not-promises': 'templates/.claude/commands/feature.md',
|
|
329
|
+
};
|
|
330
|
+
for (const [id, file] of Object.entries(expected)) {
|
|
331
|
+
const entry = registry.entries.find((e) => e.id === id);
|
|
332
|
+
assert.ok(entry, 'missing targeted mutation id ' + id);
|
|
333
|
+
assert.equal(entry.file, file, id + ' mutates the wrong surface');
|
|
334
|
+
assert.ok(entry.property && entry.property.length > 40,
|
|
335
|
+
id + ' needs a behavioral property, not a cosmetic label');
|
|
336
|
+
assert.ok(entry.mutation && entry.mutation.find && entry.mutation.replace,
|
|
337
|
+
id + ' needs a real source mutation');
|
|
338
|
+
assert.ok(Number.isInteger(entry.minFailing) && entry.minFailing >= 1,
|
|
339
|
+
id + ' must require at least one failing test');
|
|
340
|
+
assert.equal(read(entry.file).split(entry.mutation.find).length - 1, 1,
|
|
341
|
+
id + ': mutation anchor must occur exactly once');
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
});
|