@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,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Behaviour tests for the Phase-0.5 provenance gate. The guard answers a different question from
|
|
4
|
+
// check-look-trace: a third-party row may be carried forward only after dated live confirmation.
|
|
5
|
+
|
|
6
|
+
const { test, describe } = require('node:test');
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
const { spawnSync } = require('node:child_process');
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
|
|
13
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
14
|
+
const TPL = path.join(PKG, 'templates', '.claude');
|
|
15
|
+
const CHECK = path.join(TPL, 'hooks', 'check-look-origin.cjs');
|
|
16
|
+
const PROFILE = 'docs/source-product-profile.md';
|
|
17
|
+
const SPEC = 'docs/Specification.md';
|
|
18
|
+
|
|
19
|
+
function check(files) {
|
|
20
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-look-origin-')));
|
|
21
|
+
try {
|
|
22
|
+
for (const [rel, body] of Object.entries(files || {})) {
|
|
23
|
+
const abs = path.join(dir, rel);
|
|
24
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
25
|
+
fs.writeFileSync(abs, body);
|
|
26
|
+
}
|
|
27
|
+
const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8' });
|
|
28
|
+
return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
|
|
29
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function profile({ origin = 'сторонний-разбор', analysisSource = 'styles.refero.design — DESIGN.md',
|
|
33
|
+
snapshotDate = '2026-08-20', rows = [] } = {}) {
|
|
34
|
+
return '# Source Product Profile\n\n'
|
|
35
|
+
+ '**Источник:** Senja — https://senja.io\n'
|
|
36
|
+
+ '**Статус съёмки:** СНЯТ\n'
|
|
37
|
+
+ '**Статус съёмки (путь):** ИСТОЧНИКА НЕТ\n'
|
|
38
|
+
+ (origin === null ? '' : '**Происхождение:** ' + origin + '\n')
|
|
39
|
+
+ (analysisSource === null ? '' : '**Источник разбора:** ' + analysisSource + '\n')
|
|
40
|
+
+ (snapshotDate === null ? '' : '**Дата стороннего снимка:** ' + snapshotDate + '\n')
|
|
41
|
+
+ '\n## 🎨 Look Requirements Seed\n\n'
|
|
42
|
+
+ '| ID | Обязательство | Ось | Источник-экран | Уверенность | Статус |\n'
|
|
43
|
+
+ '|----|----|----|----|----|----|\n'
|
|
44
|
+
+ rows.join('\n') + (rows.length ? '\n' : '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const HYP1 = '| FR-LOOK-001 | Палитра индиго/сланец | облик | DESIGN.md#palette | сторонний снимок | ГИПОТЕЗА |';
|
|
48
|
+
const HYP2 = '| FR-LOOK-002 | Трёхшаговый онбординг | путь | DESIGN.md#flow | сторонний снимок | ГИПОТЕЗА |';
|
|
49
|
+
const DRAFT = '| FR-LOOK-003 | Белые карточки | облик | /pricing | живой прогон | ЧЕРНОВИК |';
|
|
50
|
+
|
|
51
|
+
describe('check-look-origin keeps third-party analysis hypothetical until dated live confirmation', () => {
|
|
52
|
+
test('A1 RED - promoted hypothesis rows are refused and both rows are named', () => {
|
|
53
|
+
const r = check({
|
|
54
|
+
[PROFILE]: profile({ rows: [HYP1, HYP2] }),
|
|
55
|
+
[SPEC]: '# Specification\n\nFR-LOOK-001 используем.\nFR-LOOK-002 используем.\n',
|
|
56
|
+
});
|
|
57
|
+
assert.equal(r.code, 1, 'promotion without confirmation must be refused: ' + r.out);
|
|
58
|
+
assert.match(r.out, /FR-LOOK-001/, 'the first promoted row must be named: ' + r.out);
|
|
59
|
+
assert.match(r.out, /FR-LOOK-002/, 'the second promoted row must be named: ' + r.out);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('A2 - third-party analysis needs both its source and a valid snapshot date', () => {
|
|
63
|
+
for (const bad of [
|
|
64
|
+
{ analysisSource: null, snapshotDate: '2026-08-20', expected: /Источник разбора/ },
|
|
65
|
+
{ analysisSource: 'DESIGN.md', snapshotDate: null, expected: /Дата стороннего снимка/ },
|
|
66
|
+
{ analysisSource: 'DESIGN.md', snapshotDate: 'not-a-date', expected: /not-a-date/ },
|
|
67
|
+
{ analysisSource: 'DESIGN.md', snapshotDate: '2999-01-01', expected: /2999-01-01/ },
|
|
68
|
+
]) {
|
|
69
|
+
const r = check({ [PROFILE]: profile({ ...bad, rows: [HYP1] }), [SPEC]: '# Specification\n' });
|
|
70
|
+
assert.equal(r.code, 1, 'bad third-party provenance is a proven defect: ' + r.out);
|
|
71
|
+
assert.match(r.out, bad.expected, r.out);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('A5 - illegal origin and row status are exit 1 with quoted value and the closed list', () => {
|
|
76
|
+
const badOrigin = check({
|
|
77
|
+
[PROFILE]: profile({ origin: 'с-разбора', rows: [HYP1] }), [SPEC]: '# Specification\n',
|
|
78
|
+
});
|
|
79
|
+
assert.equal(badOrigin.code, 1, badOrigin.out);
|
|
80
|
+
assert.match(badOrigin.out, /«с-разбора»/, badOrigin.out);
|
|
81
|
+
assert.match(badOrigin.out, /прокликано.*сторонний-разбор.*вручную.*не снято/s, badOrigin.out);
|
|
82
|
+
|
|
83
|
+
const misspelled = HYP1.replace('ГИПОТЕЗА', 'ГИПОТЗА');
|
|
84
|
+
const badStatus = check({
|
|
85
|
+
[PROFILE]: profile({ rows: [misspelled] }), [SPEC]: '# Specification\n',
|
|
86
|
+
});
|
|
87
|
+
assert.equal(badStatus.code, 1, badStatus.out);
|
|
88
|
+
assert.match(badStatus.out, /«ГИПОТЗА»/, badStatus.out);
|
|
89
|
+
assert.match(badStatus.out, /ЧЕРНОВИК.*ГИПОТЕЗА.*ПОДТВЕРЖДЕНО.*УСТАРЕЛО/s, badStatus.out);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('A3 - confirmed rows require parseable, non-future dates and a live run not older than the snapshot', () => {
|
|
93
|
+
const confirmed = (record) =>
|
|
94
|
+
'| FR-LOOK-001 | Палитра индиго/сланец | облик | DESIGN.md#palette | '
|
|
95
|
+
+ record + ' | ПОДТВЕРЖДЕНО |';
|
|
96
|
+
for (const record of [
|
|
97
|
+
'подтверждено: живой прогон 2026-08-10 · сторонний снимок 2026-08-20',
|
|
98
|
+
'подтверждено: живой прогон tomorrow · сторонний снимок 2026-08-20',
|
|
99
|
+
'подтверждено: живой прогон 2999-01-01 · сторонний снимок 2026-08-20',
|
|
100
|
+
'живой прогон без записи обеих дат',
|
|
101
|
+
]) {
|
|
102
|
+
const r = check({
|
|
103
|
+
[PROFILE]: profile({ rows: [confirmed(record)] }),
|
|
104
|
+
[SPEC]: '# Specification\nFR-LOOK-001 используем.\n',
|
|
105
|
+
});
|
|
106
|
+
assert.equal(r.code, 1, 'invalid confirmation must be refused: ' + record + '\n' + r.out);
|
|
107
|
+
assert.match(r.out, /FR-LOOK-001/, 'the invalid confirmed row must be named: ' + r.out);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a dated live confirmation at or after the snapshot is clean', () => {
|
|
112
|
+
const row = '| FR-LOOK-001 | Палитра индиго/сланец | облик | DESIGN.md#palette | '
|
|
113
|
+
+ 'подтверждено: живой прогон 2026-08-20 · сторонний снимок 2026-08-20 | ПОДТВЕРЖДЕНО |';
|
|
114
|
+
const r = check({
|
|
115
|
+
[PROFILE]: profile({ rows: [row] }), [SPEC]: '# Specification\nFR-LOOK-001 используем.\n',
|
|
116
|
+
});
|
|
117
|
+
assert.equal(r.code, 0, r.out);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a legacy profile with no origin field and no hypothesis rows remains exit 0 with a note', () => {
|
|
121
|
+
const r = check({
|
|
122
|
+
[PROFILE]: profile({ origin: null, analysisSource: null, snapshotDate: null, rows: [DRAFT] }),
|
|
123
|
+
[SPEC]: '# Specification\n',
|
|
124
|
+
});
|
|
125
|
+
assert.equal(r.code, 0, r.out);
|
|
126
|
+
assert.match(r.out, /legacy/i, 'the compatibility path must be visible: ' + r.out);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a hypothesis under a legacy header is refused because its provenance is missing', () => {
|
|
130
|
+
const r = check({
|
|
131
|
+
[PROFILE]: profile({ origin: null, analysisSource: null, snapshotDate: null, rows: [HYP1] }),
|
|
132
|
+
[SPEC]: '# Specification\n',
|
|
133
|
+
});
|
|
134
|
+
assert.equal(r.code, 1, r.out);
|
|
135
|
+
assert.match(r.out, /Происхождение/, r.out);
|
|
136
|
+
assert.match(r.out, /FR-LOOK-001/, r.out);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('a reasoned rejection is not promotion', () => {
|
|
140
|
+
const r = check({
|
|
141
|
+
[PROFILE]: profile({ rows: [HYP1] }),
|
|
142
|
+
[SPEC]: '# Specification\nFR-LOOK-001 отклонено — палитра защищена товарным знаком.\n',
|
|
143
|
+
});
|
|
144
|
+
assert.equal(r.code, 0, r.out);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('missing profile or Specification is exit 2, never a clean bill', () => {
|
|
148
|
+
const noProfile = check({ [SPEC]: '# Specification\n' });
|
|
149
|
+
assert.equal(noProfile.code, 2, noProfile.out);
|
|
150
|
+
assert.match(noProfile.out, /source-product-profile/, noProfile.out);
|
|
151
|
+
|
|
152
|
+
const noSpec = check({ [PROFILE]: profile({ rows: [HYP1] }) });
|
|
153
|
+
assert.equal(noSpec.code, 2, noSpec.out);
|
|
154
|
+
assert.match(noSpec.out, /Specification/, noSpec.out);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('one binary returns all three verdicts', () => {
|
|
158
|
+
const seen = [
|
|
159
|
+
check({
|
|
160
|
+
[PROFILE]: profile({ origin: null, analysisSource: null, snapshotDate: null, rows: [DRAFT] }),
|
|
161
|
+
[SPEC]: '# Specification\n',
|
|
162
|
+
}).code,
|
|
163
|
+
check({ [PROFILE]: profile({ rows: [HYP1] }), [SPEC]: 'FR-LOOK-001\n' }).code,
|
|
164
|
+
check({}).code,
|
|
165
|
+
];
|
|
166
|
+
assert.deepEqual(seen, [0, 1, 2]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('the guard is registered and hooksExpected agrees with the shipped hook count', () => {
|
|
170
|
+
const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
|
|
171
|
+
assert.ok(COMPONENTS.hooks.items['check-look-origin'], 'the guard must be a hooks component');
|
|
172
|
+
assert.ok(fs.existsSync(CHECK), 'the registered guard must exist on disk');
|
|
173
|
+
const statusline = fs.readFileSync(path.join(TPL, 'hooks', 'statusline.cjs'), 'utf8');
|
|
174
|
+
const declared = statusline.match(/hooksExpected:\s*(\d+)/);
|
|
175
|
+
assert.ok(declared, 'statusline must declare hooksExpected');
|
|
176
|
+
assert.equal(Number(declared[1]), Object.keys(COMPONENTS.hooks.items).length);
|
|
177
|
+
const onDisk = fs.readdirSync(path.join(TPL, 'hooks')).filter((name) => name.endsWith('.cjs'));
|
|
178
|
+
assert.equal(onDisk.length, Object.keys(COMPONENTS.hooks.items).length);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The deterministic half of the Phase-0.5 gate. The phase itself is prose a model executes —
|
|
4
|
+
// layer 2/3. This is layer 1, and only for whoever runs it.
|
|
5
|
+
//
|
|
6
|
+
// The load-bearing property is the THIRD exit code, for the same reason it was in check-ports and
|
|
7
|
+
// check-growth-trace: a checker that answers "clean" when it could not look converts an unknown into
|
|
8
|
+
// a reassurance. Here the unknown has THREE distinct causes, and only one of them is a defect:
|
|
9
|
+
//
|
|
10
|
+
// ИСТОЧНИКА НЕТ the project replicates nothing → 2, a legitimate answer
|
|
11
|
+
// НЕ ИЗМЕРЕНО a source was NAMED but not captured → 2, with a reason from a closed list
|
|
12
|
+
// СНЯТ the look was captured → the table is checked, 0 or 1
|
|
13
|
+
//
|
|
14
|
+
// Reading any of the 2s as 0 would report "the source look is carried through" for a project whose
|
|
15
|
+
// look nobody ever looked at — which is exactly the defect the phase exists to remove.
|
|
16
|
+
//
|
|
17
|
+
// These are BEHAVIOUR tests: the real utility, real files, real exit codes.
|
|
18
|
+
|
|
19
|
+
const { test, describe } = require('node:test');
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
const { spawnSync } = require('node:child_process');
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
27
|
+
const TPL = path.join(PKG, 'templates', '.claude');
|
|
28
|
+
const CHECK = path.join(TPL, 'hooks', 'check-look-trace.cjs');
|
|
29
|
+
|
|
30
|
+
/** Build a throwaway project and run the real checker over it. */
|
|
31
|
+
function check(files) {
|
|
32
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-look-')));
|
|
33
|
+
try {
|
|
34
|
+
for (const [rel, body] of Object.entries(files || {})) {
|
|
35
|
+
const abs = path.join(dir, rel);
|
|
36
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
37
|
+
fs.writeFileSync(abs, body);
|
|
38
|
+
}
|
|
39
|
+
const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8' });
|
|
40
|
+
return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
|
|
41
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PROFILE = 'docs/source-product-profile.md';
|
|
45
|
+
const SPEC = 'docs/Specification.md';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A profile in one of the three states, with whatever rows the case needs.
|
|
49
|
+
*
|
|
50
|
+
* `pathStatus` / `pathReason` are the SECOND axis's own answer. It defaults to «ИСТОЧНИКА НЕТ» so
|
|
51
|
+
* that a case about the `облик` axis stays about the `облик` axis — but the default is a real
|
|
52
|
+
* DECLARATION, never silence: silence is its own case, asserted in P16.
|
|
53
|
+
*/
|
|
54
|
+
function profile({ status = 'СНЯТ', reason = null, source = 'Senja — https://senja.io', rows = [],
|
|
55
|
+
pathStatus = 'ИСТОЧНИКА НЕТ', pathReason = null } = {}) {
|
|
56
|
+
return '# Source Product Profile\n\n'
|
|
57
|
+
+ '**Источник:** ' + source + '\n'
|
|
58
|
+
+ '**Статус съёмки:** ' + status + '\n'
|
|
59
|
+
+ (reason === null ? '' : '**Причина:** ' + reason + '\n')
|
|
60
|
+
+ (pathStatus === null ? '' : '**Статус съёмки (путь):** ' + pathStatus + '\n')
|
|
61
|
+
+ (pathReason === null ? '' : '**Причина (путь):** ' + pathReason + '\n')
|
|
62
|
+
+ '\n## 🎨 Look Requirements Seed\n\n'
|
|
63
|
+
+ '| ID | Обязательство | Ось | Источник-экран | Уверенность | Статус |\n'
|
|
64
|
+
+ '|----|----|----|----|----|----|\n'
|
|
65
|
+
+ rows.join('\n') + (rows.length ? '\n' : '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ROW1 = '| FR-LOOK-001 | Палитра индиго/сланец с белыми карточками | облик | /pricing | скриншот 2026-09-01 | ЧЕРНОВИК |';
|
|
69
|
+
const ROW2 = '| FR-LOOK-002 | Онбординг из трёх шагов до первого виджета | путь | /signup | запись сессии | ЧЕРНОВИК |';
|
|
70
|
+
|
|
71
|
+
/** The CLOSED list, as the checker owns it. Four originals plus the four browser-specific ones. */
|
|
72
|
+
const REASONS = ['no-browser-mcp', 'unreachable', 'auth-required', 'out-of-scope',
|
|
73
|
+
'no-browser', 'bot-protected', 'timeout', 'robots-disallowed'];
|
|
74
|
+
|
|
75
|
+
describe('the look-trace checker answers three questions, and never confuses two of them', () => {
|
|
76
|
+
test('P1 - a traced obligation is exit 0', () => {
|
|
77
|
+
const r = check({
|
|
78
|
+
[PROFILE]: profile({ rows: [ROW1] }),
|
|
79
|
+
[SPEC]: '# Spec\n\n## FR-LOOK-001 Палитра\n\nПодробности.\n',
|
|
80
|
+
});
|
|
81
|
+
assert.equal(r.code, 0, 'a traced id must pass: ' + r.out);
|
|
82
|
+
assert.match(r.out, /прослежены/, r.out);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('P2 - REQUIRED: a filled table with no promotion is exit 1, and the lost ids are NAMED', () => {
|
|
86
|
+
// The first of the three mandated cases. A count without names sends the reader to diff two
|
|
87
|
+
// documents by hand; the whole value of the check is that it says WHICH obligation was dropped.
|
|
88
|
+
const r = check({
|
|
89
|
+
[PROFILE]: profile({ rows: [ROW1, ROW2] }),
|
|
90
|
+
[SPEC]: '# Spec\n\nНичего про облик.\n',
|
|
91
|
+
});
|
|
92
|
+
assert.equal(r.code, 1, 'a captured-then-dropped look must fail: ' + r.out);
|
|
93
|
+
assert.match(r.out, /FR-LOOK-001/, 'the first lost id must be named: ' + r.out);
|
|
94
|
+
assert.match(r.out, /FR-LOOK-002/, 'the second lost id must be named: ' + r.out);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('P3 - REQUIRED: an absent profile is exit 2, never exit 0', () => {
|
|
98
|
+
// The second mandated case, and the most important one. Phase 0.5 is mandatory, so an absent
|
|
99
|
+
// profile means the phase did not run — reading that as clean would silently bless every
|
|
100
|
+
// project that skipped it.
|
|
101
|
+
const r = check({ [SPEC]: '# Spec\n' });
|
|
102
|
+
assert.equal(r.code, 2, 'no profile means the check did not run: ' + r.out);
|
|
103
|
+
assert.match(r.out, /проверка НЕ выполнена/, r.out);
|
|
104
|
+
assert.match(r.out, /Фаза 0\.5 не запускалась/, 'and the reason must be named: ' + r.out);
|
|
105
|
+
assert.match(r.out, /--from-docs/,
|
|
106
|
+
'and it must say WHY that is not an excuse: --from-docs skips Phase 0 only');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('P4 - REQUIRED: untouched bracketed placeholders are exit 2, not "the phase ran"', () => {
|
|
110
|
+
// The third mandated case. The command ships an example table with bracketed placeholders.
|
|
111
|
+
// Counting them would let an untouched template look like a filled-in one, and then
|
|
112
|
+
// "0 traced of 0" reads as clean.
|
|
113
|
+
const r = check({
|
|
114
|
+
[PROFILE]: profile({ rows: [
|
|
115
|
+
'| FR-LOOK-001 | [что обязаны воспроизвести, одним предложением] | облик | [URL] | [как измерено] | ЧЕРНОВИК |',
|
|
116
|
+
'| FR-LOOK-002 | ... | путь | ... | ... | ... |',
|
|
117
|
+
] }),
|
|
118
|
+
[SPEC]: '# Spec\n',
|
|
119
|
+
});
|
|
120
|
+
assert.equal(r.code, 2, 'a placeholder row is not an obligation: ' + r.out);
|
|
121
|
+
assert.match(r.out, /ни одной заполненной строки/, r.out);
|
|
122
|
+
assert.ok(!/прослежены/.test(r.out), 'and it must not claim anything was traced: ' + r.out);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('P5 - «источника нет» is exit 2 and is stated as a LEGITIMATE answer', () => {
|
|
126
|
+
const r = check({ [PROFILE]: profile({ status: 'ИСТОЧНИКА НЕТ', source: 'нет' }), [SPEC]: '# Spec\n' });
|
|
127
|
+
assert.equal(r.code, 2, 'nothing to trace is not "everything traced": ' + r.out);
|
|
128
|
+
assert.match(r.out, /законный ответ/, 'it must not read as a violation: ' + r.out);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('P6 - «НЕ ИЗМЕРЕНО» needs a reason from the closed list, and is exit 2 either way', () => {
|
|
132
|
+
const noReason = check({ [PROFILE]: profile({ status: 'НЕ ИЗМЕРЕНО', reason: null }), [SPEC]: '# Spec\n' });
|
|
133
|
+
assert.equal(noReason.code, 2, noReason.out);
|
|
134
|
+
assert.match(noReason.out, /Причина/, 'the missing reason must be named: ' + noReason.out);
|
|
135
|
+
assert.match(noReason.out, /no-browser-mcp/, 'and the closed list printed: ' + noReason.out);
|
|
136
|
+
|
|
137
|
+
const freeText = check({
|
|
138
|
+
[PROFILE]: profile({ status: 'НЕ ИЗМЕРЕНО', reason: 'было некогда' }), [SPEC]: '# Spec\n',
|
|
139
|
+
});
|
|
140
|
+
assert.equal(freeText.code, 2, 'free text is not one of the four fixes: ' + freeText.out);
|
|
141
|
+
assert.match(freeText.out, /закрытого списка/, freeText.out);
|
|
142
|
+
|
|
143
|
+
for (const reason of REASONS) {
|
|
144
|
+
const r = check({ [PROFILE]: profile({ status: 'НЕ ИЗМЕРЕНО', reason }), [SPEC]: '# Spec\n' });
|
|
145
|
+
assert.equal(r.code, 2, reason + ' must still be inconclusive, never clean: ' + r.out);
|
|
146
|
+
assert.match(r.out, new RegExp(reason), 'the reason must be echoed: ' + r.out);
|
|
147
|
+
assert.match(r.out, /НЕ ИЗМЕРЕН/, r.out);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// `no-browser-mcp` and `no-browser` are DIFFERENT missing tools (the clone-website browser MCP
|
|
151
|
+
// versus a local Playwright), and they share a prefix. A matcher that let one swallow the other
|
|
152
|
+
// would report "several reasons named" for either spelling and refuse both.
|
|
153
|
+
for (const reason of ['no-browser-mcp', 'no-browser']) {
|
|
154
|
+
const r = check({ [PROFILE]: profile({ status: 'НЕ ИЗМЕРЕНО', reason }), [SPEC]: '# Spec\n' });
|
|
155
|
+
assert.match(r.out, new RegExp('причина: ' + reason + '$', 'm'),
|
|
156
|
+
'the prefix pair must resolve to exactly one reason: ' + r.out);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('P7 - an unrecognised capture status is refused, and the recognised ones are printed', () => {
|
|
161
|
+
// CFG-I3 of honest-configuration: an unmapped variant refuses and lists the code-owned set.
|
|
162
|
+
// A misspelling that silently read as СНЯТ would check a table nobody captured.
|
|
163
|
+
const r = check({ [PROFILE]: profile({ status: 'снято частично' }), [SPEC]: '# Spec\n' });
|
|
164
|
+
assert.equal(r.code, 2, r.out);
|
|
165
|
+
assert.match(r.out, /нераспознанный статус/, r.out);
|
|
166
|
+
assert.match(r.out, /СНЯТ/, r.out);
|
|
167
|
+
assert.match(r.out, /ИСТОЧНИКА НЕТ/, r.out);
|
|
168
|
+
|
|
169
|
+
const missing = check({
|
|
170
|
+
[PROFILE]: '# Source Product Profile\n\n**Источник:** Senja\n', [SPEC]: '# Spec\n',
|
|
171
|
+
});
|
|
172
|
+
assert.equal(missing.code, 2, 'an absent status line is not a default: ' + missing.out);
|
|
173
|
+
assert.match(missing.out, /Статус съёмки/, missing.out);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('P8 - an absent Specification is exit 2, not exit 1', () => {
|
|
177
|
+
// Without the destination there is nothing to compare against. Calling that "nothing traced"
|
|
178
|
+
// would blame the project for the checker's blindness.
|
|
179
|
+
const r = check({ [PROFILE]: profile({ rows: [ROW1] }) });
|
|
180
|
+
assert.equal(r.code, 2, 'no Specification means the check could not run: ' + r.out);
|
|
181
|
+
assert.match(r.out, /Specification/, r.out);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('P9 - a partial loss is reported with the count and only the lost row is listed', () => {
|
|
185
|
+
const r = check({
|
|
186
|
+
[PROFILE]: profile({ rows: [ROW1, ROW2] }),
|
|
187
|
+
[SPEC]: '# Spec\n\nFR-LOOK-001 воспроизводим.\n',
|
|
188
|
+
});
|
|
189
|
+
assert.equal(r.code, 1, 'one of two lost is still a loss: ' + r.out);
|
|
190
|
+
assert.match(r.out, /1 из 2/, 'the count must be reported: ' + r.out);
|
|
191
|
+
const bullets = r.out.split('\n').filter((l) => l.includes('•')).join('\n');
|
|
192
|
+
assert.match(bullets, /FR-LOOK-002/, 'the lost one must be listed: ' + r.out);
|
|
193
|
+
assert.ok(!/FR-LOOK-001/.test(bullets), 'the traced one must NOT be listed as lost: ' + r.out);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('P10 - a rejection WITH a reason passes; a bare refusal does not', () => {
|
|
197
|
+
const withReason = check({
|
|
198
|
+
[PROFILE]: profile({ rows: [ROW1] }),
|
|
199
|
+
[SPEC]: '# Spec\n\nFR-LOOK-001 отклонено — исходная палитра защищена товарным знаком.\n',
|
|
200
|
+
});
|
|
201
|
+
assert.equal(withReason.code, 0, 'a recorded rejection is a legitimate answer: ' + withReason.out);
|
|
202
|
+
|
|
203
|
+
// The identifier itself carries two hyphens, so a reason pattern that accepts punctuation would
|
|
204
|
+
// read the id as its own justification. That was a MEASURED false-clean in the growth twin.
|
|
205
|
+
const bare = check({
|
|
206
|
+
[PROFILE]: profile({ rows: [ROW1] }), [SPEC]: '# Spec\n\nFR-LOOK-001 rejected\n',
|
|
207
|
+
});
|
|
208
|
+
assert.equal(bare.code, 1, 'a refusal with no reason is a silent drop: ' + bare.out);
|
|
209
|
+
|
|
210
|
+
const noise = check({
|
|
211
|
+
[PROFILE]: profile({ rows: [ROW1] }), [SPEC]: '# Spec\n\nFR-LOOK-001 declined - x\n',
|
|
212
|
+
});
|
|
213
|
+
assert.equal(noise.code, 1, 'one character is a separator plus noise, not a reason: ' + noise.out);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('P11 - a reused id is exit 2, because one mention would clear two obligations', () => {
|
|
217
|
+
const dup = '| FR-LOOK-001 | Совсем другое обязательство | путь | /signup | скриншот | ЧЕРНОВИК |';
|
|
218
|
+
const r = check({
|
|
219
|
+
[PROFILE]: profile({ rows: [ROW1, dup] }), [SPEC]: '# Spec\nFR-LOOK-001 берём\n',
|
|
220
|
+
});
|
|
221
|
+
assert.equal(r.code, 2, 'a duplicate id must not read as clean: ' + r.out);
|
|
222
|
+
assert.match(r.out, /повторяются идентификаторы/, r.out);
|
|
223
|
+
assert.match(r.out, /FR-LOOK-001/, 'and the duplicated id must be named: ' + r.out);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('P12 - the axis is a COLUMN of one family, and a row without it is inconclusive', () => {
|
|
227
|
+
// The design constraint is one identifier family with an axis column, NOT a second namespace
|
|
228
|
+
// such as FR-FLOW-nnn. That is only true while the column actually carries a value.
|
|
229
|
+
const r = check({
|
|
230
|
+
[PROFILE]: profile({ rows: [
|
|
231
|
+
'| FR-LOOK-001 | Палитра индиго/сланец | | /pricing | скриншот | ЧЕРНОВИК |',
|
|
232
|
+
] }),
|
|
233
|
+
[SPEC]: '# Spec\nFR-LOOK-001\n',
|
|
234
|
+
});
|
|
235
|
+
assert.equal(r.code, 2, 'a row with no axis must not read as traced: ' + r.out);
|
|
236
|
+
assert.match(r.out, /Ось/, r.out);
|
|
237
|
+
assert.match(r.out, /облик/, 'the closed set must be printed: ' + r.out);
|
|
238
|
+
assert.match(r.out, /путь/, r.out);
|
|
239
|
+
|
|
240
|
+
const bogus = check({
|
|
241
|
+
[PROFILE]: profile({ rows: [
|
|
242
|
+
'| FR-LOOK-001 | Палитра индиго/сланец | поток | /pricing | скриншот | ЧЕРНОВИК |',
|
|
243
|
+
] }),
|
|
244
|
+
[SPEC]: '# Spec\nFR-LOOK-001\n',
|
|
245
|
+
});
|
|
246
|
+
assert.equal(bogus.code, 2, 'an axis outside the closed set is not an axis: ' + bogus.out);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('P13 - one binary, all three verdicts in a single run', () => {
|
|
250
|
+
// Each case above asserts ONE direction, so a constant-answer implementation could pass a
|
|
251
|
+
// subset. The same executable must produce 0, 1 and 2.
|
|
252
|
+
const seen = [
|
|
253
|
+
check({ [PROFILE]: profile({ rows: [ROW1] }), [SPEC]: 'FR-LOOK-001\n' }).code,
|
|
254
|
+
check({ [PROFILE]: profile({ rows: [ROW1] }), [SPEC]: 'ничего\n' }).code,
|
|
255
|
+
check({}).code,
|
|
256
|
+
];
|
|
257
|
+
assert.deepEqual(seen, [0, 1, 2], 'expected clean/lost/could-not-check: ' + JSON.stringify(seen));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('P14 - a directory that does not exist is exit 2', () => {
|
|
261
|
+
const r = spawnSync(process.execPath, [CHECK, '/nonexistent-path-for-this-test'],
|
|
262
|
+
{ encoding: 'utf8' });
|
|
263
|
+
assert.equal(r.status, 2, 'an unusable argument is not a clean bill: ' + r.stdout);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ── ось «путь»: тот же механизм трёх исходов, отдельный ответ ────────────────────────────────
|
|
267
|
+
//
|
|
268
|
+
// The axes FAIL APART — a landing page captures while the click-through dies on a 403 — so one
|
|
269
|
+
// shared status would have to lie about one of them. These assert the second axis answers for
|
|
270
|
+
// itself, WITHOUT a second identifier family and without a second artifact.
|
|
271
|
+
|
|
272
|
+
test('P16 - REQUIRED: an EMPTY «путь» axis with no declaration is exit 2, never exit 0', () => {
|
|
273
|
+
// The mandated "untouched stub" case for this axis. Before the instrument existed, silence about
|
|
274
|
+
// the path was unavoidable; now silence is a gap, and reading it as clean would bless every
|
|
275
|
+
// profile that only ever looked at one screen.
|
|
276
|
+
const r = check({
|
|
277
|
+
[PROFILE]: profile({ rows: [ROW1], pathStatus: null }),
|
|
278
|
+
[SPEC]: '# Spec\n\nFR-LOOK-001 берём как есть.\n',
|
|
279
|
+
});
|
|
280
|
+
assert.equal(r.code, 2, 'an undeclared path axis must not read as a clean bill: ' + r.out);
|
|
281
|
+
assert.match(r.out, /проверка НЕ выполнена/, r.out);
|
|
282
|
+
assert.match(r.out, /ось «путь» пуста и не объявлена/, r.out);
|
|
283
|
+
assert.match(r.out, /capture-source-path\.cjs/,
|
|
284
|
+
'and the instrument must be named, or the reader has a verdict with no repair: ' + r.out);
|
|
285
|
+
assert.ok(!/прослежены/.test(r.out), 'nothing may be claimed traced: ' + r.out);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('P17 - REQUIRED: a FILLED «путь» axis with no promotion is exit 1, and the ids are NAMED', () => {
|
|
289
|
+
// The mandated "filled axis, no promotion" case. ROW2 is a path row, so no declaration is asked
|
|
290
|
+
// for: rows ARE the answer.
|
|
291
|
+
const r = check({
|
|
292
|
+
[PROFILE]: profile({ rows: [ROW1, ROW2], pathStatus: null }),
|
|
293
|
+
[SPEC]: '# Spec\n\nFR-LOOK-001 берём.\n',
|
|
294
|
+
});
|
|
295
|
+
assert.equal(r.code, 1, 'a captured-then-dropped path must fail: ' + r.out);
|
|
296
|
+
assert.match(r.out, /FR-LOOK-002/, 'the lost path id must be NAMED: ' + r.out);
|
|
297
|
+
const bullets = r.out.split('\n').filter((l) => l.includes('•')).join('\n');
|
|
298
|
+
assert.match(bullets, /путь/, 'and its axis must be shown: ' + r.out);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('P18 - «путь» declared НЕ ИЗМЕРЕНО keeps a fully traced profile at 2, not 0', () => {
|
|
302
|
+
// The asymmetry that makes the axis worth having. Everything written down IS traced, yet half
|
|
303
|
+
// the phase never ran — "прослежено" here would be a complete-looking receipt for half a check.
|
|
304
|
+
const r = check({
|
|
305
|
+
[PROFILE]: profile({ rows: [ROW1], pathStatus: 'НЕ ИЗМЕРЕНО', pathReason: 'bot-protected' }),
|
|
306
|
+
[SPEC]: '# Spec\n\nFR-LOOK-001 берём.\n',
|
|
307
|
+
});
|
|
308
|
+
assert.equal(r.code, 2, 'a half-run phase is not a clean bill: ' + r.out);
|
|
309
|
+
assert.match(r.out, /ось «путь» НЕ ИЗМЕРЕНА/, r.out);
|
|
310
|
+
assert.match(r.out, /bot-protected/, 'the reason must be echoed: ' + r.out);
|
|
311
|
+
assert.ok(!/^✅/m.test(r.out), 'and it must not print the clean line: ' + r.out);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('P19 - a proven LOSS outranks an unanswered «путь» axis (1 beats 2)', () => {
|
|
315
|
+
// Order of verdicts is a decision, not an accident: a dropped obligation is a defect with a
|
|
316
|
+
// named fix, an unanswered axis is a question. Reporting the question first would hide the
|
|
317
|
+
// defect behind "проверка не выполнена".
|
|
318
|
+
const r = check({
|
|
319
|
+
[PROFILE]: profile({ rows: [ROW1], pathStatus: 'НЕ ИЗМЕРЕНО', pathReason: 'timeout' }),
|
|
320
|
+
[SPEC]: '# Spec\n\nничего про облик\n',
|
|
321
|
+
});
|
|
322
|
+
assert.equal(r.code, 1, 'the proven loss must win: ' + r.out);
|
|
323
|
+
assert.match(r.out, /FR-LOOK-001/, r.out);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('P20 - «путь: ИСТОЧНИКА НЕТ» is a legitimate answer and does NOT block a clean bill', () => {
|
|
327
|
+
// Unlike the whole-profile version of that answer, the trace set here is non-empty, so 0 still
|
|
328
|
+
// means something. If an honest answer could never reach 0, the check would degenerate into a
|
|
329
|
+
// permanent 2 that nobody reads.
|
|
330
|
+
const r = check({
|
|
331
|
+
[PROFILE]: profile({ rows: [ROW1], pathStatus: 'ИСТОЧНИКА НЕТ' }),
|
|
332
|
+
[SPEC]: '# Spec\n\nFR-LOOK-001 берём.\n',
|
|
333
|
+
});
|
|
334
|
+
assert.equal(r.code, 0, 'a declared sourceless path axis must not block: ' + r.out);
|
|
335
|
+
assert.match(r.out, /Ось «путь»: ИСТОЧНИКА НЕТ/,
|
|
336
|
+
'and the answer must be VISIBLE in the receipt, or the reader cannot tell which check ran: '
|
|
337
|
+
+ r.out);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('P21 - NEGATIVE: a «путь» reason outside the closed list is not an honest refusal', () => {
|
|
341
|
+
// The list is closed because each entry names a different repair. Free text, a plausible-looking
|
|
342
|
+
// near-miss, and two reasons at once all leave the reader with nothing to do.
|
|
343
|
+
for (const bad of ['было некогда', 'no-browser-please', 'bot-protected и timeout']) {
|
|
344
|
+
const r = check({
|
|
345
|
+
[PROFILE]: profile({ rows: [ROW1], pathStatus: 'НЕ ИЗМЕРЕНО', pathReason: bad }),
|
|
346
|
+
[SPEC]: '# Spec\n\nFR-LOOK-001 берём.\n',
|
|
347
|
+
});
|
|
348
|
+
assert.equal(r.code, 2, '«' + bad + '» must not pass as a reason: ' + r.out);
|
|
349
|
+
assert.match(r.out, /не из закрытого списка/, r.out);
|
|
350
|
+
assert.match(r.out, /\(путь\)/, 'and the axis must be named: ' + r.out);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// A missing reason line is its own failure, distinct from a wrong one.
|
|
354
|
+
const none = check({
|
|
355
|
+
[PROFILE]: profile({ rows: [ROW1], pathStatus: 'НЕ ИЗМЕРЕНО' }),
|
|
356
|
+
[SPEC]: '# Spec\n\nFR-LOOK-001 берём.\n',
|
|
357
|
+
});
|
|
358
|
+
assert.equal(none.code, 2, none.out);
|
|
359
|
+
assert.match(none.out, /Причина \(путь\)/, 'the missing line must be named: ' + none.out);
|
|
360
|
+
|
|
361
|
+
// And a «путь: СНЯТ» with no path rows is the mirror of the untouched-template case: a captured
|
|
362
|
+
// path nobody wrote down is indistinguishable from an uncaptured one.
|
|
363
|
+
const claimed = check({
|
|
364
|
+
[PROFILE]: profile({ rows: [ROW1], pathStatus: 'СНЯТ' }),
|
|
365
|
+
[SPEC]: '# Spec\n\nFR-LOOK-001 берём.\n',
|
|
366
|
+
});
|
|
367
|
+
assert.equal(claimed.code, 2, claimed.out);
|
|
368
|
+
assert.match(claimed.out, /объявлена СНЯТ, но в таблице нет ни одной строки/, claimed.out);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('P22 - a traced draft plus an unpromoted hypothesis is exit 0 with an informational count', () => {
|
|
372
|
+
const hypothesis = '| FR-LOOK-002 | Гипотеза о пути регистрации | путь | DESIGN.md#flow | сторонний снимок | ГИПОТЕЗА |';
|
|
373
|
+
const r = check({
|
|
374
|
+
[PROFILE]: profile({ rows: [ROW1, hypothesis] }),
|
|
375
|
+
[SPEC]: '# Specification\nFR-LOOK-001 берём.\n',
|
|
376
|
+
});
|
|
377
|
+
assert.equal(r.code, 0, 'an unpromoted hypothesis is not a lost captured row: ' + r.out);
|
|
378
|
+
assert.match(r.out, /строк-гипотез вне промоушена: 1/, r.out);
|
|
379
|
+
assert.match(r.out, /check-look-origin\.cjs/, r.out);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('P23 - a lost draft is exit 1 while the hypothesis is not named as lost', () => {
|
|
383
|
+
const hypothesis = '| FR-LOOK-002 | Гипотеза о пути регистрации | путь | DESIGN.md#flow | сторонний снимок | ГИПОТЕЗА |';
|
|
384
|
+
const r = check({
|
|
385
|
+
[PROFILE]: profile({ rows: [ROW1, hypothesis] }), [SPEC]: '# Specification\n',
|
|
386
|
+
});
|
|
387
|
+
assert.equal(r.code, 1, 'the promotable draft is still a proved loss: ' + r.out);
|
|
388
|
+
const bullets = r.out.split('\n').filter((line) => line.includes('•')).join('\n');
|
|
389
|
+
assert.match(bullets, /FR-LOOK-001/, 'the lost draft must be named: ' + r.out);
|
|
390
|
+
assert.ok(!/FR-LOOK-002/.test(bullets), 'the hypothesis must not be named as lost: ' + r.out);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('P24 - an all-hypothesis profile is exit 2, never a clean 0', () => {
|
|
394
|
+
const hypothesis = '| FR-LOOK-002 | Гипотеза о пути регистрации | путь | DESIGN.md#flow | сторонний снимок | ГИПОТЕЗА |';
|
|
395
|
+
const stale = '| FR-LOOK-003 | Устаревшая палитра | облик | DESIGN.md#palette | сторонний снимок | УСТАРЕЛО |';
|
|
396
|
+
const r = check({
|
|
397
|
+
[PROFILE]: profile({ rows: [hypothesis, stale] }), [SPEC]: '# Specification\n',
|
|
398
|
+
});
|
|
399
|
+
assert.equal(r.code, 2, 'zero promotable rows is not a clean tracing receipt: ' + r.out);
|
|
400
|
+
assert.match(r.out, /все строки — гипотезы, промоушен ещё не имел права случиться/, r.out);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('P15 - it is a hooks component wired to NO event, and the three counts agree', () => {
|
|
404
|
+
const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
|
|
405
|
+
assert.ok(COMPONENTS.hooks.items['check-look-trace'],
|
|
406
|
+
'it must be registered, or init/doctor/verify will not know it');
|
|
407
|
+
const settings = fs.readFileSync(path.join(TPL, 'settings.json'), 'utf-8');
|
|
408
|
+
assert.ok(!settings.includes('check-look-trace'),
|
|
409
|
+
'it must not be wired to an event: this package\'s hooks are non-blocking by contract, so a '
|
|
410
|
+
+ 'hook could only print — it could never refuse anything');
|
|
411
|
+
const statusline = fs.readFileSync(path.join(TPL, 'hooks', 'statusline.cjs'), 'utf-8');
|
|
412
|
+
const m = statusline.match(/hooksExpected:\s*(\d+)/);
|
|
413
|
+
assert.ok(m, 'statusline must declare hooksExpected');
|
|
414
|
+
assert.equal(Number(m[1]), Object.keys(COMPONENTS.hooks.items).length,
|
|
415
|
+
'the status line would report a phantom missing hook: ' + m[1]);
|
|
416
|
+
const onDisk = fs.readdirSync(path.join(TPL, 'hooks')).filter((f) => f.endsWith('.cjs'));
|
|
417
|
+
assert.equal(onDisk.length, Object.keys(COMPONENTS.hooks.items).length,
|
|
418
|
+
'the shipped directory and the declared contract disagree: ' + onDisk.join(', '));
|
|
419
|
+
});
|
|
420
|
+
});
|