@dzhechkov/p-replicator 1.10.4 → 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 +312 -76
- package/CHANGELOG.md +232 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +3 -3
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/04_api_reference.md +11 -5
- package/README/eng/05_architecture.md +1 -1
- package/README/eng/README.md +2 -1
- package/README/ru/01_quickstart.md +3 -3
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/04_api_reference.md +11 -5
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +9 -9
- package/README.md +278 -39
- package/package.json +5 -4
- package/sbom.json +665 -75
- package/scripts/check-pipeline-gaps.sh +413 -0
- package/src/commands/doctor.js +94 -4
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +15 -0
- package/src/utils.js +35 -11
- package/templates/.claude/agents/harvest-coordinator.md +10 -1
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +81 -9
- package/templates/.claude/commands/go.md +9 -0
- package/templates/.claude/commands/harvest.md +39 -3
- package/templates/.claude/commands/myinsights.md +21 -26
- package/templates/.claude/commands/replicate.md +171 -37
- package/templates/.claude/commands/start.md +29 -0
- 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 +434 -24
- 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/session-insights.cjs +158 -25
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/hooks/write-insight.cjs +253 -0
- package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
- package/templates/.claude/rules/docker-ports.md +41 -19
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +13 -3
- package/templates/.claude/rules/honest-configuration.md +54 -0
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/insights-capture.md +10 -5
- 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 +123 -52
- package/templates/.claude/rules/skill-interface-protocol.md +1 -0
- package/templates/.claude/rules/swarm-file-evidence.md +46 -0
- package/templates/.claude/settings.json +13 -1
- 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/knowledge-extractor/modules/01-agent-review.md +16 -5
- 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 +234 -716
- package/tests/e2e/lifecycle.test.js +55 -9
- package/tests/e2e/packed-insights-writer.test.js +308 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
- package/tests/snapshot/baseline.json +72 -46
- package/tests/snapshot/templates.test.js +47 -0
- package/tests/unit/absence-is-not-emptiness.test.js +15 -1
- 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-pipeline-gaps.test.js +94 -0
- package/tests/unit/check-ports.test.js +773 -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 +43 -6
- package/tests/unit/detection-ladder-contract.test.js +302 -0
- package/tests/unit/detection-ladder-registry.test.js +52 -0
- package/tests/unit/doctor-insight-flow.test.js +315 -0
- package/tests/unit/external-dependency-check.test.js +19 -19
- 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 +574 -0
- package/tests/unit/hooks-project-anchored.test.js +67 -3
- package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
- package/tests/unit/insights-dz-delegation.test.js +197 -0
- package/tests/unit/insights-writer.test.js +285 -0
- 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/shipped-suite-context.test.js +3 -1
- package/tests/unit/traceability-machine-ids.test.js +413 -0
- package/tests/unit/traceability-negative-fixture.test.js +322 -0
- package/tests/unit/utils.test.js +40 -2
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* check-look-origin.cjs — did a third-party design-analysis row reach Specification.md before a
|
|
6
|
+
* dated live run confirmed it?
|
|
7
|
+
*
|
|
8
|
+
* NOT an event hook. Invoke deliberately:
|
|
9
|
+
*
|
|
10
|
+
* node .claude/hooks/check-look-origin.cjs [path-to-project]
|
|
11
|
+
*
|
|
12
|
+
* Exit codes:
|
|
13
|
+
* 0 provenance grammar is valid and no ГИПОТЕЗА/УСТАРЕЛО row was promoted
|
|
14
|
+
* 1 a defect is proved: illegal closed-list value, incomplete third-party provenance, invalid
|
|
15
|
+
* confirmation dates, or promotion before confirmation; affected FR-LOOK-nnn rows are named
|
|
16
|
+
* 2 THE CHECK DID NOT RUN: required input is absent/unreadable, the table cannot be parsed, or
|
|
17
|
+
* an internal error occurred
|
|
18
|
+
*
|
|
19
|
+
* Closed lists:
|
|
20
|
+
* Происхождение: прокликано | сторонний-разбор | вручную | не снято
|
|
21
|
+
* Статус строки: ЧЕРНОВИК | ГИПОТЕЗА | ПОДТВЕРЖДЕНО | УСТАРЕЛО
|
|
22
|
+
* Confirmation: подтверждено: живой прогон YYYY-MM-DD · сторонний снимок YYYY-MM-DD
|
|
23
|
+
*
|
|
24
|
+
* Unlike check-look-trace, an illegal closed-list value is exit 1, not 2. Here the value IS the
|
|
25
|
+
* assertion being checked; treating a misspelling as "not checked" would hide a proved bad claim.
|
|
26
|
+
* A proved defect outranks an unanswered check. Dates are compared at day granularity; equality is
|
|
27
|
+
* allowed, while absent, unparseable or future dates degrade to "no date" and are refused.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
|
|
33
|
+
const PROFILE = path.join('docs', 'source-product-profile.md');
|
|
34
|
+
const SPEC = path.join('docs', 'Specification.md');
|
|
35
|
+
const ORIGINS = ['прокликано', 'сторонний-разбор', 'вручную', 'не снято'];
|
|
36
|
+
const ROW_STATUSES = ['ЧЕРНОВИК', 'ГИПОТЕЗА', 'ПОДТВЕРЖДЕНО', 'УСТАРЕЛО'];
|
|
37
|
+
const BLOCKED_STATUSES = new Set(['ГИПОТЕЗА', 'УСТАРЕЛО']);
|
|
38
|
+
const ID = /\bFR-LOOK-(\d{3})\b/g;
|
|
39
|
+
const REJECT_WORD = /(отклон\w*|не берём|не беремся|не берем|rejected|declined|out of scope|вне области)/i;
|
|
40
|
+
const CONFIRMATION = /подтверждено:\s*живой прогон\s+(\S+)\s*·\s*сторонний снимок\s+(\S+)/i;
|
|
41
|
+
|
|
42
|
+
function say(s) { process.stdout.write(s + '\n'); }
|
|
43
|
+
|
|
44
|
+
function cannotCheck(reason, hint) {
|
|
45
|
+
say('⚠️ проверка НЕ выполнена: ' + reason);
|
|
46
|
+
if (hint) say(' ' + hint);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readRequired(root, rel, absentReason, hint) {
|
|
51
|
+
const abs = path.join(root, rel);
|
|
52
|
+
let st;
|
|
53
|
+
try { st = fs.statSync(abs); } catch { cannotCheck(absentReason, hint); }
|
|
54
|
+
if (!st.isFile()) cannotCheck(rel + ' существует, но это не файл');
|
|
55
|
+
try { return fs.readFileSync(abs, 'utf8'); } catch (err) {
|
|
56
|
+
cannotCheck('не читается ' + rel + ': ' + String((err && err.message) || err));
|
|
57
|
+
}
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function header(text, label) {
|
|
62
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
63
|
+
const re = new RegExp('^\\s*\\*\\*' + escaped + ':?\\*\\*\\s*:?(.*)$', 'im');
|
|
64
|
+
const m = re.exec(text);
|
|
65
|
+
return m ? m[1].trim().replace(/^[«"`]|[»"`]$/g, '').trim() : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function seedRows(profile) {
|
|
69
|
+
const rows = [];
|
|
70
|
+
let hasHeader = false;
|
|
71
|
+
for (const raw of profile.split('\n')) {
|
|
72
|
+
const line = raw.trim();
|
|
73
|
+
if (!line.startsWith('|')) continue;
|
|
74
|
+
if (/^\|\s*ID\s*\|/i.test(line) && /\|\s*Статус\s*\|/i.test(line)) hasHeader = true;
|
|
75
|
+
const cells = line.split('|').map((cell) => cell.trim());
|
|
76
|
+
if (!/^FR-LOOK-\d{3}$/.test(cells[1] || '')) continue;
|
|
77
|
+
if (cells.length < 8) cannotCheck('таблица seed не разбирается: у ' + cells[1] + ' нет всех колонок');
|
|
78
|
+
const requirement = cells[2] || '';
|
|
79
|
+
const placeholder = /^\[.*\]$/.test(requirement) || requirement === '...' || requirement === '';
|
|
80
|
+
if (placeholder) continue;
|
|
81
|
+
rows.push({ id: cells[1], status: cells[6] || '', raw: line });
|
|
82
|
+
}
|
|
83
|
+
if (!hasHeader) {
|
|
84
|
+
cannotCheck('таблица seed не разбирается: нет шапки с колонками ID и Статус',
|
|
85
|
+
'ожидается таблица Look Requirements Seed из docs/source-product-profile.md');
|
|
86
|
+
}
|
|
87
|
+
const dupes = [...new Set(rows.map((row) => row.id).filter((id, i, all) => all.indexOf(id) !== i))];
|
|
88
|
+
if (dupes.length) cannotCheck('в таблице seed повторяются идентификаторы: ' + dupes.join(', '));
|
|
89
|
+
return rows;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseDay(raw) {
|
|
93
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw || '')) return null;
|
|
94
|
+
const [year, month, day] = raw.split('-').map(Number);
|
|
95
|
+
const value = Date.UTC(year, month - 1, day);
|
|
96
|
+
const parsed = new Date(value);
|
|
97
|
+
if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1
|
|
98
|
+
|| parsed.getUTCDate() !== day) return null;
|
|
99
|
+
const now = new Date();
|
|
100
|
+
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
101
|
+
return value <= today ? value : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function mentioned(spec) {
|
|
105
|
+
const out = new Set();
|
|
106
|
+
for (const line of spec.split('\n')) {
|
|
107
|
+
if (REJECT_WORD.test(line)) continue;
|
|
108
|
+
ID.lastIndex = 0;
|
|
109
|
+
for (let m = ID.exec(line); m !== null; m = ID.exec(line)) out.add(m[0]);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function rejected(profile, spec, id) {
|
|
115
|
+
const re = new RegExp('^.*\\b' + id + '\\b.*$', 'gm');
|
|
116
|
+
for (const text of [profile, spec]) {
|
|
117
|
+
for (const line of text.match(re) || []) {
|
|
118
|
+
const match = REJECT_WORD.exec(line);
|
|
119
|
+
if (!match) continue;
|
|
120
|
+
const tail = line.slice(match.index + match[0].length);
|
|
121
|
+
const reason = /[\p{L}\p{N}][\p{L}\p{N}\s]{6,}/u
|
|
122
|
+
.test(tail.replace(/^[\s:—–-]+/, ''));
|
|
123
|
+
if (reason) return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function quote(value) { return '«' + (value === '' ? '(пусто)' : value) + '»'; }
|
|
130
|
+
|
|
131
|
+
function refuse(errors) {
|
|
132
|
+
say('❌ проверка происхождения отказала:');
|
|
133
|
+
for (const error of errors) say(' • ' + error);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function main() {
|
|
138
|
+
const root = process.argv[2] || '.';
|
|
139
|
+
try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
|
|
140
|
+
catch { cannotCheck('путь не существует: ' + root); }
|
|
141
|
+
|
|
142
|
+
const profile = readRequired(root, PROFILE, 'нет файла ' + PROFILE,
|
|
143
|
+
'без профиля происхождение строк проверить невозможно');
|
|
144
|
+
const rows = seedRows(profile);
|
|
145
|
+
const errors = [];
|
|
146
|
+
|
|
147
|
+
const origin = header(profile, 'Происхождение');
|
|
148
|
+
const hypothesisRows = rows.filter((row) => BLOCKED_STATUSES.has(row.status));
|
|
149
|
+
const originValid = origin !== null && ORIGINS.includes(origin);
|
|
150
|
+
|
|
151
|
+
if (origin !== null && !originValid) {
|
|
152
|
+
errors.push('Происхождение ' + quote(origin) + ' не из закрытого списка: ' + ORIGINS.join(' | '));
|
|
153
|
+
}
|
|
154
|
+
for (const row of rows) {
|
|
155
|
+
if (!ROW_STATUSES.includes(row.status)) {
|
|
156
|
+
errors.push(row.id + ': Статус ' + quote(row.status) + ' не из закрытого списка: '
|
|
157
|
+
+ ROW_STATUSES.join(' | '));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (origin === null && hypothesisRows.length) {
|
|
162
|
+
errors.push('нет строки `**Происхождение:**` для гипотезных строк: '
|
|
163
|
+
+ hypothesisRows.map((row) => row.id).join(', '));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let snapshotDay = null;
|
|
167
|
+
let snapshotRaw = null;
|
|
168
|
+
if (origin === 'сторонний-разбор') {
|
|
169
|
+
const source = header(profile, 'Источник разбора');
|
|
170
|
+
snapshotRaw = header(profile, 'Дата стороннего снимка');
|
|
171
|
+
if (source === null || source === '') {
|
|
172
|
+
errors.push('при сторонний-разбор обязательна непустая строка `**Источник разбора:**`');
|
|
173
|
+
}
|
|
174
|
+
snapshotDay = parseDay(snapshotRaw);
|
|
175
|
+
if (snapshotRaw === null || snapshotRaw === '') {
|
|
176
|
+
errors.push('при сторонний-разбор обязательна строка `**Дата стороннего снимка:**` YYYY-MM-DD');
|
|
177
|
+
} else if (snapshotDay === null) {
|
|
178
|
+
errors.push('Дата стороннего снимка ' + quote(snapshotRaw)
|
|
179
|
+
+ ' отсутствует, неразбираема или находится в будущем');
|
|
180
|
+
}
|
|
181
|
+
for (const row of rows.filter((candidate) => candidate.status === 'ЧЕРНОВИК')) {
|
|
182
|
+
errors.push(row.id + ': строка стороннего разбора входит только как ГИПОТЕЗА, не ЧЕРНОВИК');
|
|
183
|
+
}
|
|
184
|
+
} else if (originValid && hypothesisRows.length) {
|
|
185
|
+
errors.push('строки ' + hypothesisRows.map((row) => row.id).join(', ')
|
|
186
|
+
+ ' имеют статус ГИПОТЕЗА/УСТАРЕЛО, но Происхождение не равно сторонний-разбор');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const row of rows.filter((candidate) => candidate.status === 'ПОДТВЕРЖДЕНО')) {
|
|
190
|
+
const match = CONFIRMATION.exec(row.raw);
|
|
191
|
+
if (!match) {
|
|
192
|
+
errors.push(row.id + ': ПОДТВЕРЖДЕНО требует запись `подтверждено: живой прогон YYYY-MM-DD '
|
|
193
|
+
+ '· сторонний снимок YYYY-MM-DD`');
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const liveRaw = match[1];
|
|
197
|
+
const confirmedSnapshotRaw = match[2];
|
|
198
|
+
const liveDay = parseDay(liveRaw);
|
|
199
|
+
const confirmedSnapshotDay = parseDay(confirmedSnapshotRaw);
|
|
200
|
+
if (liveDay === null || confirmedSnapshotDay === null) {
|
|
201
|
+
errors.push(row.id + ': даты подтверждения ' + quote(liveRaw) + ' / '
|
|
202
|
+
+ quote(confirmedSnapshotRaw) + ' отсутствуют, неразбираемы или находятся в будущем');
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (liveDay < confirmedSnapshotDay) {
|
|
206
|
+
errors.push(row.id + ': живой прогон ' + liveRaw + ' старше стороннего снимка '
|
|
207
|
+
+ confirmedSnapshotRaw + ' — это не подтверждение');
|
|
208
|
+
}
|
|
209
|
+
if (snapshotDay !== null && confirmedSnapshotDay !== snapshotDay) {
|
|
210
|
+
errors.push(row.id + ': сторонний снимок в подтверждении ' + confirmedSnapshotRaw
|
|
211
|
+
+ ' не совпадает с полем профиля ' + snapshotRaw);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A proved grammar/date defect is older than an input that would be needed only for promotion.
|
|
216
|
+
if (errors.length) refuse(errors);
|
|
217
|
+
|
|
218
|
+
const spec = readRequired(root, SPEC, 'нет файла ' + SPEC,
|
|
219
|
+
'без Specification.md нельзя проверить шов промоушена');
|
|
220
|
+
const seen = mentioned(spec);
|
|
221
|
+
const promoted = hypothesisRows.filter((row) => seen.has(row.id) && !rejected(profile, spec, row.id));
|
|
222
|
+
if (promoted.length) {
|
|
223
|
+
refuse(['без датированного живого подтверждения промотированы строки: '
|
|
224
|
+
+ promoted.map((row) => row.id).join(', ')]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (origin === null) {
|
|
228
|
+
say('✅ legacy-профиль: поля Происхождение нет, гипотезных строк нет — совместимость сохранена');
|
|
229
|
+
} else {
|
|
230
|
+
say('✅ происхождение проверено: непромотированных строк ГИПОТЕЗА/УСТАРЕЛО — '
|
|
231
|
+
+ hypothesisRows.length);
|
|
232
|
+
}
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
main();
|
|
238
|
+
} catch (err) {
|
|
239
|
+
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
|
|
240
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* check-look-trace.cjs — облик исходного продукта сняли и донесли до спецификации, или сняли и
|
|
6
|
+
* выбросили?
|
|
7
|
+
*
|
|
8
|
+
* NOT an event hook. Like `check-ports.cjs`, `check-growth-trace.cjs` and `check-docs-complete.cjs`,
|
|
9
|
+
* it lives here because this directory already carries plain Node utilities; nothing registers it in
|
|
10
|
+
* settings.json. This is deliberate and load-bearing: this package's hooks are NON-BLOCKING by
|
|
11
|
+
* contract (pinned by tests/unit/hooks-project-anchored.test.js, which requires exit 0), so a hook
|
|
12
|
+
* could never refuse anything — it could only print. Invoke it:
|
|
13
|
+
*
|
|
14
|
+
* node .claude/hooks/check-look-trace.cjs [path-to-project]
|
|
15
|
+
*
|
|
16
|
+
* Exit codes — three, and the third is the point:
|
|
17
|
+
* 0 every seed row is traced into docs/Specification.md, or rejected on the record
|
|
18
|
+
* 1 the seed table carries rows and the Specification traces none / some of them
|
|
19
|
+
* 2 THE CHECK DID NOT RUN — no profile, no Specification, a table that would not parse, or a
|
|
20
|
+
* profile that legitimately says the look was NEVER CAPTURED
|
|
21
|
+
*
|
|
22
|
+
* A checker that answers "clean" when it could not look converts an unknown into a reassurance.
|
|
23
|
+
* Three outcomes of Phase 0.5, and only ONE of them is a table to check:
|
|
24
|
+
*
|
|
25
|
+
* ИСТОЧНИКА НЕТ the project replicates nothing — a legitimate answer, and nothing to trace → 2
|
|
26
|
+
* НЕ ИЗМЕРЕНО a source was NAMED but its look could not be captured; the reason comes from a
|
|
27
|
+
* closed list (no-browser-mcp|unreachable|auth-required|out-of-scope) → 2
|
|
28
|
+
* СНЯТ the look was captured → the seed table is checked → 0 / 1
|
|
29
|
+
*
|
|
30
|
+
* The middle state is the one this file exists for. Rule `honest-configuration` CFG-I4: when the
|
|
31
|
+
* source of truth is unreachable the answer is UNKNOWN, never a plausible value — a pipeline that
|
|
32
|
+
* invented a palette for a product it never looked at would be exactly that plausible value.
|
|
33
|
+
*
|
|
34
|
+
* TWO AXES, ONE IDENTIFIER FAMILY. `облик` (what is seen) and `путь` (the order of screens) are a
|
|
35
|
+
* COLUMN of `FR-LOOK-nnn`, never a second namespace. They FAIL APART, though: a landing page can be
|
|
36
|
+
* captured while the click-through dies on a 403, so each axis answers for itself —
|
|
37
|
+
* `**Статус съёмки:**` for `облик`, `**Статус съёмки (путь):**` for `путь`. That is one extra
|
|
38
|
+
* header line, not a second artifact.
|
|
39
|
+
*
|
|
40
|
+
* The `путь` declaration is demanded ONLY when the axis carries no rows: rows ARE the answer, and
|
|
41
|
+
* the empty case is the one where silence and "there are no path obligations" look identical. Since
|
|
42
|
+
* `capture-source-path.cjs` exists, that silence is a gap and not a default.
|
|
43
|
+
*
|
|
44
|
+
* An unanswered `путь` axis can turn a 0 into a 2, never a 1 into anything: a PROVEN loss outranks
|
|
45
|
+
* an unanswered question. A declared «ИСТОЧНИКА НЕТ» on the path axis does NOT block a clean bill —
|
|
46
|
+
* unlike the whole-profile version of that answer, the trace set here is non-empty, so 0 still
|
|
47
|
+
* means something. That asymmetry is deliberate: an honest answer must stay reachable, or the
|
|
48
|
+
* check degenerates into a permanent 2 nobody reads.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const fs = require('node:fs');
|
|
52
|
+
const path = require('node:path');
|
|
53
|
+
|
|
54
|
+
const PROFILE = path.join('docs', 'source-product-profile.md');
|
|
55
|
+
const SPEC = path.join('docs', 'Specification.md');
|
|
56
|
+
|
|
57
|
+
/** The exact token, case-sensitive. ONE family of identifiers; the axis is a COLUMN, never a second
|
|
58
|
+
* namespace, so there is no `FR-FLOW-nnn` to keep in step with this one. */
|
|
59
|
+
const ID = /\bFR-LOOK-(\d{3})\b/g;
|
|
60
|
+
|
|
61
|
+
/** The capture status, as a CLOSED set owned by this code. An unmapped spelling is refused and the
|
|
62
|
+
* recognised ones are printed — never silently read as one of them. */
|
|
63
|
+
const STATUS = {
|
|
64
|
+
'СНЯТ': 'captured',
|
|
65
|
+
'НЕ ИЗМЕРЕНО': 'not-measured',
|
|
66
|
+
'ИСТОЧНИКА НЕТ': 'no-source',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Why a NAMED source could not be captured. Closed list — a free-text reason is not a reason here,
|
|
71
|
+
* because the point of the list is that each entry names a DIFFERENT fix.
|
|
72
|
+
*
|
|
73
|
+
* The last four arrived with the browser instrument for the `путь` axis
|
|
74
|
+
* (`capture-source-path.cjs`), which owns the same list; they are the ways a CLICK-THROUGH fails
|
|
75
|
+
* and each has its own repair: install a browser · narrow the scope or stop · nothing to do, the
|
|
76
|
+
* site refuses · raise the budget. `no-browser-mcp` (the clone-website prerequisite) and
|
|
77
|
+
* `no-browser` (a local Playwright) are DIFFERENT missing tools and stay separate entries.
|
|
78
|
+
*/
|
|
79
|
+
const REASONS = ['no-browser-mcp', 'unreachable', 'auth-required', 'out-of-scope',
|
|
80
|
+
'no-browser', 'bot-protected', 'timeout', 'robots-disallowed'];
|
|
81
|
+
|
|
82
|
+
/** The axis a row belongs to. Closed, and checked, because "one identifier family, axis as a column"
|
|
83
|
+
* is only true while the column actually carries a value from the family. */
|
|
84
|
+
const AXES = ['облик', 'путь'];
|
|
85
|
+
|
|
86
|
+
/** A line that refuses an obligation. Shared by mentioned() and rejected() so the two rules cannot
|
|
87
|
+
* disagree about what a refusal looks like. */
|
|
88
|
+
const REJECT_WORD = /(отклон\w*|не берём|не беремся|не берем|rejected|declined|out of scope|вне области)/i;
|
|
89
|
+
|
|
90
|
+
function say(s) { process.stdout.write(s + '\n'); }
|
|
91
|
+
|
|
92
|
+
/** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
|
|
93
|
+
function cannotCheck(reason, hint) {
|
|
94
|
+
say('⚠️ проверка НЕ выполнена: ' + reason);
|
|
95
|
+
if (hint) say(' ' + hint);
|
|
96
|
+
process.exit(2);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read one required file. Asks about the EXACT path — never lists a directory and matches names
|
|
101
|
+
* against the listing, because a listing answers a different question than "does this file exist"
|
|
102
|
+
* and the two diverge on case, symlinks and unicode normalisation.
|
|
103
|
+
*/
|
|
104
|
+
function readRequired(root, rel, absentReason, hint) {
|
|
105
|
+
const abs = path.join(root, rel);
|
|
106
|
+
let st;
|
|
107
|
+
try { st = fs.statSync(abs); } catch { cannotCheck(absentReason, hint); }
|
|
108
|
+
if (!st.isFile()) cannotCheck(rel + ' существует, но это не файл');
|
|
109
|
+
try { return fs.readFileSync(abs, 'utf-8'); } catch (e) {
|
|
110
|
+
cannotCheck('не читается ' + rel + ': ' + ((e && e.message) || e));
|
|
111
|
+
}
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The value of a `**Label:** value` header line, or null when the label is absent entirely.
|
|
116
|
+
* An EMPTY value is returned as '' and is never collapsed into "absent" — CFG-I2. */
|
|
117
|
+
function header(text, label) {
|
|
118
|
+
const re = new RegExp('^\\s*\\*\\*' + label + ':?\\*\\*\\s*:?(.*)$', 'im');
|
|
119
|
+
const m = re.exec(text);
|
|
120
|
+
return m ? m[1].trim().replace(/^[«"`]|[»"`]$/g, '').trim() : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The three outcomes, decided BEFORE any table is parsed.
|
|
125
|
+
*
|
|
126
|
+
* Order matters: a project with no source legitimately has no rows, and reaching the table first
|
|
127
|
+
* would report "таблица пуста" — blaming the project for an answer it gave correctly.
|
|
128
|
+
*/
|
|
129
|
+
function captureState(profile) {
|
|
130
|
+
const raw = header(profile, 'Статус съёмки');
|
|
131
|
+
if (raw === null) {
|
|
132
|
+
cannotCheck('в профиле нет строки `**Статус съёмки:**`',
|
|
133
|
+
'без неё нельзя отличить «источника нет» от «источник назван, но не снят» — '
|
|
134
|
+
+ 'допустимые значения: ' + Object.keys(STATUS).join(' | '));
|
|
135
|
+
}
|
|
136
|
+
const key = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
137
|
+
if (!Object.prototype.hasOwnProperty.call(STATUS, key)) {
|
|
138
|
+
cannotCheck('нераспознанный статус съёмки: ' + (key === '' ? '(пусто)' : key),
|
|
139
|
+
'допустимы ровно: ' + Object.keys(STATUS).join(' | '));
|
|
140
|
+
}
|
|
141
|
+
const state = STATUS[key];
|
|
142
|
+
|
|
143
|
+
if (state === 'no-source') {
|
|
144
|
+
cannotCheck('профиль говорит ИСТОЧНИКА НЕТ — проект ничего не воспроизводит',
|
|
145
|
+
'это законный ответ Фазы 0.5, а не нарушение; прослеживать нечего, поэтому не 0 и не 1');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (state === 'not-measured') {
|
|
149
|
+
const reason = pickReason(profile, 'Причина', 'облик');
|
|
150
|
+
cannotCheck('облик источника НЕ ИЗМЕРЕН, причина: ' + reason,
|
|
151
|
+
'источник назван, но не снят — это честное «неизвестно», а не «требований по облику нет»; '
|
|
152
|
+
+ 'отраслевая палитра в этом состоянии остаётся фолбэком, и он помечается как фолбэк');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return state; // 'captured'
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The reason a NAMED source went uncaptured, from the CLOSED list.
|
|
160
|
+
*
|
|
161
|
+
* Shared by both axes on purpose: two copies of this rule would drift, and then one axis would
|
|
162
|
+
* accept a spelling the other refuses. Free text is not a reason — the whole value of the list is
|
|
163
|
+
* that each entry names a different repair, so "было некогда" leaves the reader with nothing to do.
|
|
164
|
+
*/
|
|
165
|
+
function pickReason(profile, label, axisLabel) {
|
|
166
|
+
const reason = header(profile, label);
|
|
167
|
+
if (reason === null || reason === '') {
|
|
168
|
+
cannotCheck('статус НЕ ИЗМЕРЕНО (' + axisLabel + ') без строки `**' + label.replace(/\\/g, '') + ':**`',
|
|
169
|
+
'источник назван, но не снят — причина обязательна и берётся из закрытого списка: '
|
|
170
|
+
+ REASONS.join(' | '));
|
|
171
|
+
}
|
|
172
|
+
const picked = REASONS.filter((r) => new RegExp('(^|[^a-z-])' + r + '([^a-z-]|$)', 'i').test(reason));
|
|
173
|
+
if (picked.length !== 1) {
|
|
174
|
+
cannotCheck('причина «' + reason + '» (' + axisLabel + ') не из закрытого списка '
|
|
175
|
+
+ '(или названо сразу несколько)',
|
|
176
|
+
'допустимы ровно: ' + REASONS.join(' | ') + ' — каждая означает СВОЙ ремонт, '
|
|
177
|
+
+ 'поэтому свободный текст здесь не причина');
|
|
178
|
+
}
|
|
179
|
+
return picked[0];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The `путь` axis, answered SEPARATELY from `облик`.
|
|
184
|
+
*
|
|
185
|
+
* Why a second status line and not a second artifact: the axes fail apart. A landing page can be
|
|
186
|
+
* captured while the click-through dies on a 403, and one shared status would have to lie about one
|
|
187
|
+
* of them. The identifier family stays ONE — this adds a header line, not a namespace.
|
|
188
|
+
*
|
|
189
|
+
* Rows are the evidence: any `путь` row means the axis was captured and nothing more is asked. The
|
|
190
|
+
* declaration is required ONLY when the axis is EMPTY, because that is the state where silence and
|
|
191
|
+
* "there are no path obligations" look identical — and since the instrument exists
|
|
192
|
+
* (`capture-source-path.cjs`), silence is a gap, not a default.
|
|
193
|
+
*/
|
|
194
|
+
function pathAxisState(profile, rows) {
|
|
195
|
+
if (rows.some((r) => r.axis === 'путь')) return { state: 'captured' };
|
|
196
|
+
|
|
197
|
+
const raw = header(profile, 'Статус съёмки \\(путь\\)');
|
|
198
|
+
if (raw === null) {
|
|
199
|
+
cannotCheck('ось «путь» пуста и не объявлена',
|
|
200
|
+
'молчание об оси «путь» неотличимо от «обязательств по пути нет», а инструмент для неё есть: '
|
|
201
|
+
+ '`node .claude/hooks/capture-source-path.cjs <url>`. Объявите исход строкой '
|
|
202
|
+
+ '`**Статус съёмки (путь):**` — ' + Object.keys(STATUS).join(' | '));
|
|
203
|
+
}
|
|
204
|
+
const key = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
205
|
+
if (!Object.prototype.hasOwnProperty.call(STATUS, key)) {
|
|
206
|
+
cannotCheck('нераспознанный статус съёмки (путь): ' + (key === '' ? '(пусто)' : key),
|
|
207
|
+
'допустимы ровно: ' + Object.keys(STATUS).join(' | '));
|
|
208
|
+
}
|
|
209
|
+
const state = STATUS[key];
|
|
210
|
+
if (state === 'captured') {
|
|
211
|
+
cannotCheck('ось «путь» объявлена СНЯТ, но в таблице нет ни одной строки с осью «путь»',
|
|
212
|
+
'снятый путь, который никто не записал обязательством, ничем не отличается от неснятого');
|
|
213
|
+
}
|
|
214
|
+
if (state === 'not-measured') {
|
|
215
|
+
return { state, reason: pickReason(profile, 'Причина \\(путь\\)', 'путь') };
|
|
216
|
+
}
|
|
217
|
+
return { state }; // 'no-source' — у источника нет пути (одноэкранный продукт)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The seed rows, as the profile records them.
|
|
222
|
+
*
|
|
223
|
+
* A row is a markdown table row whose FIRST cell is an id. The template ships an example row with a
|
|
224
|
+
* placeholder id, so a row whose requirement cell is still a bracketed placeholder is a TEMPLATE
|
|
225
|
+
* row, not a real obligation, and counting it would let an untouched template look like a
|
|
226
|
+
* filled-in one.
|
|
227
|
+
*/
|
|
228
|
+
function seedRows(profile) {
|
|
229
|
+
const rows = [];
|
|
230
|
+
for (const raw of profile.split('\n')) {
|
|
231
|
+
const line = raw.trim();
|
|
232
|
+
if (!line.startsWith('|')) continue;
|
|
233
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
234
|
+
// cells[0] is '' for a leading pipe; the id lives in cells[1]
|
|
235
|
+
if (!/^FR-LOOK-\d{3}$/.test(cells[1] || '')) continue;
|
|
236
|
+
const requirement = cells[2] || '';
|
|
237
|
+
const isPlaceholder = /^\[.*\]$/.test(requirement) || requirement === '...' || requirement === '';
|
|
238
|
+
if (isPlaceholder) continue;
|
|
239
|
+
rows.push({
|
|
240
|
+
id: cells[1],
|
|
241
|
+
axis: (cells[3] || '').toLowerCase(),
|
|
242
|
+
status: cells[6] || '',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return rows;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Ids the Specification mentions, by the same definition check-growth-trace uses.
|
|
250
|
+
*
|
|
251
|
+
* A REJECTION LINE IS NOT A MENTION. The two rules overlap on exactly the case that matters: a line
|
|
252
|
+
* reading `FR-LOOK-001 отклонено` contains the exact token, so a naive mention rule reports the
|
|
253
|
+
* obligation as carried forward — and the reason requirement on the rejection path is never reached.
|
|
254
|
+
*/
|
|
255
|
+
function mentioned(spec) {
|
|
256
|
+
const out = new Set();
|
|
257
|
+
for (const line of spec.split('\n')) {
|
|
258
|
+
if (REJECT_WORD.test(line)) continue; // a refusal is decided by rejected(), which wants a reason
|
|
259
|
+
ID.lastIndex = 0;
|
|
260
|
+
for (let m = ID.exec(line); m !== null; m = ID.exec(line)) out.add(m[0]);
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A row may also be REJECTED on the record instead of traced. A rejection is a line naming the id
|
|
267
|
+
* together with a rejection word AND a reason marker, because "FR-LOOK-004 не берём" with nothing
|
|
268
|
+
* after it is indistinguishable from forgetting.
|
|
269
|
+
*/
|
|
270
|
+
function rejected(profile, spec, id) {
|
|
271
|
+
const re = new RegExp('^.*\\b' + id + '\\b.*$', 'gm');
|
|
272
|
+
for (const hay of [profile, spec]) {
|
|
273
|
+
for (const line of hay.match(re) || []) {
|
|
274
|
+
const m = REJECT_WORD.exec(line);
|
|
275
|
+
if (!m) continue;
|
|
276
|
+
// The reason must live AFTER the rejection word, and it must be WORDS: the IDENTIFIER itself
|
|
277
|
+
// carries two hyphens, so a pattern that accepts punctuation would read the id as its own
|
|
278
|
+
// justification.
|
|
279
|
+
const tail = line.slice(m.index + m[0].length);
|
|
280
|
+
const hasReason = /[\p{L}\p{N}][\p{L}\p{N}\s]{6,}/u.test(tail.replace(/^[\s:—–-]+/, ''));
|
|
281
|
+
if (hasReason) return true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function main() {
|
|
288
|
+
const root = process.argv[2] || '.';
|
|
289
|
+
try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
|
|
290
|
+
catch { cannotCheck('путь не существует: ' + root); }
|
|
291
|
+
|
|
292
|
+
const profile = readRequired(root, PROFILE,
|
|
293
|
+
'нет файла ' + PROFILE,
|
|
294
|
+
'это значит, что Фаза 0.5 не запускалась — а НЕ что облик воспроизводить не надо; '
|
|
295
|
+
+ 'Фаза 0.5 обязательна и на входе --from-docs, который пропускает только Фазу 0');
|
|
296
|
+
|
|
297
|
+
captureState(profile); // exits 2 on «источника нет» and on «НЕ ИЗМЕРЕНО»
|
|
298
|
+
|
|
299
|
+
const rows = seedRows(profile);
|
|
300
|
+
|
|
301
|
+
// A REUSED id makes the profile malformed, and malformed is exit 2 — never a pass. When a number
|
|
302
|
+
// is reused, two distinct obligations share one token and a SINGLE mention in the Specification
|
|
303
|
+
// marks BOTH traced: coverage counted over usable ITEMS instead of per POSITION.
|
|
304
|
+
const dupes = [...new Set(rows.map((r) => r.id).filter((id, i, a) => a.indexOf(id) !== i))];
|
|
305
|
+
if (dupes.length) {
|
|
306
|
+
cannotCheck('в таблице-семени повторяются идентификаторы: ' + dupes.join(', '),
|
|
307
|
+
'номер FR-LOOK-nnn не переиспользуется — пока дубли не разведены, одно упоминание в '
|
|
308
|
+
+ 'Specification.md зачло бы сразу два разных обязательства');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// The axis is a COLUMN of the one identifier family. A row that does not name its axis makes that
|
|
312
|
+
// claim false, and the honest answer is «не проверено», not «прослежено».
|
|
313
|
+
const noAxis = rows.filter((r) => !AXES.includes(r.axis));
|
|
314
|
+
if (noAxis.length) {
|
|
315
|
+
cannotCheck('строки без оси из закрытого списка: ' + noAxis.map((r) => r.id).join(', '),
|
|
316
|
+
'колонка «Ось» обязана быть одним из: ' + AXES.join(' | ') + ' — ось это КОЛОНКА одного '
|
|
317
|
+
+ 'семейства идентификаторов, а не второе пространство имён');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!rows.length) {
|
|
321
|
+
// Status says СНЯТ, yet nothing was written down. That is not a clean bill and not a loss
|
|
322
|
+
// either — the table was never filled, so there is nothing to trace.
|
|
323
|
+
cannotCheck('статус СНЯТ, но в профиле нет ни одной заполненной строки FR-LOOK-nnn',
|
|
324
|
+
'скобочные заглушки шаблона не считаются обязательствами — снятый облик, который никто не '
|
|
325
|
+
+ 'записал требованием, ничем не отличается от неснятого');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const hypothesisRows = rows.filter((r) => r.status === 'ГИПОТЕЗА' || r.status === 'УСТАРЕЛО');
|
|
329
|
+
const promotableRows = rows.filter((r) => r.status !== 'ГИПОТЕЗА' && r.status !== 'УСТАРЕЛО');
|
|
330
|
+
if (hypothesisRows.length) {
|
|
331
|
+
say('ℹ строк-гипотез вне промоушена: ' + hypothesisRows.length
|
|
332
|
+
+ ' — их проверяет check-look-origin.cjs');
|
|
333
|
+
}
|
|
334
|
+
if (!promotableRows.length) {
|
|
335
|
+
cannotCheck('все строки — гипотезы, промоушен ещё не имел права случиться',
|
|
336
|
+
'это проверяет check-look-origin.cjs; ноль промотируемых строк не является чистой квитанцией');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// The `путь` axis is answered BEFORE the tracing verdict but does not decide it: a proven loss
|
|
340
|
+
// outranks an unanswered axis, so this only records what the axis said.
|
|
341
|
+
const pathAxis = pathAxisState(profile, promotableRows);
|
|
342
|
+
|
|
343
|
+
const spec = readRequired(root, SPEC, 'нет файла ' + SPEC,
|
|
344
|
+
'без спецификации не с чем сверять — это не «всё прослежено»');
|
|
345
|
+
|
|
346
|
+
const seen = mentioned(spec);
|
|
347
|
+
const missing = promotableRows.filter((r) => !seen.has(r.id) && !rejected(profile, spec, r.id));
|
|
348
|
+
|
|
349
|
+
if (missing.length === promotableRows.length) {
|
|
350
|
+
say('❌ ни одно обязательство по облику не доехало до ' + SPEC + ':');
|
|
351
|
+
for (const r of missing) say(' • ' + r.id + ' (' + r.axis + ')');
|
|
352
|
+
say(' Облик источника сняли и выбросили — это ровно тот класс потерь, который ловит проверка.');
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
if (missing.length) {
|
|
356
|
+
say('❌ часть обязательств по облику потеряна (' + missing.length + ' из '
|
|
357
|
+
+ promotableRows.length + '):');
|
|
358
|
+
for (const r of missing) say(' • ' + r.id + ' (' + r.axis + ')');
|
|
359
|
+
say(' Каждое надо либо перенести в ' + SPEC + ', либо отклонить С ПРИЧИНОЙ — молча уронить нельзя.');
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
// Every row is traced. Now, and only now, the unanswered `путь` axis decides between 0 and 2:
|
|
363
|
+
// «прослежено всё, что записали» over a profile that never looked at the path would report a
|
|
364
|
+
// complete check that was half a check.
|
|
365
|
+
if (pathAxis.state === 'not-measured') {
|
|
366
|
+
cannotCheck('строки прослежены, но ось «путь» НЕ ИЗМЕРЕНА, причина: ' + pathAxis.reason,
|
|
367
|
+
'проверена только ось «облик» — это половина проверки, а не чистая квитанция; '
|
|
368
|
+
+ 'инструмент оси: `node .claude/hooks/capture-source-path.cjs <url>`');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
say('✅ все ' + promotableRows.length + ' обязательств по облику прослежены в ' + SPEC
|
|
372
|
+
+ ' либо отклонены с причиной');
|
|
373
|
+
if (pathAxis.state === 'no-source') {
|
|
374
|
+
say(' Ось «путь»: ИСТОЧНИКА НЕТ — у источника нет пути (одноэкранный продукт), объявлено явно.');
|
|
375
|
+
}
|
|
376
|
+
say(' Ограничение: это доказывает, что обязательство ДОНЕСЛИ, а не что интерфейс на него похож.');
|
|
377
|
+
process.exit(0);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
main();
|
|
382
|
+
} catch (err) {
|
|
383
|
+
// Even an unexpected failure must not read as "clean".
|
|
384
|
+
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
|
|
385
|
+
}
|