@dzhechkov/p-replicator 1.12.0 → 1.13.1
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 +225 -61
- 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 +132 -9
- package/bin/cli.js +0 -0
- package/package.json +10 -11
- package/sbom.json +470 -60
- package/scripts/check-pipeline-gaps.sh +0 -0
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +5 -1
- package/src/utils.js +32 -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 +29 -5
- 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-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 +121 -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/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +173 -725
- package/tests/snapshot/baseline.json +60 -38
- 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-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 +91 -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/utils.test.js +40 -3
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* check-file-ownership.cjs — у каждого записываемого файла ровно один писатель, ВКЛЮЧАЯ файлы,
|
|
6
|
+
* которые родились посреди прогона?
|
|
7
|
+
*
|
|
8
|
+
* NOT an event hook. Like its nine siblings in this directory it is a plain Node utility; nothing
|
|
9
|
+
* registers it in settings.json, because this package's hooks are NON-BLOCKING by contract (pinned
|
|
10
|
+
* by tests/unit/hooks-project-anchored.test.js, which requires exit 0) — a hook could only print, it
|
|
11
|
+
* could never refuse. Invoke it:
|
|
12
|
+
*
|
|
13
|
+
* node .claude/hooks/check-file-ownership.cjs [path-to-project]
|
|
14
|
+
*
|
|
15
|
+
* WHAT IS ALREADY CLOSED, AND MUST NOT BE RE-OPENED HERE. The general «two writers, one file» half
|
|
16
|
+
* of this problem is settled twice over in this package: the shared-store principle, and the
|
|
17
|
+
* ownership split BY ORIGIN between `/replicate` and `/start` (architecture-derived files to one,
|
|
18
|
+
* build-derived to the other — see tests/unit/pipeline-file-ownership.test.js). This file adds
|
|
19
|
+
* exactly the half those two do not cover.
|
|
20
|
+
*
|
|
21
|
+
* THE UNCOVERED HALF: THE LIFECYCLE OF OWNERSHIP — what happens to ownership when a file APPEARS
|
|
22
|
+
* during the run.
|
|
23
|
+
*
|
|
24
|
+
* A file born by SPLITTING a large file inherits an owner from nobody. Ownership does not travel by
|
|
25
|
+
* itself: the moment `Architecture-OPS.md` is carved out of `Architecture.md`, there is a new
|
|
26
|
+
* document and no single writer for it. The field case is exact and it is quiet: a coordinator wrote
|
|
27
|
+
* a paragraph into the fresh `Architecture-OPS.md` about a permission that the owner of the SOURCE
|
|
28
|
+
* file was revoking in the neighbouring document in the same minutes. Both authors were internally
|
|
29
|
+
* consistent. Both files were individually correct. The two documents contradicted each other, and
|
|
30
|
+
* nothing in the run could observe that, because the contradiction lived between them.
|
|
31
|
+
*
|
|
32
|
+
* The second measured signature of the same failure, this repository 2026-09-01: the two swarms
|
|
33
|
+
* working in their own worktrees produced 23 and 17 merge conflicts, while the swarm working in the
|
|
34
|
+
* MAIN tree produced none at all — not because it was better coordinated, but because its second
|
|
35
|
+
* writer edited a live file with no merge to arbitrate. A silent overwrite leaves no conflict to
|
|
36
|
+
* count. Conflicts are the LOUD form of this defect; the main tree got the quiet form.
|
|
37
|
+
*
|
|
38
|
+
* WHY THE COORDINATOR HAS A MANDATORY ROW. Every dispatched unit knows it is a writer. The
|
|
39
|
+
* coordinator is the one writer nobody dispatched, and in the field case it is precisely the
|
|
40
|
+
* coordinator that wrote. A table that enumerates the units and omits the coordinator is total over
|
|
41
|
+
* everyone except the author of the defect.
|
|
42
|
+
*
|
|
43
|
+
* WHAT THIS FILE CAN AND CANNOT DECIDE — read before trusting exit 0.
|
|
44
|
+
*
|
|
45
|
+
* It reads a DECLARATION, `docs/dispatch-plan.md`, and decides only what a declaration can settle:
|
|
46
|
+
* that the ownership table is TOTAL (every dispatched unit owns something, every owner is somebody
|
|
47
|
+
* who exists) and UNAMBIGUOUS (no file has two owners), that the coordinator answered for itself,
|
|
48
|
+
* and that every file born by a split got an owner AT CREATION and that this owner agrees with the
|
|
49
|
+
* ownership table.
|
|
50
|
+
*
|
|
51
|
+
* It CANNOT decide behaviour, and this is not a gap to be closed later — it is a boundary. A
|
|
52
|
+
* coordinator that edits somebody else's file outside the protocol leaves the plan untouched and
|
|
53
|
+
* this check green. A layer-1 check of the BEHAVIOUR does not exist without write attribution, and
|
|
54
|
+
* saying so out loud is the difference between a bounded guarantee and a false one. The package
|
|
55
|
+
* writes this same caveat in five other rules; it is written here for the same reason.
|
|
56
|
+
*
|
|
57
|
+
* THE EXACT FORM OF `docs/dispatch-plan.md` — kept here on purpose: this file is not part of the
|
|
58
|
+
* always-loaded corpus, so the long form costs nothing per run. It is the SAME artifact the canon
|
|
59
|
+
* check reads; the two answer different questions and exit independently.
|
|
60
|
+
*
|
|
61
|
+
* **Пишущий фан-аут:** да (да | нет — `нет` is a legitimate answer)
|
|
62
|
+
* **Координатор пишет:** да (да | нет — but it must ANSWER)
|
|
63
|
+
* **Разрезы файлов:** да (да | нет — an absent section is not «there were none»)
|
|
64
|
+
* **Проверка владения:** ВЫПОЛНЕНА (ВЫПОЛНЕНА | НЕ ВЫПОЛНЕНА)
|
|
65
|
+
* **Причина:** — (required when НЕ ВЫПОЛНЕНА; one of the closed REASONS)
|
|
66
|
+
*
|
|
67
|
+
* ## Единицы
|
|
68
|
+
*
|
|
69
|
+
* | Единица | Что пишет |
|
|
70
|
+
* |---|---|
|
|
71
|
+
* | packages/api | src/api/**, tests/api/** |
|
|
72
|
+
* | packages/web | src/web/**, tests/web/** |
|
|
73
|
+
*
|
|
74
|
+
* ## Владение
|
|
75
|
+
*
|
|
76
|
+
* | Файл | Владелец |
|
|
77
|
+
* |---|---|
|
|
78
|
+
* | docs/Architecture.md | packages/api |
|
|
79
|
+
* | docs/Architecture-OPS.md | координатор |
|
|
80
|
+
*
|
|
81
|
+
* ## События разреза
|
|
82
|
+
*
|
|
83
|
+
* | Новый файл | Разрезан из | Владелец |
|
|
84
|
+
* |---|---|---|
|
|
85
|
+
* | docs/Architecture-OPS.md | docs/Architecture.md | координатор |
|
|
86
|
+
*
|
|
87
|
+
* Exit codes — three, and the third is the point:
|
|
88
|
+
* 0 ownership is total and unambiguous, the coordinator answered for itself, and every
|
|
89
|
+
* split-born file carries an owner that agrees with the table
|
|
90
|
+
* 1 a defect is PROVEN and named: one file with two owners, an owner nobody dispatched, a
|
|
91
|
+
* dispatched unit that owns nothing, a coordinator that writes and is not in the table (or
|
|
92
|
+
* declares it writes nothing and appears anyway), an empty ownership table under a writing
|
|
93
|
+
* fan-out, a split that produced a file with no owner, a split whose owner contradicts the
|
|
94
|
+
* table, or a declared split with an empty split table
|
|
95
|
+
* 2 THE CHECK DID NOT RUN — no plan, an unrecognised value, a duplicated row, or the legitimate
|
|
96
|
+
* answers «пишущего фан-аута нет» and «проверка НЕ ВЫПОЛНЕНА, причина такая-то»
|
|
97
|
+
*
|
|
98
|
+
* A checker that answers "clean" when it could not look converts an unknown into a reassurance.
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
const fs = require('node:fs');
|
|
102
|
+
const path = require('node:path');
|
|
103
|
+
|
|
104
|
+
const PLAN = path.join('docs', 'dispatch-plan.md');
|
|
105
|
+
|
|
106
|
+
/** Does this dispatch WRITE? A CLOSED set — a read-only fan-out owns nothing and exits 2. */
|
|
107
|
+
const WRITES = { 'ДА': true, 'НЕТ': false };
|
|
108
|
+
|
|
109
|
+
/** Does the coordinator write? CLOSED, and it must ANSWER: the coordinator is the one writer nobody
|
|
110
|
+
* dispatched, and in the measured field case it is the one that wrote. */
|
|
111
|
+
const COORD_WRITES = { 'ДА': true, 'НЕТ': false };
|
|
112
|
+
|
|
113
|
+
/** Were any files split during the run? CLOSED. An absent section would read as «there were none»,
|
|
114
|
+
* which is the same substitution as an absent inventory reading as «no dependencies». */
|
|
115
|
+
const SPLITS = { 'ДА': true, 'НЕТ': false };
|
|
116
|
+
|
|
117
|
+
/** Was the ownership review performed? CLOSED, and the negative answer is honest, not a failure —
|
|
118
|
+
* `honest-configuration` CFG-I4: an unreachable truth yields UNKNOWN, never a plausible value. */
|
|
119
|
+
const RUN_STATUS = { 'ВЫПОЛНЕНА': 'done', 'НЕ ВЫПОЛНЕНА': 'not-done' };
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Why ownership was not settled. CLOSED list — each entry names a DIFFERENT repair:
|
|
123
|
+
* состав-единиц-не-известен — the fan-out is not planned yet; plan it, then assign
|
|
124
|
+
* разрезы-не-перечислены — the splits happened but were not recorded; record them
|
|
125
|
+
* решение-отложено — take the decision (this is the one that becomes a silent overwrite)
|
|
126
|
+
* вне-объёма — decide it is out of scope and record the decision
|
|
127
|
+
*/
|
|
128
|
+
const REASONS = ['состав-единиц-не-известен', 'разрезы-не-перечислены', 'решение-отложено', 'вне-объёма'];
|
|
129
|
+
|
|
130
|
+
/** The one owner that is never a dispatched unit, and the one the field case was about. */
|
|
131
|
+
const COORDINATOR = 'координатор';
|
|
132
|
+
|
|
133
|
+
/** Spellings that mean "nobody" in an owner cell. `—` is the honest empty; the rest are the shapes
|
|
134
|
+
* people write when they mean the same thing, and collapsing them keeps the report about the
|
|
135
|
+
* missing owner rather than about punctuation. */
|
|
136
|
+
const NO_OWNER = /^(—|-|–|n\/a|нет|никто|\[.*\])?$/i;
|
|
137
|
+
|
|
138
|
+
function say(s) { process.stdout.write(s + '\n'); }
|
|
139
|
+
|
|
140
|
+
/** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
|
|
141
|
+
function cannotCheck(reason, hint) {
|
|
142
|
+
say('⚠️ проверка НЕ выполнена: ' + reason);
|
|
143
|
+
if (hint) say(' ' + hint);
|
|
144
|
+
process.exit(2);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Exit 1 with the defect NAMED. A violation that cannot be named is a 2, not a 1. */
|
|
148
|
+
function proven(title, lines, tail) {
|
|
149
|
+
say('❌ ' + title);
|
|
150
|
+
for (const line of lines) say(' • ' + line);
|
|
151
|
+
if (tail) say(' ' + tail);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The value of a `**Label:** value` header line, or null when the label is absent entirely. */
|
|
156
|
+
function header(text, label) {
|
|
157
|
+
const re = new RegExp('^\\s*\\*\\*' + label + ':?\\*\\*\\s*:?(.*)$', 'im');
|
|
158
|
+
const m = re.exec(text);
|
|
159
|
+
if (!m) return null;
|
|
160
|
+
return m[1].trim().replace(/^[«"`]|[»"`]$/g, '').trim();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A header value read against a CLOSED map, with both failure modes kept apart. */
|
|
164
|
+
function closedHeader(text, label, map, what) {
|
|
165
|
+
const raw = header(text, label);
|
|
166
|
+
if (raw === null) {
|
|
167
|
+
cannotCheck('в плане нет строки `**' + label + ':**`',
|
|
168
|
+
what + ' — допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
169
|
+
}
|
|
170
|
+
const key = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
171
|
+
if (!Object.prototype.hasOwnProperty.call(map, key)) {
|
|
172
|
+
cannotCheck('нераспознанное значение `' + label + '`: ' + (key === '' ? '(пусто)' : key),
|
|
173
|
+
'допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
174
|
+
}
|
|
175
|
+
return map[key];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The body of one `## Heading` section — rows are read PER SECTION, never from the whole file.
|
|
180
|
+
*
|
|
181
|
+
* Three tables live in this document and two of them have a first column that looks like the other's.
|
|
182
|
+
* A parser that scanned the file globally would read the units table as ownership rows and report a
|
|
183
|
+
* unit as a file with no owner: an answer to a question nobody asked, delivered as a defect.
|
|
184
|
+
*/
|
|
185
|
+
function section(text, heading) {
|
|
186
|
+
const lines = text.split('\n');
|
|
187
|
+
const start = lines.findIndex((l) => new RegExp('^#{2,6}\\s+' + heading + '\\s*$', 'i').test(l.trim()));
|
|
188
|
+
if (start < 0) return null;
|
|
189
|
+
const level = /^(#+)/.exec(lines[start].trim())[1].length;
|
|
190
|
+
let end = lines.length;
|
|
191
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
192
|
+
const m = /^(#{1,6})\s+\S/.exec(lines[i].trim());
|
|
193
|
+
if (m && m[1].length <= level) { end = i; break; }
|
|
194
|
+
}
|
|
195
|
+
return lines.slice(start + 1, end).join('\n');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Markdown table rows of a section, as arrays of trimmed cells. Header and `|---|` are skipped. */
|
|
199
|
+
function rows(body, headWord) {
|
|
200
|
+
const out = [];
|
|
201
|
+
if (!body) return out;
|
|
202
|
+
for (const raw of body.split('\n')) {
|
|
203
|
+
const line = raw.trim();
|
|
204
|
+
if (!line.startsWith('|')) continue;
|
|
205
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
206
|
+
cells.shift();
|
|
207
|
+
if (cells.length && cells[cells.length - 1] === '') cells.pop();
|
|
208
|
+
if (cells.length < 2) continue;
|
|
209
|
+
if (cells.every((c) => /^:?-+:?$/.test(c))) continue;
|
|
210
|
+
if (headWord && cells[0].toLowerCase() === headWord) continue;
|
|
211
|
+
out.push(cells);
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const norm = (s) => String(s || '').trim().replace(/^[`«"]|[`»"]$/g, '').trim();
|
|
217
|
+
const key = (s) => norm(s).toLowerCase();
|
|
218
|
+
|
|
219
|
+
function main() {
|
|
220
|
+
const root = process.argv[2] || '.';
|
|
221
|
+
try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
|
|
222
|
+
catch { cannotCheck('путь не существует: ' + root); }
|
|
223
|
+
|
|
224
|
+
const abs = path.join(root, PLAN);
|
|
225
|
+
let text;
|
|
226
|
+
try {
|
|
227
|
+
if (!fs.statSync(abs).isFile()) cannotCheck(PLAN + ' существует, но это не файл');
|
|
228
|
+
text = fs.readFileSync(abs, 'utf-8');
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (e && e.code === 'ENOENT') {
|
|
231
|
+
cannotCheck('нет файла ' + PLAN,
|
|
232
|
+
'это значит, что вопрос о владении НЕ ЗАДАВАЛСЯ — а НЕ что писатель один; запуск без '
|
|
233
|
+
+ 'параллельных пишущих единиц отвечает `**Пишущий фан-аут:** нет`, и это законный ответ');
|
|
234
|
+
}
|
|
235
|
+
cannotCheck('не читается ' + PLAN + ': ' + ((e && e.message) || e));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 1. A read-only fan-out owns nothing → 2, never 0.
|
|
239
|
+
const writes = closedHeader(text, 'Пишущий фан-аут', WRITES,
|
|
240
|
+
'без этой строки «фан-аут только читает» неотличимо от «про запись не подумали»');
|
|
241
|
+
if (!writes) {
|
|
242
|
+
cannotCheck('план говорит «Пишущий фан-аут: нет» — параллельные единицы ничего не пишут',
|
|
243
|
+
'владение делится между ПИСАТЕЛЯМИ; у читающих проверяющих делить нечего');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 2. A named refusal is honest and exits 2.
|
|
247
|
+
const run = closedHeader(text, 'Проверка владения', RUN_STATUS,
|
|
248
|
+
'без этой строки «не разбирали владение» неотличимо от «разобрали»');
|
|
249
|
+
if (run === 'not-done') {
|
|
250
|
+
const raw = header(text, 'Причина');
|
|
251
|
+
if (raw === null || raw === '') {
|
|
252
|
+
cannotCheck('проверка владения НЕ ВЫПОЛНЕНА без строки `**Причина:**`',
|
|
253
|
+
'причина обязательна и берётся из закрытого списка: ' + REASONS.join(' | ')
|
|
254
|
+
+ ' — каждая означает СВОЙ ремонт');
|
|
255
|
+
}
|
|
256
|
+
const picked = REASONS.filter((r) => raw.includes(r));
|
|
257
|
+
if (picked.length !== 1) {
|
|
258
|
+
cannotCheck('причина «' + raw + '» не из закрытого списка (или названо сразу несколько)',
|
|
259
|
+
'допустимы ровно: ' + REASONS.join(' | '));
|
|
260
|
+
}
|
|
261
|
+
cannotCheck('проверка владения НЕ ВЫПОЛНЕНА, причина: ' + picked[0],
|
|
262
|
+
'честное «неизвестно», а не «у каждого файла один писатель»; пока причина не закрыта, '
|
|
263
|
+
+ 'второй писатель обнаружится либо конфликтом слияния, либо молча — перезаписью');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 3. Who was dispatched. Their names are the only legal owners besides the coordinator.
|
|
267
|
+
const units = rows(section(text, 'Единицы'), 'единица').map((c) => norm(c[0])).filter(Boolean);
|
|
268
|
+
if (units.length === 0) {
|
|
269
|
+
proven('объявлен пишущий фан-аут, но ни одна единица не названа',
|
|
270
|
+
['раздел `## Единицы` пуст или отсутствует'],
|
|
271
|
+
'владение назначается ПИСАТЕЛЯМ; список писателей — это и есть список законных владельцев.');
|
|
272
|
+
}
|
|
273
|
+
const unitKeys = new Set(units.map(key));
|
|
274
|
+
const dupUnits = [...new Set(units.map(key).filter((n, i, a) => a.indexOf(n) !== i))];
|
|
275
|
+
if (dupUnits.length) {
|
|
276
|
+
cannotCheck('в таблице единиц повторяются строки: ' + dupUnits.join(', '),
|
|
277
|
+
'одна единица — одна строка; иначе владение приписано двум писателям под одним именем');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 4. The ownership table itself.
|
|
281
|
+
const ownRows = rows(section(text, 'Владение'), 'файл');
|
|
282
|
+
if (ownRows.length === 0) {
|
|
283
|
+
proven('объявлен пишущий фан-аут, но таблица владения пуста',
|
|
284
|
+
['раздел `## Владение` пуст или отсутствует'],
|
|
285
|
+
'пустая таблица под «да» — доказанный пропуск, а не неизвестность: у файлов, которые никто '
|
|
286
|
+
+ 'не объявил своими, писатель всё равно найдётся, только назовёт его слияние.');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const owners = new Map(); // file → Set of owners
|
|
290
|
+
for (const cells of ownRows) {
|
|
291
|
+
const file = norm(cells[0]);
|
|
292
|
+
const owner = norm(cells[1]);
|
|
293
|
+
if (!file) continue;
|
|
294
|
+
if (!owners.has(key(file))) owners.set(key(file), { file, set: new Map() });
|
|
295
|
+
owners.get(key(file)).set.set(key(owner), owner);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const ownerless = [...owners.values()].filter((e) => [...e.set.keys()].some((o) => NO_OWNER.test(o)));
|
|
299
|
+
if (ownerless.length) {
|
|
300
|
+
proven('у файла в таблице владения не назван владелец', ownerless.map((e) => e.file),
|
|
301
|
+
'файл без единственного писателя — это не «пока не решили», это решение по умолчанию: '
|
|
302
|
+
+ 'пишет тот, кто дошёл первым, и второй об этом не узнает.');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 4a. THE FIXTURE: one file, two owners. Both authors are internally consistent; only the union
|
|
306
|
+
// of their edits is wrong, and the union is what nobody reads.
|
|
307
|
+
const shared = [...owners.values()].filter((e) => e.set.size > 1);
|
|
308
|
+
if (shared.length) {
|
|
309
|
+
proven('у одного файла два владельца',
|
|
310
|
+
shared.map((e) => e.file + ' → ' + [...e.set.values()].join(', ')),
|
|
311
|
+
'два писателя по одному пути — это либо конфликт слияния, либо, что хуже, ТИХАЯ перезапись: '
|
|
312
|
+
+ 'оба прочитали старое содержимое, оба записали своё, второй выиграл, и оба отчитались об '
|
|
313
|
+
+ 'успехе. Атомарная запись этого не лечит — она лечит рваный файл, а не потерянную правку.');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 4b. An owner nobody dispatched. A file assigned to a writer who is not in the run has, in
|
|
317
|
+
// practice, no writer at all — and reads as if it had one.
|
|
318
|
+
const strangers = [];
|
|
319
|
+
for (const e of owners.values()) {
|
|
320
|
+
for (const owner of e.set.values()) {
|
|
321
|
+
if (key(owner) === COORDINATOR) continue;
|
|
322
|
+
if (!unitKeys.has(key(owner))) strangers.push(e.file + ' → ' + owner);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (strangers.length) {
|
|
326
|
+
proven('владелец не назван среди диспатчируемых единиц', strangers,
|
|
327
|
+
'владельцем может быть либо единица из `## Единицы`, либо `' + COORDINATOR + '`. Имя, '
|
|
328
|
+
+ 'которого нет в диспатче, назначает файл тому, кто в прогоне не участвует, — то есть никому.');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 4c. A dispatched unit that owns nothing writes somewhere anyway. Totality has two directions,
|
|
332
|
+
// and this is the one a table naturally forgets.
|
|
333
|
+
const ownedBy = new Set([...owners.values()].flatMap((e) => [...e.set.keys()]));
|
|
334
|
+
const idle = units.filter((u) => !ownedBy.has(key(u)));
|
|
335
|
+
if (idle.length) {
|
|
336
|
+
proven('единица диспатчирована на запись, но не владеет ни одним файлом', idle,
|
|
337
|
+
'она всё равно что-то запишет — просто путь этой записи не объявлен, и столкнуться он '
|
|
338
|
+
+ 'может с чем угодно. Назовите файлы, за которые она отвечает, либо уберите её из '
|
|
339
|
+
+ 'пишущего фан-аута.');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 5. The coordinator MUST answer for itself. It is the one writer nobody dispatched, and it is
|
|
343
|
+
// the one that wrote in the measured field case.
|
|
344
|
+
const coordWrites = closedHeader(text, 'Координатор пишет', COORD_WRITES,
|
|
345
|
+
'координатор — единственный писатель, которого никто не диспатчировал');
|
|
346
|
+
const coordOwns = [...owners.values()].filter((e) => e.set.has(COORDINATOR)).map((e) => e.file);
|
|
347
|
+
if (coordWrites && coordOwns.length === 0) {
|
|
348
|
+
proven('координатор объявлен пишущим, но не владеет ни одним файлом',
|
|
349
|
+
['`**Координатор пишет:** да`, а строк с владельцем `' + COORDINATOR + '` в таблице нет'],
|
|
350
|
+
'таблица, перечисляющая всех, кроме координатора, тотальна для всех, кроме автора дефекта: '
|
|
351
|
+
+ 'именно координатор в измеренном случае вписал в свежий файл абзац о праве, которое '
|
|
352
|
+
+ 'владелец исходного файла в те же минуты отзывал в соседнем документе.');
|
|
353
|
+
}
|
|
354
|
+
if (!coordWrites && coordOwns.length > 0) {
|
|
355
|
+
proven('координатор объявлен непишущим, но владеет файлами', coordOwns,
|
|
356
|
+
'объявление и таблица противоречат друг другу, и читатель поверит объявлению: «координатор '
|
|
357
|
+
+ 'не пишет» — ровно то допущение, под которым второй писатель остаётся незамеченным.');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 6. THE LOAD-BEARING HALF: ownership at the moment of the SPLIT.
|
|
361
|
+
const hasSplits = closedHeader(text, 'Разрезы файлов', SPLITS,
|
|
362
|
+
'отсутствующий раздел прочитался бы как «разрезов не было»');
|
|
363
|
+
const splitRows = rows(section(text, 'События разреза'), 'новый файл');
|
|
364
|
+
if (hasSplits && splitRows.length === 0) {
|
|
365
|
+
proven('объявлены разрезы файлов, но ни один не назван',
|
|
366
|
+
['раздел `## События разреза` пуст или отсутствует'],
|
|
367
|
+
'файл, родившийся разрезом, не наследует владельца НИ ОТ КОГО — владение не переносится '
|
|
368
|
+
+ 'само. Неперечисленный разрез — это неназначенный владелец, записанный так, будто разреза '
|
|
369
|
+
+ 'не было.');
|
|
370
|
+
}
|
|
371
|
+
if (!hasSplits && splitRows.length > 0) {
|
|
372
|
+
cannotCheck('`Разрезы файлов: нет`, но таблица разрезов не пуста',
|
|
373
|
+
'объявление и таблица противоречат друг другу — какое из двух верно, отсюда не видно');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const bornWithoutOwner = [];
|
|
377
|
+
const disagree = [];
|
|
378
|
+
const unowned = [];
|
|
379
|
+
for (const cells of splitRows) {
|
|
380
|
+
const born = norm(cells[0]);
|
|
381
|
+
const from = norm(cells[1]);
|
|
382
|
+
const owner = norm(cells[2] || '');
|
|
383
|
+
if (!born) continue;
|
|
384
|
+
if (NO_OWNER.test(key(owner))) { bornWithoutOwner.push(born + ' ← ' + (from || '?')); continue; }
|
|
385
|
+
const entry = owners.get(key(born));
|
|
386
|
+
if (!entry) { unowned.push(born + ' ← ' + (from || '?')); continue; }
|
|
387
|
+
if (!entry.set.has(key(owner))) {
|
|
388
|
+
disagree.push(born + ': при разрезе `' + owner + '`, в таблице владения `'
|
|
389
|
+
+ [...entry.set.values()].join(', ') + '`');
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (bornWithoutOwner.length) {
|
|
393
|
+
proven('файл создан разрезом БЕЗ владельца', bornWithoutOwner,
|
|
394
|
+
'владение не переносится само: у нового документа в момент рождения нет ни одного писателя, '
|
|
395
|
+
+ 'и назначить его надо ТОГДА ЖЕ. Создание без владельца — это дефект, а не отложенное '
|
|
396
|
+
+ 'решение; отложенное решение здесь неотличимо от принятого в пользу «пишут все».');
|
|
397
|
+
}
|
|
398
|
+
if (unowned.length) {
|
|
399
|
+
proven('файл создан разрезом и не попал в таблицу владения', unowned,
|
|
400
|
+
'строка разреза назвала владельца, а таблица владения о таком файле не знает: у прогона два '
|
|
401
|
+
+ 'ответа на один вопрос, и вторая половина прогона прочитает второй.');
|
|
402
|
+
}
|
|
403
|
+
if (disagree.length) {
|
|
404
|
+
proven('владелец разреза противоречит таблице владения', disagree,
|
|
405
|
+
'два объявления об одном файле, и они разные. Пока они не сведены, «кто пишет» зависит от '
|
|
406
|
+
+ 'того, какое из них прочитал исполнитель.');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
say('✅ владение тотально и однозначно: ' + owners.size + ' файл(ов), ' + units.length
|
|
410
|
+
+ ' единиц(ы), координатор ' + (coordWrites ? 'пишет и назван' : 'объявлен непишущим'));
|
|
411
|
+
say(' Разрезов: ' + splitRows.length + (splitRows.length
|
|
412
|
+
? ' — у каждого владелец назначен в момент создания и совпадает с таблицей' : ' (объявлено «нет»)'));
|
|
413
|
+
say(' Ограничение: это проверка ДЕКЛАРАЦИИ. Координатор, правящий чужой файл в обход '
|
|
414
|
+
+ 'протокола, оставляет план неизменным и эту проверку зелёной; полной проверки слоя 1 здесь '
|
|
415
|
+
+ 'не существует без атрибуции записи.');
|
|
416
|
+
process.exit(0);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
main();
|
|
421
|
+
} catch (err) {
|
|
422
|
+
// Even an unexpected failure must not read as "clean".
|
|
423
|
+
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
|
|
424
|
+
}
|