@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,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate the feature review's binding to the specification it judged.
|
|
6
|
+
*
|
|
7
|
+
* Usage: node check-review-contract.cjs <project-root> <feature-slug>
|
|
8
|
+
* Exit 0: contract passes; 1: named contract gaps; 2: inputs could not be established.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
|
|
15
|
+
const AC_ID_SOURCE = 'AC-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*-[0-9]+';
|
|
16
|
+
const AC_HEADING = new RegExp('^###\\s+(' + AC_ID_SOURCE + ')(?:\\s|$)');
|
|
17
|
+
const FAMILIES = new Set(['claude', 'codex', 'human', 'unknown']);
|
|
18
|
+
const VERDICTS = new Set(['met', 'not met', 'unverifiable']);
|
|
19
|
+
|
|
20
|
+
function say(line) { process.stdout.write(line + '\n'); }
|
|
21
|
+
|
|
22
|
+
function cannotCheck(reason) {
|
|
23
|
+
say('NOT-ESTABLISHED review contract: ' + reason);
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isInside(root, candidate) {
|
|
28
|
+
const relative = path.relative(root, candidate);
|
|
29
|
+
return relative !== '' && relative !== '..' && !relative.startsWith('..' + path.sep)
|
|
30
|
+
&& !path.isAbsolute(relative);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function projectRoot(input) {
|
|
34
|
+
let root;
|
|
35
|
+
try { root = fs.realpathSync(path.resolve(input)); } catch (error) {
|
|
36
|
+
cannotCheck('project root is missing or unreadable: ' + ((error && error.message) || error));
|
|
37
|
+
}
|
|
38
|
+
let stat;
|
|
39
|
+
try { stat = fs.statSync(root); } catch (error) {
|
|
40
|
+
cannotCheck('project root cannot be inspected: ' + ((error && error.message) || error));
|
|
41
|
+
}
|
|
42
|
+
if (!stat.isDirectory()) cannotCheck('project root is not a directory: ' + root);
|
|
43
|
+
return root;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function safeRead(root, relative, label) {
|
|
47
|
+
const candidate = path.resolve(root, relative);
|
|
48
|
+
if (!isInside(root, candidate)) cannotCheck(label + ' escapes the project root: ' + relative);
|
|
49
|
+
|
|
50
|
+
let current = root;
|
|
51
|
+
const parts = path.relative(root, candidate).split(path.sep);
|
|
52
|
+
for (let index = 0; index < parts.length; index++) {
|
|
53
|
+
current = path.join(current, parts[index]);
|
|
54
|
+
let stat;
|
|
55
|
+
try { stat = fs.lstatSync(current); } catch (error) {
|
|
56
|
+
cannotCheck(label + ' is missing or unreadable: ' + current + ' ('
|
|
57
|
+
+ ((error && error.message) || error) + ')');
|
|
58
|
+
}
|
|
59
|
+
if (stat.isSymbolicLink()) cannotCheck(label + ' is a symlink: ' + current);
|
|
60
|
+
if (index < parts.length - 1 && !stat.isDirectory()) {
|
|
61
|
+
cannotCheck(label + ' has a non-directory path component: ' + current);
|
|
62
|
+
}
|
|
63
|
+
if (index === parts.length - 1) {
|
|
64
|
+
if (!stat.isFile()) cannotCheck(label + ' is not a regular file: ' + current);
|
|
65
|
+
if ((stat.mode & 0o444) === 0) cannotCheck(label + ' is unreadable: ' + current);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let real;
|
|
70
|
+
try { real = fs.realpathSync(candidate); } catch (error) {
|
|
71
|
+
cannotCheck(label + ' cannot be resolved: ' + ((error && error.message) || error));
|
|
72
|
+
}
|
|
73
|
+
if (!isInside(root, real)) cannotCheck(label + ' resolves outside the project root: ' + real);
|
|
74
|
+
|
|
75
|
+
try { return fs.readFileSync(real); } catch (error) {
|
|
76
|
+
cannotCheck(label + ' is unreadable: ' + ((error && error.message) || error));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function specificationIds(buffer) {
|
|
81
|
+
const lines = buffer.toString('utf8').split(/\n/).map((line) => line.replace(/\r$/, ''));
|
|
82
|
+
const ids = [];
|
|
83
|
+
let fence = '';
|
|
84
|
+
for (let index = 0; index < lines.length; index++) {
|
|
85
|
+
const line = lines[index];
|
|
86
|
+
const marker = line.trimStart().match(/^(```|~~~)/);
|
|
87
|
+
if (marker) {
|
|
88
|
+
if (!fence) fence = marker[1][0];
|
|
89
|
+
else if (marker[1][0] === fence) fence = '';
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (fence) continue;
|
|
93
|
+
if (!/^###\s+AC-/.test(line)) continue;
|
|
94
|
+
const match = line.match(AC_HEADING);
|
|
95
|
+
if (!match) cannotCheck('malformed AC heading at specification line ' + (index + 1));
|
|
96
|
+
ids.push(match[1]);
|
|
97
|
+
}
|
|
98
|
+
if (fence) cannotCheck('specification has an unclosed fenced code block');
|
|
99
|
+
const duplicate = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))];
|
|
100
|
+
if (duplicate.length) cannotCheck('specification has duplicate AC id: ' + duplicate.join(', '));
|
|
101
|
+
return ids;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function reportRows(lines, gaps) {
|
|
105
|
+
const section = lines.findIndex((line) => line.trim() === '## Spec conformance');
|
|
106
|
+
if (section < 0) {
|
|
107
|
+
gaps.push('GAP Spec conformance section missing');
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let header = -1;
|
|
112
|
+
for (let index = section + 1; index < lines.length; index++) {
|
|
113
|
+
if (/^##\s+/.test(lines[index])) break;
|
|
114
|
+
if (lines[index].trim() === '| Criterion | Verdict | Evidence |') {
|
|
115
|
+
header = index;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (header < 0 || !/^\|\s*-+\s*\|\s*-+\s*\|\s*-+\s*\|$/.test((lines[header + 1] || '').trim())) {
|
|
120
|
+
gaps.push('GAP Spec conformance table missing or malformed');
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const rows = [];
|
|
125
|
+
for (let index = header + 2; index < lines.length; index++) {
|
|
126
|
+
const line = lines[index];
|
|
127
|
+
if (!line.trim()) break;
|
|
128
|
+
if (/^##?\s+/.test(line)) break;
|
|
129
|
+
if (!line.trimStart().startsWith('|')) break;
|
|
130
|
+
const match = line.match(/^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*$/);
|
|
131
|
+
if (!match) cannotCheck('malformed Spec conformance row at report line ' + (index + 1));
|
|
132
|
+
rows.push({ id: match[1].trim(), verdict: match[2].trim(), evidence: match[3].trim() });
|
|
133
|
+
}
|
|
134
|
+
return rows;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateReport(specification, report, slug) {
|
|
138
|
+
const ids = specificationIds(specification);
|
|
139
|
+
const idSet = new Set(ids);
|
|
140
|
+
const lines = report.toString('utf8').split(/\n/).map((line) => line.replace(/\r$/, ''));
|
|
141
|
+
const firstTwenty = lines.slice(0, 20);
|
|
142
|
+
const gaps = [];
|
|
143
|
+
|
|
144
|
+
const familyLine = firstTwenty.find((line) => /^Reviewer family:/.test(line));
|
|
145
|
+
if (familyLine === undefined) gaps.push('GAP Reviewer family line missing');
|
|
146
|
+
else {
|
|
147
|
+
const family = familyLine.replace(/^Reviewer family:\s*/, '').trim();
|
|
148
|
+
if (!FAMILIES.has(family)) gaps.push('GAP Reviewer family invalid value=' + (family || '<empty>'));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const revisionLines = firstTwenty.filter((line) => /^Spec revision:/.test(line));
|
|
152
|
+
if (revisionLines.length !== 1) {
|
|
153
|
+
cannotCheck('first 20 report lines require exactly one Spec revision line');
|
|
154
|
+
}
|
|
155
|
+
const revision = revisionLines[0].match(/^Spec revision: sha256:([a-f0-9]{64})$/);
|
|
156
|
+
if (!revision) cannotCheck('malformed Spec revision line in first 20 report lines');
|
|
157
|
+
const actual = crypto.createHash('sha256').update(specification).digest('hex');
|
|
158
|
+
if (revision[1] !== actual) {
|
|
159
|
+
gaps.push('GAP Spec revision mismatch report=' + revision[1].slice(0, 12)
|
|
160
|
+
+ ' specification=' + actual.slice(0, 12));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const rows = reportRows(lines, gaps);
|
|
164
|
+
const counts = new Map();
|
|
165
|
+
for (const row of rows) counts.set(row.id, (counts.get(row.id) || 0) + 1);
|
|
166
|
+
|
|
167
|
+
for (const id of ids) {
|
|
168
|
+
if (!counts.has(id)) gaps.push('GAP ' + id + ' has no Spec conformance row');
|
|
169
|
+
}
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
if (!idSet.has(row.id)) gaps.push('GAP ' + row.id + ' row id is not in the specification');
|
|
172
|
+
if ((counts.get(row.id) || 0) > 1 && rows.findIndex((item) => item.id === row.id) === rows.indexOf(row)) {
|
|
173
|
+
gaps.push('GAP ' + row.id + ' duplicate Spec conformance row');
|
|
174
|
+
}
|
|
175
|
+
if (!VERDICTS.has(row.verdict)) {
|
|
176
|
+
gaps.push('GAP ' + row.id + ' verdict invalid value=' + (row.verdict || '<empty>'));
|
|
177
|
+
}
|
|
178
|
+
if ((row.verdict === 'met' || row.verdict === 'not met') && !row.evidence) {
|
|
179
|
+
gaps.push('GAP ' + row.id + ' evidence empty for verdict=' + row.verdict);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (gaps.length) {
|
|
184
|
+
for (const gap of gaps) say(gap);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
say('PASS review-contract feature=' + slug + ' AC-ids=' + ids.length + ' rows=' + rows.length);
|
|
188
|
+
process.exit(0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function main() {
|
|
192
|
+
const args = process.argv.slice(2);
|
|
193
|
+
if (args.length !== 2) cannotCheck('usage: node check-review-contract.cjs <project-root> <feature-slug>');
|
|
194
|
+
const [rootInput, slug] = args;
|
|
195
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) cannotCheck('malformed feature slug: ' + slug);
|
|
196
|
+
const root = projectRoot(rootInput);
|
|
197
|
+
const base = path.join('docs', 'features', slug);
|
|
198
|
+
const specification = safeRead(root, path.join(base, '01_specification.md'), 'specification');
|
|
199
|
+
const report = safeRead(root, path.join(base, 'review-report.md'), 'review report');
|
|
200
|
+
validateReport(specification, report, slug);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try { main(); } catch (error) {
|
|
204
|
+
cannotCheck('internal error: ' + String((error && error.message) || error));
|
|
205
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* check-source-version.cjs — правка и вывод объявляют ВЕРСИЮ источника, на которой построены?
|
|
6
|
+
*
|
|
7
|
+
* NOT an event hook. Like its ten siblings in this directory it is a plain Node utility; nothing
|
|
8
|
+
* registers it in settings.json, because this package's hooks are NON-BLOCKING by contract (pinned
|
|
9
|
+
* by tests/unit/hooks-project-anchored.test.js, which requires exit 0) — a hook could only print, it
|
|
10
|
+
* could never refuse. Invoke it:
|
|
11
|
+
*
|
|
12
|
+
* node .claude/hooks/check-source-version.cjs [path-to-project]
|
|
13
|
+
*
|
|
14
|
+
* WHAT IS ALREADY PRESCRIBED, AND IS NOT RE-OPENED HERE. The half «take the status from the FILE,
|
|
15
|
+
* not from the worker's narrative» is already required: the coordinator checks each receipt for
|
|
16
|
+
* existence, non-emptiness and mtime freshness before merging, and the decisive field diagnosis
|
|
17
|
+
* there was an `ls -la` comparing mtimes taken BEFORE the run. That half stands. This file adds the
|
|
18
|
+
* one it does not reach.
|
|
19
|
+
*
|
|
20
|
+
* THE UNCOVERED HALF: FRESHNESS IS CHECKED ON THE RECEIPT, NEVER ON THE SOURCE THE RECEIPT WAS
|
|
21
|
+
* DERIVED FROM.
|
|
22
|
+
*
|
|
23
|
+
* A read copy is a snapshot of the MOMENT OF READING, not of the file. So an edit addressed by a
|
|
24
|
+
* literal string is a race BY CONSTRUCTION: read at T1, the file changes at T2, write at T3 against
|
|
25
|
+
* a string that no longer exists — or, worse, that now means something else. The field case is
|
|
26
|
+
* exact: an edit to `Refinement.md` died on an assertion because a worker had renumbered the guards
|
|
27
|
+
* between the read and the write. And the symmetric case is quieter and worse: three reports saying
|
|
28
|
+
* «the defect is still there» were written from stale copies, and each report was itself perfectly
|
|
29
|
+
* FRESH. A freshness check on the receipt cannot see that — it answers a neighbouring question and
|
|
30
|
+
* hands the answer over as if it were the asked one.
|
|
31
|
+
*
|
|
32
|
+
* WHY A HASH AND NOT AN INSTRUCTION. The obvious cure — a rule saying «re-read immediately before
|
|
33
|
+
* editing» — is UNVERIFIABLE, and that is not a quibble: inspecting the finished file cannot show
|
|
34
|
+
* whether a re-read happened, so the rule's failure is silent, which puts it on the weakest layer of
|
|
35
|
+
* this package's own ladder. A DECLARED VERSION is checkable by anyone at any later time. The
|
|
36
|
+
* promise is not; that is the whole argument for this shape.
|
|
37
|
+
*
|
|
38
|
+
* WHY A MISMATCH IS A REFUSAL WITHOUT MUTATION. When the declared version and the live file
|
|
39
|
+
* disagree, the correct action is to STOP — not to apply the edit against the newer text and hope.
|
|
40
|
+
* This checker embodies that literally: it never writes anything, anywhere. It reads, it decides,
|
|
41
|
+
* it exits. «Отказ без мутации» is a property of the tool, not an instruction to its user.
|
|
42
|
+
*
|
|
43
|
+
* WHAT THIS FILE CAN AND CANNOT DECIDE — read before trusting exit 0.
|
|
44
|
+
*
|
|
45
|
+
* It settles that every declared edit and every declared verdict names a source that EXISTS and
|
|
46
|
+
* whose live sha256 EQUALS the recorded one — that is, that nothing the run's conclusions rest on
|
|
47
|
+
* has moved since those conclusions were drawn. It does NOT decide whether the conclusion follows
|
|
48
|
+
* from the source, nor whether the worker really re-read anything: a matching hash proves the ground
|
|
49
|
+
* did not move, never that the reasoning standing on it is right. That stays layer 3.
|
|
50
|
+
*
|
|
51
|
+
* THE EXACT FORM OF `docs/source-versions.md` — kept here on purpose: this file is not part of the
|
|
52
|
+
* always-loaded corpus, so the long form costs nothing per run.
|
|
53
|
+
*
|
|
54
|
+
* **Правки и выводы:** да (да | нет — `нет` is a legitimate answer)
|
|
55
|
+
* **Проверка версий:** ВЫПОЛНЕНА (ВЫПОЛНЕНА | НЕ ВЫПОЛНЕНА)
|
|
56
|
+
* **Причина:** — (required when НЕ ВЫПОЛНЕНА; one of the closed REASONS)
|
|
57
|
+
*
|
|
58
|
+
* ## Выводы и правки
|
|
59
|
+
*
|
|
60
|
+
* | Что | Вид | Источник | Хеш источника |
|
|
61
|
+
* |---|---|---|---|
|
|
62
|
+
* | перенумеровать стражей | правка | docs/Refinement.md | <64 hex> |
|
|
63
|
+
* | дефект F1 всё ещё жив | вывод | docs/Refinement.md | <64 hex> |
|
|
64
|
+
*
|
|
65
|
+
* Exit codes — three, and the third is the point:
|
|
66
|
+
* 0 every edit and every verdict names a source that exists and still hashes to the declared
|
|
67
|
+
* value
|
|
68
|
+
* 1 a defect is PROVEN and named: a declared version that no longer matches the live file (the
|
|
69
|
+
* race, caught before the write), a source that does not exist, a row with no source or no
|
|
70
|
+
* hash, or an empty table under «Правки и выводы: да»
|
|
71
|
+
* 2 THE CHECK DID NOT RUN — no declaration file, an unrecognised value, a malformed hash,
|
|
72
|
+
* duplicate rows, or the legitimate answers «правок и выводов нет» and «проверка НЕ
|
|
73
|
+
* ВЫПОЛНЕНА, причина такая-то»
|
|
74
|
+
*
|
|
75
|
+
* A checker that answers "clean" when it could not look converts an unknown into a reassurance.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
const fs = require('node:fs');
|
|
79
|
+
const path = require('node:path');
|
|
80
|
+
const crypto = require('node:crypto');
|
|
81
|
+
|
|
82
|
+
const DECL = path.join('docs', 'source-versions.md');
|
|
83
|
+
|
|
84
|
+
/** Are there edits or verdicts to answer for? A CLOSED set — `нет` is legitimate and exits 2. */
|
|
85
|
+
const HAS_WORK = { 'ДА': true, 'НЕТ': false };
|
|
86
|
+
|
|
87
|
+
/** Was the version review performed? CLOSED, and the negative answer is honest, not a failure —
|
|
88
|
+
* `honest-configuration` CFG-I4: an unreachable truth yields UNKNOWN, never a plausible value. */
|
|
89
|
+
const RUN_STATUS = { 'ВЫПОЛНЕНА': 'done', 'НЕ ВЫПОЛНЕНА': 'not-done' };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Why versions were not settled. CLOSED list — each entry names a DIFFERENT repair:
|
|
93
|
+
* источник-не-назван — the edits exist but nobody recorded what they were built on
|
|
94
|
+
* источник-недоступен — the source is outside this tree; get it, then pin it
|
|
95
|
+
* решение-отложено — take the decision (this is the one that becomes a lost update)
|
|
96
|
+
* вне-объёма — decide it is out of scope and record the decision
|
|
97
|
+
*/
|
|
98
|
+
const REASONS = ['источник-не-назван', 'источник-недоступен', 'решение-отложено', 'вне-объёма'];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What a row is. CLOSED, and the two halves are kept apart on purpose: an EDIT mutates a file and a
|
|
102
|
+
* VERDICT only asserts, but both are built on a snapshot and both are wrong in the same way when
|
|
103
|
+
* the snapshot has aged. The field case produced one of each — a failed edit, and three reports
|
|
104
|
+
* that were themselves perfectly fresh while their source was not.
|
|
105
|
+
*/
|
|
106
|
+
const KIND = { 'правка': 'edit', 'вывод': 'verdict' };
|
|
107
|
+
|
|
108
|
+
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
109
|
+
|
|
110
|
+
function say(s) { process.stdout.write(s + '\n'); }
|
|
111
|
+
|
|
112
|
+
/** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
|
|
113
|
+
function cannotCheck(reason, hint) {
|
|
114
|
+
say('⚠️ проверка НЕ выполнена: ' + reason);
|
|
115
|
+
if (hint) say(' ' + hint);
|
|
116
|
+
process.exit(2);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Exit 1 with the defect NAMED. A violation that cannot be named is a 2, not a 1. */
|
|
120
|
+
function proven(title, lines, tail) {
|
|
121
|
+
say('❌ ' + title);
|
|
122
|
+
for (const line of lines) say(' • ' + line);
|
|
123
|
+
if (tail) say(' ' + tail);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The value of a `**Label:** value` header line, or null when the label is absent entirely. */
|
|
128
|
+
function header(text, label) {
|
|
129
|
+
const re = new RegExp('^\\s*\\*\\*' + label + ':?\\*\\*\\s*:?(.*)$', 'im');
|
|
130
|
+
const m = re.exec(text);
|
|
131
|
+
if (!m) return null;
|
|
132
|
+
return m[1].trim().replace(/^[«"`]|[»"`]$/g, '').trim();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A header value read against a CLOSED map, with both failure modes kept apart. */
|
|
136
|
+
function closedHeader(text, label, map, what) {
|
|
137
|
+
const raw = header(text, label);
|
|
138
|
+
if (raw === null) {
|
|
139
|
+
cannotCheck('в объявлении нет строки `**' + label + ':**`',
|
|
140
|
+
what + ' — допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
141
|
+
}
|
|
142
|
+
const key = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
143
|
+
if (!Object.prototype.hasOwnProperty.call(map, key)) {
|
|
144
|
+
cannotCheck('нераспознанное значение `' + label + '`: ' + (key === '' ? '(пусто)' : key),
|
|
145
|
+
'допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
146
|
+
}
|
|
147
|
+
return map[key];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The declaration table.
|
|
152
|
+
*
|
|
153
|
+
* A row is a markdown table row with at least four cells whose SECOND cell is one of the two kinds —
|
|
154
|
+
* so the header row and the `|---|` separator cannot be mistaken for data, and a row whose kind is
|
|
155
|
+
* misspelled is REPORTED rather than silently dropped. Dropping it silently would be the same class
|
|
156
|
+
* of defect the whole file is about: an unread declaration is indistinguishable from an absent one.
|
|
157
|
+
*/
|
|
158
|
+
function declRows(text) {
|
|
159
|
+
const rows = [];
|
|
160
|
+
for (const raw of text.split('\n')) {
|
|
161
|
+
const line = raw.trim();
|
|
162
|
+
if (!line.startsWith('|')) continue;
|
|
163
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
164
|
+
if (cells.length < 6) continue; // '' + 4 columns + ''
|
|
165
|
+
const what = cells[1];
|
|
166
|
+
if (!what || /^:?-+:?$/.test(what) || what.toLowerCase() === 'что') continue;
|
|
167
|
+
rows.push({
|
|
168
|
+
what,
|
|
169
|
+
kind: cells[2].toLowerCase(),
|
|
170
|
+
source: cells[3].replace(/^[`«"]|[`»"]$/g, '').trim(),
|
|
171
|
+
hash: cells[4].replace(/^[`«"]|[`»"]$/g, '').trim(),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return rows;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function main() {
|
|
178
|
+
const root = process.argv[2] || '.';
|
|
179
|
+
try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
|
|
180
|
+
catch { cannotCheck('путь не существует: ' + root); }
|
|
181
|
+
|
|
182
|
+
const abs = path.join(root, DECL);
|
|
183
|
+
let text;
|
|
184
|
+
try {
|
|
185
|
+
if (!fs.statSync(abs).isFile()) cannotCheck(DECL + ' существует, но это не файл');
|
|
186
|
+
text = fs.readFileSync(abs, 'utf-8');
|
|
187
|
+
} catch (e) {
|
|
188
|
+
if (e && e.code === 'ENOENT') {
|
|
189
|
+
cannotCheck('нет файла ' + DECL,
|
|
190
|
+
'это значит, что вопрос о версии источника НЕ ЗАДАВАЛСЯ — а НЕ что правки построены на '
|
|
191
|
+
+ 'свежем чтении; прогон без правок и выводов отвечает `**Правки и выводы:** нет`, и это '
|
|
192
|
+
+ 'законный ответ');
|
|
193
|
+
}
|
|
194
|
+
cannotCheck('не читается ' + DECL + ': ' + ((e && e.message) || e));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 1. Is there anything built on a source at all? Nothing to pin → 2, never 0.
|
|
198
|
+
const hasWork = closedHeader(text, 'Правки и выводы', HAS_WORK,
|
|
199
|
+
'без этой строки «правок не было» неотличимо от «про источники не подумали»');
|
|
200
|
+
if (!hasWork) {
|
|
201
|
+
cannotCheck('объявление говорит «Правки и выводы: нет» — ни одна правка и ни один вывод не '
|
|
202
|
+
+ 'построены на прочитанном файле',
|
|
203
|
+
'это законный ответ, а не нарушение: закреплять версию не на чем');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 2. A named refusal is honest and exits 2.
|
|
207
|
+
const run = closedHeader(text, 'Проверка версий', RUN_STATUS,
|
|
208
|
+
'без этой строки «версии не сверяли» неотличимо от «сверили»');
|
|
209
|
+
if (run === 'not-done') {
|
|
210
|
+
const raw = header(text, 'Причина');
|
|
211
|
+
if (raw === null || raw === '') {
|
|
212
|
+
cannotCheck('проверка версий НЕ ВЫПОЛНЕНА без строки `**Причина:**`',
|
|
213
|
+
'причина обязательна и берётся из закрытого списка: ' + REASONS.join(' | ')
|
|
214
|
+
+ ' — каждая означает СВОЙ ремонт');
|
|
215
|
+
}
|
|
216
|
+
const picked = REASONS.filter((r) => raw.includes(r));
|
|
217
|
+
if (picked.length !== 1) {
|
|
218
|
+
cannotCheck('причина «' + raw + '» не из закрытого списка (или названо сразу несколько)',
|
|
219
|
+
'допустимы ровно: ' + REASONS.join(' | '));
|
|
220
|
+
}
|
|
221
|
+
cannotCheck('проверка версий НЕ ВЫПОЛНЕНА, причина: ' + picked[0],
|
|
222
|
+
'честное «неизвестно», а не «правки построены на актуальном чтении»; пока причина не '
|
|
223
|
+
+ 'закрыта, свежая квитанция может добросовестно излагать выводы из устаревшего источника');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 3. The rows.
|
|
227
|
+
const rows = declRows(text);
|
|
228
|
+
if (rows.length === 0) {
|
|
229
|
+
proven('объявлены правки и выводы, но ни один не назван',
|
|
230
|
+
['таблица `## Выводы и правки` пуста'],
|
|
231
|
+
'нельзя сверить версию источника у правки, которой нет в списке. Пустая таблица под «да» — '
|
|
232
|
+
+ 'доказанный пропуск, а не неизвестность.');
|
|
233
|
+
}
|
|
234
|
+
const keys = rows.map((r) => (r.what + '|' + r.source).toLowerCase());
|
|
235
|
+
const dupes = [...new Set(keys.filter((k, i) => keys.indexOf(k) !== i))];
|
|
236
|
+
if (dupes.length) {
|
|
237
|
+
cannotCheck('в таблице повторяются строки: ' + dupes.join(', '),
|
|
238
|
+
'одна правка над одним источником — одна строка; иначе одна сверка закрывает две разные');
|
|
239
|
+
}
|
|
240
|
+
const badKind = rows.filter((r) => !Object.prototype.hasOwnProperty.call(KIND, r.kind));
|
|
241
|
+
if (badKind.length) {
|
|
242
|
+
cannotCheck('нераспознанный `Вид`: '
|
|
243
|
+
+ badKind.map((r) => r.what + ' → ' + (r.kind || '(пусто)')).join(', '),
|
|
244
|
+
'допустимы ровно: ' + Object.keys(KIND).join(' | ')
|
|
245
|
+
+ ' — правка меняет файл, вывод утверждает о нём, и устаревают они одинаково');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const noSource = rows.filter((r) => !r.source || r.source === '—');
|
|
249
|
+
if (noSource.length) {
|
|
250
|
+
proven('источник не назван', noSource.map((r) => r.what),
|
|
251
|
+
'правка или вывод, не назвавшие файл, на котором построены, не могут быть сверены НИКОГДА: '
|
|
252
|
+
+ 'проверять нечего, а выглядит это как проверенное.');
|
|
253
|
+
}
|
|
254
|
+
const noHash = rows.filter((r) => !r.hash || r.hash === '—');
|
|
255
|
+
if (noHash.length) {
|
|
256
|
+
proven('версия источника не записана', noHash.map((r) => r.what + ' ← ' + r.source),
|
|
257
|
+
'без записанной версии «источник не менялся» и «источник переписали после чтения» '
|
|
258
|
+
+ 'выглядят одинаково: путь остаётся тем же в обоих случаях. Обещание перечитать проверить '
|
|
259
|
+
+ 'нельзя, записанный хеш — можно.');
|
|
260
|
+
}
|
|
261
|
+
const malformed = rows.filter((r) => !HEX64.test(r.hash));
|
|
262
|
+
if (malformed.length) {
|
|
263
|
+
cannotCheck('`Хеш источника` не похож на sha256: '
|
|
264
|
+
+ malformed.map((r) => r.what + ' → ' + r.hash).join(', '),
|
|
265
|
+
'нужны ровно 64 шестнадцатеричных знака — `sha256sum <источник>`');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 4. THE LOAD-BEARING CHECK. Compare each declared version against the live file. Nothing is
|
|
269
|
+
// written here, by construction: a mismatch is a refusal, and a refusal does not mutate.
|
|
270
|
+
const missing = [];
|
|
271
|
+
const moved = [];
|
|
272
|
+
for (const row of rows) {
|
|
273
|
+
const src = path.join(root, row.source);
|
|
274
|
+
let bytes;
|
|
275
|
+
try {
|
|
276
|
+
if (!fs.statSync(src).isFile()) { missing.push(row.what + ' ← ' + row.source); continue; }
|
|
277
|
+
bytes = fs.readFileSync(src);
|
|
278
|
+
} catch { missing.push(row.what + ' ← ' + row.source); continue; }
|
|
279
|
+
const live = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
280
|
+
if (live.toLowerCase() !== row.hash.toLowerCase()) {
|
|
281
|
+
moved.push(row.what + ' (' + KIND[row.kind] + ') ← ' + row.source
|
|
282
|
+
+ ': объявлено ' + row.hash.toLowerCase().slice(0, 12) + '…, на диске ' + live.slice(0, 12) + '…');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (missing.length) {
|
|
286
|
+
proven('источник назван, но его нет на диске', missing,
|
|
287
|
+
'ссылка указывает в пустоту, а правка при этом объявлена построенной на нём.');
|
|
288
|
+
}
|
|
289
|
+
if (moved.length) {
|
|
290
|
+
proven('источник изменился после чтения — правка ОТКЛОНЕНА, мутации нет', moved,
|
|
291
|
+
'прочитанная копия — снимок МОМЕНТА ЧТЕНИЯ, а не состояния файла: между чтением и записью '
|
|
292
|
+
+ 'источник переписали, и правка по литеральной строке попала бы либо мимо, либо в текст, '
|
|
293
|
+
+ 'который теперь значит другое. Симметрично и тише: свежий отчёт может добросовестно '
|
|
294
|
+
+ 'излагать вывод из устаревшего источника — квитанция при этом свежа, и её проверка '
|
|
295
|
+
+ 'отвечает не на тот вопрос. Перечитайте источник, перезапишите хеш и постройте правку '
|
|
296
|
+
+ 'заново — не применяйте её к новому тексту.');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const edits = rows.filter((r) => KIND[r.kind] === 'edit').length;
|
|
300
|
+
say('✅ ' + rows.length + ' объявлени(й) сверено с диском: ' + edits + ' правк(и), '
|
|
301
|
+
+ (rows.length - edits) + ' вывод(ов) — каждый источник на месте и не изменился с момента чтения');
|
|
302
|
+
say(' Ограничение: совпавший хеш доказывает, что почва НЕ СДВИНУЛАСЬ, — но не то, что вывод из '
|
|
303
|
+
+ 'неё следует и не то, что кто-то действительно перечитал файл. Это остаётся слоем 3.');
|
|
304
|
+
process.exit(0);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
main();
|
|
309
|
+
} catch (err) {
|
|
310
|
+
// Even an unexpected failure must not read as "clean".
|
|
311
|
+
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
|
|
312
|
+
}
|