@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,536 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The deterministic half of the incoming-webhooks rule. The rule itself is prose a model executes —
|
|
4
|
+
// layer 2. This is layer 1, and only for whoever runs it.
|
|
5
|
+
//
|
|
6
|
+
// THE DEFECT IT EXISTS FOR. A payment provider delivers ONE event MORE THAN ONCE by construction:
|
|
7
|
+
// its guarantee is at-least-once, never exactly-once. A handler with no repeat key credits the
|
|
8
|
+
// partner's commission twice, and nobody notices — each credit is a legitimate row on its own, so
|
|
9
|
+
// there is no error, no alert, no failing request. The same retry topology also delivers events OUT
|
|
10
|
+
// OF ORDER, which overwrites newer state with older, equally silently. And because the endpoint is
|
|
11
|
+
// public, an unsigned handler accepts a "payment succeeded" event from any stranger.
|
|
12
|
+
//
|
|
13
|
+
// Three sides are covered here, deliberately:
|
|
14
|
+
// 1. BEHAVIOUR — the real utility, real files, real exit codes (P1-P19)
|
|
15
|
+
// 2. FORCE — the rule's text still MANDATES, not suggests (P20-P21); a polite rephrasing must
|
|
16
|
+
// turn this suite red, because a suite that tests a contract's VOCABULARY and not
|
|
17
|
+
// its FORCE has already been observed to pass an optional obligation on this
|
|
18
|
+
// package (commit 37916bd3)
|
|
19
|
+
// 3. WIRING — the seam, the counters and the mutation registry agree (P22-P24)
|
|
20
|
+
|
|
21
|
+
const { test, describe } = require('node:test');
|
|
22
|
+
const assert = require('node:assert/strict');
|
|
23
|
+
const { spawnSync } = require('node:child_process');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
|
|
28
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
29
|
+
const TPL = path.join(PKG, 'templates', '.claude');
|
|
30
|
+
const CHECK = path.join(TPL, 'hooks', 'check-webhook-contract.cjs');
|
|
31
|
+
const CONTRACT = 'docs/webhook-contract.md';
|
|
32
|
+
|
|
33
|
+
const read = (rel) => fs.readFileSync(path.join(PKG, rel), 'utf8');
|
|
34
|
+
|
|
35
|
+
const CLASSES = ['подделка', 'повтор', 'перестановка'];
|
|
36
|
+
/** The three proof files a healthy contract points at. They must EXIST — that is the whole bite. */
|
|
37
|
+
const TESTS = {
|
|
38
|
+
'подделка': 'tests/webhooks/test_signature.py',
|
|
39
|
+
'повтор': 'tests/webhooks/test_redelivery.py',
|
|
40
|
+
'перестановка': 'tests/webhooks/test_ordering.py',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A contract in whatever state the case needs, written into a throwaway project together with the
|
|
45
|
+
* test files it cites, and judged by the REAL checker.
|
|
46
|
+
*
|
|
47
|
+
* Defaults are the HEALTHY ones so every case stays about the single thing it changes; a case that
|
|
48
|
+
* wants silence passes `null` for that field, and silence is asserted as its own outcome.
|
|
49
|
+
*/
|
|
50
|
+
function contract({
|
|
51
|
+
incoming = 'да', sender = 'Stripe', run = 'ВЫПОЛНЕНА', reason = null,
|
|
52
|
+
key = 'event.id', source = 'событие-отправителя',
|
|
53
|
+
store = 'таблица webhook_events, колонка event_id', exclusion = 'уникальный-индекс',
|
|
54
|
+
signedOver = 'сырое-тело', signWhen = 'до-разбора', comparison = 'постоянное-время',
|
|
55
|
+
window = '300', order = 'версия-из-события',
|
|
56
|
+
rows = CLASSES.map((name) => ({ name, status: 'ЗАКРЫТ', evidence: TESTS[name] })),
|
|
57
|
+
} = {}) {
|
|
58
|
+
const line = (label, value) => (value === null ? '' : '**' + label + ':** ' + value + '\n');
|
|
59
|
+
return '# Webhook contract\n\n'
|
|
60
|
+
+ line('Входящие вебхуки', incoming)
|
|
61
|
+
+ line('Отправитель', sender)
|
|
62
|
+
+ line('Проверка повторной доставкой', run)
|
|
63
|
+
+ line('Причина', reason)
|
|
64
|
+
+ line('Ключ повторности', key)
|
|
65
|
+
+ line('Источник ключа', source)
|
|
66
|
+
+ line('Хранилище ключа', store)
|
|
67
|
+
+ line('Механизм исключения', exclusion)
|
|
68
|
+
+ line('Что подписано', signedOver)
|
|
69
|
+
+ line('Когда проверяется подпись', signWhen)
|
|
70
|
+
+ line('Сравнение подписи', comparison)
|
|
71
|
+
+ line('Окно свежести (секунды)', window)
|
|
72
|
+
+ line('Порядок событий', order)
|
|
73
|
+
+ '\n## Классы отказа\n\n'
|
|
74
|
+
+ '| Класс | Статус | Признак | Лечение | Доказательство |\n'
|
|
75
|
+
+ '|---|---|---|---|---|\n'
|
|
76
|
+
+ rows.map((r) => '| ' + r.name + ' | ' + r.status + ' | у владельца | лечение | '
|
|
77
|
+
+ r.evidence + ' |').join('\n') + (rows.length ? '\n' : '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build a throwaway project (contract + the cited test files) and run the REAL checker over it. */
|
|
81
|
+
function check(files, { withTests = true } = {}) {
|
|
82
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-webhook-')));
|
|
83
|
+
try {
|
|
84
|
+
if (withTests) {
|
|
85
|
+
fs.mkdirSync(path.join(dir, 'tests', 'webhooks'), { recursive: true });
|
|
86
|
+
for (const rel of Object.values(TESTS)) fs.writeFileSync(path.join(dir, rel), '# fixture\n');
|
|
87
|
+
}
|
|
88
|
+
for (const [rel, body] of Object.entries(files || {})) {
|
|
89
|
+
const abs = path.join(dir, rel);
|
|
90
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
91
|
+
fs.writeFileSync(abs, body);
|
|
92
|
+
}
|
|
93
|
+
const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8' });
|
|
94
|
+
return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
|
|
95
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('the webhook-contract checker answers three questions and never confuses two of them', () => {
|
|
99
|
+
test('P1 - a fully answered contract is exit 0, and the receipt states what it does NOT prove', () => {
|
|
100
|
+
const r = check({ [CONTRACT]: contract() });
|
|
101
|
+
assert.equal(r.code, 0, r.out);
|
|
102
|
+
assert.match(r.out, /все 3 классов отказа закрыты/, r.out);
|
|
103
|
+
assert.match(r.out, /event\.id/, 'the repeat key must be named back: ' + r.out);
|
|
104
|
+
// Without this line a reader upgrades a declaration check into a proof that the handler is safe.
|
|
105
|
+
assert.match(r.out, /Ограничение:/, r.out);
|
|
106
|
+
assert.match(r.out, /дважды/, r.out);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('P2 - no contract at all is exit 2, and the hint refuses the wrong reading', () => {
|
|
110
|
+
const r = check({});
|
|
111
|
+
assert.equal(r.code, 2, r.out);
|
|
112
|
+
assert.match(r.out, /нет файла docs\/webhook-contract\.md/, r.out);
|
|
113
|
+
// «no file» means the QUESTION was never asked — never «there are no webhooks».
|
|
114
|
+
assert.match(r.out, /НЕ ЗАДАВАЛСЯ/, r.out);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('P3 - «входящие вебхуки: нет» is a legitimate answer and exits 2, never 0', () => {
|
|
118
|
+
const r = check({ [CONTRACT]: contract({ incoming: 'нет' }) });
|
|
119
|
+
assert.equal(r.code, 2, 'a product nobody calls into has nothing to check: ' + r.out);
|
|
120
|
+
assert.match(r.out, /законный ответ/, r.out);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('P4 - a missing or unrecognised declaration is exit 2 with the closed set printed', () => {
|
|
124
|
+
const absent = check({ [CONTRACT]: contract({ incoming: null }) });
|
|
125
|
+
assert.equal(absent.code, 2, absent.out);
|
|
126
|
+
assert.match(absent.out, /Входящие вебхуки/, absent.out);
|
|
127
|
+
for (const bad of ['возможно', '', 'yes']) {
|
|
128
|
+
const r = check({ [CONTRACT]: contract({ incoming: bad }) });
|
|
129
|
+
assert.equal(r.code, 2, '«' + bad + '» must not be read as a verdict: ' + r.out);
|
|
130
|
+
assert.match(r.out, /допустимы ровно/, r.out);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('P5 - «НЕ ВЫПОЛНЕНА» with a closed-list reason is honest UNKNOWN, exit 2', () => {
|
|
135
|
+
const ok = check({ [CONTRACT]: contract({ run: 'НЕ ВЫПОЛНЕНА', reason: 'no-test-harness' }) });
|
|
136
|
+
assert.equal(ok.code, 2, ok.out);
|
|
137
|
+
assert.match(ok.out, /НЕ ВОСПРОИЗВОДИЛАСЬ, причина: no-test-harness/, ok.out);
|
|
138
|
+
assert.match(ok.out, /честное «неизвестно»/, ok.out);
|
|
139
|
+
|
|
140
|
+
// A missing reason and a free-text reason are DIFFERENT mistakes with different repairs.
|
|
141
|
+
const none = check({ [CONTRACT]: contract({ run: 'НЕ ВЫПОЛНЕНА', reason: null }) });
|
|
142
|
+
assert.equal(none.code, 2, none.out);
|
|
143
|
+
assert.match(none.out, /без строки `\*\*Причина:\*\*`/, none.out);
|
|
144
|
+
|
|
145
|
+
for (const bad of ['было некогда', 'no-providers', 'no-provider и out-of-scope']) {
|
|
146
|
+
const r = check({ [CONTRACT]: contract({ run: 'НЕ ВЫПОЛНЕНА', reason: bad }) });
|
|
147
|
+
assert.equal(r.code, 2, r.out);
|
|
148
|
+
assert.match(r.out, /не из закрытого списка/, '«' + bad + '»: ' + r.out);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('P6 - LOAD-BEARING: check-then-insert dedup is a PROVEN defect, exit 1', () => {
|
|
153
|
+
const r = check({ [CONTRACT]: contract({ exclusion: 'проверка-перед-вставкой' }) });
|
|
154
|
+
assert.equal(r.code, 1, 'a read-then-write dedup must not read as verified: ' + r.out);
|
|
155
|
+
// The reason must be the MECHANISM, not a scolding: two retries arrive CONCURRENTLY.
|
|
156
|
+
assert.match(r.out, /ОДНОВРЕМЕННО/, r.out);
|
|
157
|
+
assert.match(r.out, /уникальный индекс/, r.out);
|
|
158
|
+
|
|
159
|
+
for (const good of ['уникальный-индекс', 'атомарная-вставка']) {
|
|
160
|
+
assert.equal(check({ [CONTRACT]: contract({ exclusion: good }) }).code, 0, good);
|
|
161
|
+
}
|
|
162
|
+
// An unmapped spelling is refused, never silently read as one of the two atomic answers.
|
|
163
|
+
const junk = check({ [CONTRACT]: contract({ exclusion: 'блокировка' }) });
|
|
164
|
+
assert.equal(junk.code, 2, junk.out);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('P7 - LOAD-BEARING: a key the RECEIVER invents can never recognise a repeat, exit 1', () => {
|
|
168
|
+
const r = check({ [CONTRACT]: contract({ source: 'сгенерирован-получателем' }) });
|
|
169
|
+
assert.equal(r.code, 1, r.out);
|
|
170
|
+
assert.match(r.out, /различно на каждой доставке/, r.out);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('P8 - a key that cannot distinguish repeat from new is exit 1, in BOTH directions', () => {
|
|
174
|
+
// Direction one: the value changes per delivery, so the repeat looks new — two commissions.
|
|
175
|
+
for (const bad of ['event.created', 'received_at', 'Stripe-Signature timestamp']) {
|
|
176
|
+
const r = check({ [CONTRACT]: contract({ key: bad }) });
|
|
177
|
+
assert.equal(r.code, 1, '«' + bad + '» must not pass as a repeat key: ' + r.out);
|
|
178
|
+
assert.match(r.out, /меняется при КАЖДОЙ доставке/, r.out);
|
|
179
|
+
}
|
|
180
|
+
// Direction two: the value is shared by two GENUINE events — the second real payment is eaten.
|
|
181
|
+
for (const bad of ['amount', 'сумма платежа']) {
|
|
182
|
+
const r = check({ [CONTRACT]: contract({ key: bad }) });
|
|
183
|
+
assert.equal(r.code, 1, '«' + bad + '» must not pass as a repeat key: ' + r.out);
|
|
184
|
+
assert.match(r.out, /ДВУХ настоящих событий/, r.out);
|
|
185
|
+
}
|
|
186
|
+
// BOUNDED: the blacklist is short on purpose — a check that refuses correct keys gets switched
|
|
187
|
+
// off. Real sender-side identities must keep passing.
|
|
188
|
+
for (const good of ['event.id', 'payment_intent.id', 'data.object.id', 'Idempotency-Key']) {
|
|
189
|
+
assert.equal(check({ [CONTRACT]: contract({ key: good }) }).code, 0, good);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('P9 - a store INSIDE the process is exit 1, and Redis is not collateral damage', () => {
|
|
194
|
+
for (const bad of ['множество в памяти процесса', 'глобальный словарь handled_ids']) {
|
|
195
|
+
const r = check({ [CONTRACT]: contract({ store: bad }) });
|
|
196
|
+
assert.equal(r.code, 1, '«' + bad + '» must not pass as a store: ' + r.out);
|
|
197
|
+
assert.match(r.out, /невидим второй реплике/, r.out);
|
|
198
|
+
}
|
|
199
|
+
// BOUNDED: Redis IS an in-memory store and IS a correct answer — it is shared and it outlives a
|
|
200
|
+
// request. Refusing it on the phrase «in-memory» would refuse the recommended configuration.
|
|
201
|
+
for (const good of ['ключ webhook:{id} в Redis (in-memory), TTL 30 дней',
|
|
202
|
+
'таблица webhook_events, колонка event_id']) {
|
|
203
|
+
assert.equal(check({ [CONTRACT]: contract({ store: good }) }).code, 0, good);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('P10 - the three signature defects each fire on their own, exit 1', () => {
|
|
208
|
+
const reparsed = check({ [CONTRACT]: contract({ signedOver: 'разобранное-тело' }) });
|
|
209
|
+
assert.equal(reparsed.code, 1, reparsed.out);
|
|
210
|
+
assert.match(reparsed.out, /не совпадёт НИКОГДА/, reparsed.out);
|
|
211
|
+
|
|
212
|
+
const after = check({ [CONTRACT]: contract({ signWhen: 'после-разбора' }) });
|
|
213
|
+
assert.equal(after.code, 1, after.out);
|
|
214
|
+
assert.match(after.out, /прислал кто угодно/, after.out);
|
|
215
|
+
|
|
216
|
+
const naive = check({ [CONTRACT]: contract({ comparison: 'обычное' }) });
|
|
217
|
+
assert.equal(naive.code, 1, naive.out);
|
|
218
|
+
assert.match(naive.out, /префикс угадан/, naive.out);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('P11 - the freshness window is bounded on BOTH sides, and unparseable is exit 2', () => {
|
|
222
|
+
for (const bad of ['нет', '0', '-5']) {
|
|
223
|
+
const r = check({ [CONTRACT]: contract({ window: bad }) });
|
|
224
|
+
assert.equal(r.code, 1, '«' + bad + '» must be a proven defect: ' + r.out);
|
|
225
|
+
}
|
|
226
|
+
const wide = check({ [CONTRACT]: contract({ window: '86400' }) });
|
|
227
|
+
assert.equal(wide.code, 1, wide.out);
|
|
228
|
+
assert.match(wide.out, /шире часа/, wide.out);
|
|
229
|
+
|
|
230
|
+
// BOUNDED: everything up to an hour passes untouched, or the check refuses real configurations.
|
|
231
|
+
for (const good of ['300', '900', '3600', '300 с']) {
|
|
232
|
+
assert.equal(check({ [CONTRACT]: contract({ window: good }) }).code, 0, good);
|
|
233
|
+
}
|
|
234
|
+
// Unparseable is «could not check», not «violated» — different facts, different repairs.
|
|
235
|
+
const junk = check({ [CONTRACT]: contract({ window: 'пять минут' }) });
|
|
236
|
+
assert.equal(junk.code, 2, junk.out);
|
|
237
|
+
assert.match(junk.out, /не разбирается как число/, junk.out);
|
|
238
|
+
assert.equal(check({ [CONTRACT]: contract({ window: null }) }).code, 2);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('P12 - «the sender guarantees order» is a PROVEN false belief, exit 1', () => {
|
|
242
|
+
const r = check({ [CONTRACT]: contract({ order: 'гарантирован-отправителем' }) });
|
|
243
|
+
assert.equal(r.code, 1, r.out);
|
|
244
|
+
assert.match(r.out, /порядок НЕ гарантирован/, r.out);
|
|
245
|
+
// The mechanism must tie it to the SAME retry that produces duplicates, or a reader treats
|
|
246
|
+
// ordering as an unrelated nicety and fixes only half the failure.
|
|
247
|
+
assert.match(r.out, /тот же ретрай/, r.out);
|
|
248
|
+
|
|
249
|
+
// Both legitimate answers stay reachable.
|
|
250
|
+
for (const good of ['версия-из-события', 'перестановочен']) {
|
|
251
|
+
assert.equal(check({ [CONTRACT]: contract({ order: good }) }).code, 0, good);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('P13 - a class left out of the table is a PROVEN omission and is NAMED', () => {
|
|
256
|
+
for (const dropped of CLASSES) {
|
|
257
|
+
const rows = CLASSES.filter((n) => n !== dropped)
|
|
258
|
+
.map((name) => ({ name, status: 'ЗАКРЫТ', evidence: TESTS[name] }));
|
|
259
|
+
const r = check({ [CONTRACT]: contract({ rows }) });
|
|
260
|
+
assert.equal(r.code, 1, dropped + ' silently missing: ' + r.out);
|
|
261
|
+
assert.match(r.out, new RegExp(dropped), 'the missing class must be named: ' + r.out);
|
|
262
|
+
}
|
|
263
|
+
const empty = check({ [CONTRACT]: contract({ rows: [] }) });
|
|
264
|
+
assert.equal(empty.code, 1, empty.out);
|
|
265
|
+
assert.match(empty.out, /3 из 3/, empty.out);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('P14 - «НЕ ЗАКРЫТ» under a declared ВЫПОЛНЕНА is a contradiction, exit 1', () => {
|
|
269
|
+
const rows = CLASSES.map((name, i) => ({
|
|
270
|
+
name, status: i === 1 ? 'НЕ ЗАКРЫТ' : 'ЗАКРЫТ', evidence: TESTS[name],
|
|
271
|
+
}));
|
|
272
|
+
const r = check({ [CONTRACT]: contract({ rows }) });
|
|
273
|
+
assert.equal(r.code, 1, r.out);
|
|
274
|
+
assert.match(r.out, /повтор/, r.out);
|
|
275
|
+
assert.match(r.out, /ложная квитанция/, r.out);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('P15 - a proof that names no test file is exit 1', () => {
|
|
279
|
+
const rows = CLASSES.map((name) => ({ name, status: 'ЗАКРЫТ', evidence: 'проверено вручную' }));
|
|
280
|
+
const r = check({ [CONTRACT]: contract({ rows }) });
|
|
281
|
+
assert.equal(r.code, 1, r.out);
|
|
282
|
+
assert.match(r.out, /не называет файл теста/, r.out);
|
|
283
|
+
for (const name of CLASSES) assert.match(r.out, new RegExp(name), r.out);
|
|
284
|
+
|
|
285
|
+
// A design document is not a test, and the shipped template's placeholder is not a proof.
|
|
286
|
+
const doc = check({
|
|
287
|
+
[CONTRACT]: contract({ rows: CLASSES.map((name) => ({ name, status: 'ЗАКРЫТ', evidence: 'docs/design.md' })) }),
|
|
288
|
+
});
|
|
289
|
+
assert.equal(doc.code, 1, 'a document must not close a class: ' + doc.out);
|
|
290
|
+
const tpl = check({
|
|
291
|
+
[CONTRACT]: contract({ rows: CLASSES.map((name) => ({ name, status: 'ЗАКРЫТ', evidence: '[файл теста]' })) }),
|
|
292
|
+
});
|
|
293
|
+
assert.equal(tpl.code, 1, 'an untouched template must not pass: ' + tpl.out);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('P16 - LOAD-BEARING: a named test file that does not exist is exit 1, not exit 2', () => {
|
|
297
|
+
// We LOOKED. An absent file is a proven loss, not an unknown — that is the difference between
|
|
298
|
+
// this and a proof written as prose, and it is the strongest bite a declaration checker has.
|
|
299
|
+
const r = check({ [CONTRACT]: contract() }, { withTests: false });
|
|
300
|
+
assert.equal(r.code, 1, r.out);
|
|
301
|
+
assert.match(r.out, /названный файл теста не существует/, r.out);
|
|
302
|
+
assert.match(r.out, /tests\/webhooks\/test_redelivery\.py/, r.out);
|
|
303
|
+
|
|
304
|
+
// Only the offending class is named when the others are real.
|
|
305
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-webhook-one-')));
|
|
306
|
+
try {
|
|
307
|
+
fs.mkdirSync(path.join(dir, 'tests', 'webhooks'), { recursive: true });
|
|
308
|
+
for (const [name, rel] of Object.entries(TESTS)) {
|
|
309
|
+
if (name !== 'повтор') fs.writeFileSync(path.join(dir, rel), '# fixture\n');
|
|
310
|
+
}
|
|
311
|
+
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
|
|
312
|
+
fs.writeFileSync(path.join(dir, CONTRACT), contract());
|
|
313
|
+
const one = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8' });
|
|
314
|
+
assert.equal(one.status, 1, one.stdout);
|
|
315
|
+
assert.match(one.stdout, /повтор →/, one.stdout);
|
|
316
|
+
assert.doesNotMatch(one.stdout, /• подделка →/, one.stdout);
|
|
317
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('P17 - a malformed table is exit 2, never a pass and never a proven loss', () => {
|
|
321
|
+
const bogus = check({
|
|
322
|
+
[CONTRACT]: contract({ rows: CLASSES.map((name) => ({ name, status: 'ок', evidence: TESTS[name] })) }),
|
|
323
|
+
});
|
|
324
|
+
assert.equal(bogus.code, 2, bogus.out);
|
|
325
|
+
assert.match(bogus.out, /нераспознанный статус класса/, bogus.out);
|
|
326
|
+
|
|
327
|
+
const dupe = check({
|
|
328
|
+
[CONTRACT]: contract({
|
|
329
|
+
rows: [...CLASSES, 'повтор'].map((name) => ({ name, status: 'ЗАКРЫТ', evidence: TESTS[name] })),
|
|
330
|
+
}),
|
|
331
|
+
});
|
|
332
|
+
assert.equal(dupe.code, 2, dupe.out);
|
|
333
|
+
assert.match(dupe.out, /повторяются строки/, dupe.out);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('P18 - an unfilled required field is «not filled in», exit 2', () => {
|
|
337
|
+
for (const field of ['sender', 'key', 'store']) {
|
|
338
|
+
const missing = check({ [CONTRACT]: contract({ [field]: null }) });
|
|
339
|
+
assert.equal(missing.code, 2, field + ' missing: ' + missing.out);
|
|
340
|
+
const dash = check({ [CONTRACT]: contract({ [field]: '—' }) });
|
|
341
|
+
assert.equal(dash.code, 2, field + ' dash: ' + dash.out);
|
|
342
|
+
const tplRow = check({ [CONTRACT]: contract({ [field]: '[заполните]' }) });
|
|
343
|
+
assert.equal(tplRow.code, 2, field + ' template: ' + tplRow.out);
|
|
344
|
+
}
|
|
345
|
+
// «Сделаем идемпотентно» is an intention, not a key — but it IS filled in, so it is not this
|
|
346
|
+
// check's business; the closed-set and blacklist checks below it are. What matters here is only
|
|
347
|
+
// that an EMPTY field never reads as an answered one.
|
|
348
|
+
assert.equal(check({ [CONTRACT]: contract({ source: null }) }).code, 2);
|
|
349
|
+
assert.equal(check({ [CONTRACT]: contract({ order: null }) }).code, 2);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('P19 - cannot-check is NEVER clean: a bad path exits 2, not 0', () => {
|
|
353
|
+
const r = spawnSync(process.execPath, [CHECK, path.join(os.tmpdir(), 'p-rep-webhook-absent-dir')],
|
|
354
|
+
{ encoding: 'utf8' });
|
|
355
|
+
assert.equal(r.status, 2, r.stdout + r.stderr);
|
|
356
|
+
assert.match(r.stdout, /проверка НЕ выполнена/, r.stdout);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The rule's FORCE, kept apart from its vocabulary.
|
|
362
|
+
*
|
|
363
|
+
* MEASURED on this package 2026-09-01 (commit 37916bd3): twelve keyword predicates over the swarm
|
|
364
|
+
* receipt contract were fully satisfied by a block retitled "Coordination note" whose imperative had
|
|
365
|
+
* been softened to "Where convenient" — the registered mutation SURVIVED. These predicates are that
|
|
366
|
+
* lesson applied here: the headings must still refuse, the obligations must still read as duties,
|
|
367
|
+
* the closed set must still be mandatory, and the ORDERING half must still be inside the rule rather
|
|
368
|
+
* than deferred to a backlog entry nobody will open.
|
|
369
|
+
*/
|
|
370
|
+
function ruleForceProblems(text) {
|
|
371
|
+
const problems = [];
|
|
372
|
+
if (!/^## Одна доставка — не одно событие$/m.test(text)) {
|
|
373
|
+
problems.push('the heading no longer refuses the one-delivery reading');
|
|
374
|
+
}
|
|
375
|
+
if (!/Платёжные системы доставляют одно событие НЕСКОЛЬКО РАЗ по построению/.test(text)) {
|
|
376
|
+
problems.push('at-least-once is presented as a possibility, not as the sender\'s contract');
|
|
377
|
+
}
|
|
378
|
+
if (!/Ключ повторности ОБЯЗАН быть НАЗВАН/.test(text)) {
|
|
379
|
+
problems.push('the repeat key is advisory, not a named obligation');
|
|
380
|
+
}
|
|
381
|
+
// Naming the key without its STORE and its EXCLUSION closes nothing: «назовите поле» alone still
|
|
382
|
+
// permits a per-process dict and a check-then-insert race.
|
|
383
|
+
for (const [word, message] of [['ПОЛЕ', 'the key field is not required'],
|
|
384
|
+
['МЕСТО', 'the storage location is not required'],
|
|
385
|
+
['МЕХАНИЗМ', 'the exclusion mechanism is not required']]) {
|
|
386
|
+
if (!new RegExp('\\*\\*' + word + '\\*\\*').test(text)) problems.push(message);
|
|
387
|
+
}
|
|
388
|
+
if (!/ОБЯЗАН быть атомарным/.test(text)) problems.push('the exclusion may be a read-then-write');
|
|
389
|
+
if (!/^## Подпись проверяется ПЕРВОЙ$/m.test(text)) {
|
|
390
|
+
problems.push('signature verification is no longer ordered first');
|
|
391
|
+
}
|
|
392
|
+
if (!/Четыре свойства\s*\nОБЯЗАТЕЛЬНЫ вместе|Четыре свойства ОБЯЗАТЕЛЬНЫ вместе/.test(text)) {
|
|
393
|
+
problems.push('the four signature properties are a menu, not a conjunction');
|
|
394
|
+
}
|
|
395
|
+
if (!/^## Порядок доставки не гарантирован — и это ТА ЖЕ причина$/m.test(text)) {
|
|
396
|
+
problems.push('the ordering half is no longer part of this rule');
|
|
397
|
+
}
|
|
398
|
+
if (!/Это часть правила, а не отдельная запись бэклога/.test(text)) {
|
|
399
|
+
problems.push('the decision to keep ordering here is unrecorded and will be re-litigated');
|
|
400
|
+
}
|
|
401
|
+
if (!/Набор ЗАКРЫТЫЙ и ОБЯЗАТЕЛЬНЫЙ/.test(text)) {
|
|
402
|
+
problems.push('the three classes are a menu, not a mandatory set');
|
|
403
|
+
}
|
|
404
|
+
// Each class must arrive with the symptom the OWNER sees — a class named without its признак is a
|
|
405
|
+
// label, and a label cannot be recognised in the field.
|
|
406
|
+
for (const [cls, sign] of [['подделка', /кабинете отправителя/],
|
|
407
|
+
['повтор', /ДВЕ комиссии за один платёж/], ['перестановка', /«откатился» сам/]]) {
|
|
408
|
+
if (!new RegExp('`' + cls + '`').test(text)) problems.push(cls + ' is not named');
|
|
409
|
+
if (!sign.test(text)) problems.push(cls + ' carries no owner-visible symptom');
|
|
410
|
+
}
|
|
411
|
+
if (!/Слой 3–4/.test(text) || !/НЕ МОЖЕТ по названной причине/.test(text)) {
|
|
412
|
+
problems.push('the layer marking hides what stays judgment');
|
|
413
|
+
}
|
|
414
|
+
if (!/Код `2` никогда не значит «всё в порядке»/.test(text)) {
|
|
415
|
+
problems.push('exit 2 may be read as clean');
|
|
416
|
+
}
|
|
417
|
+
return problems;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
describe('the rule still mandates, and the pipeline still calls the check', () => {
|
|
421
|
+
test('P20 - the shipped rule satisfies every force predicate', () => {
|
|
422
|
+
assert.deepEqual(ruleForceProblems(read('templates/.claude/rules/incoming-webhooks.md')), []);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('P21 - MUTATION: a polite rephrasing must turn this suite red', () => {
|
|
426
|
+
const source = read('templates/.claude/rules/incoming-webhooks.md');
|
|
427
|
+
|
|
428
|
+
// Retitle only. Every keyword in the file survives; only the refusal in the heading is gone.
|
|
429
|
+
const retitled = source.replace('## Одна доставка — не одно событие',
|
|
430
|
+
'## Замечание о повторных доставках');
|
|
431
|
+
assert.notEqual(retitled, source, 'mutation fixture did not apply — the heading moved');
|
|
432
|
+
assert.deepEqual(ruleForceProblems(retitled),
|
|
433
|
+
['the heading no longer refuses the one-delivery reading'],
|
|
434
|
+
'a retitled block must fire EXACTLY the heading predicate and nothing else');
|
|
435
|
+
|
|
436
|
+
// Soften «ОБЯЗАН быть НАЗВАН» to a wish. This is the mutation that survived on the swarm
|
|
437
|
+
// contract: every keyword stays, only the duty is gone.
|
|
438
|
+
const softened = source.replace('**Ключ повторности ОБЯЗАН быть НАЗВАН, а не подразумеваться.**',
|
|
439
|
+
'**Ключ повторности желательно назвать.**');
|
|
440
|
+
assert.notEqual(softened, source, 'mutation fixture did not apply — the sentence moved');
|
|
441
|
+
assert.deepEqual(ruleForceProblems(softened),
|
|
442
|
+
['the repeat key is advisory, not a named obligation'],
|
|
443
|
+
'softening the duty must fire EXACTLY the mandate predicate');
|
|
444
|
+
|
|
445
|
+
// Downgrade the atomicity requirement — the exact defect P6 refuses at runtime.
|
|
446
|
+
const racy = source.replace('ОБЯЗАН быть атомарным', 'лучше сделать атомарным');
|
|
447
|
+
assert.deepEqual(ruleForceProblems(racy), ['the exclusion may be a read-then-write'],
|
|
448
|
+
'a downgraded exclusion must fire EXACTLY its own predicate');
|
|
449
|
+
|
|
450
|
+
// Downgrade the closed set to a suggestion.
|
|
451
|
+
const optional = source.replace('Набор ЗАКРЫТЫЙ и ОБЯЗАТЕЛЬНЫЙ', 'Набор рекомендуемый');
|
|
452
|
+
assert.deepEqual(ruleForceProblems(optional),
|
|
453
|
+
['the three classes are a menu, not a mandatory set']);
|
|
454
|
+
|
|
455
|
+
// Move the ordering half out to a backlog entry — the split this rule deliberately refuses.
|
|
456
|
+
const split = source.replace('## Порядок доставки не гарантирован — и это ТА ЖЕ причина',
|
|
457
|
+
'## Порядок доставки (вынесено в бэклог)');
|
|
458
|
+
assert.deepEqual(ruleForceProblems(split),
|
|
459
|
+
['the ordering half is no longer part of this rule']);
|
|
460
|
+
|
|
461
|
+
// And deleting the layer honesty must be observable on its own.
|
|
462
|
+
const dishonest = source.replace('НЕ МОЖЕТ по названной причине', 'пока не сделана');
|
|
463
|
+
assert.deepEqual(ruleForceProblems(dishonest), ['the layer marking hides what stays judgment']);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('P22 - Phase 2 of /replicate calls the check as an acceptance criterion', () => {
|
|
467
|
+
const cmd = read('templates/.claude/commands/replicate.md');
|
|
468
|
+
assert.match(cmd, /node \.claude\/hooks\/check-webhook-contract\.cjs \./,
|
|
469
|
+
'the pipeline must actually invoke it, not merely mention the rule');
|
|
470
|
+
assert.match(cmd, /\.claude\/rules\/incoming-webhooks\.md/, 'the rule must be reachable from the seam');
|
|
471
|
+
// Exit 2 must be spelled out at the seam too, or the operator reads silence as success.
|
|
472
|
+
const seam = cmd.slice(cmd.indexOf('check-webhook-contract'));
|
|
473
|
+
assert.match(seam.slice(0, 600), /это НЕ «в порядке»/, seam.slice(0, 600));
|
|
474
|
+
|
|
475
|
+
const rule = read('templates/.claude/rules/replicate-pipeline.md');
|
|
476
|
+
assert.match(rule, /check-webhook-contract\.cjs/, 'the hook inventory must list it');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('P23 - it is a hooks component wired to NO event, and every counter agrees', () => {
|
|
480
|
+
const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
|
|
481
|
+
assert.ok(COMPONENTS.hooks.items['check-webhook-contract'],
|
|
482
|
+
'it must be registered, or init/doctor/verify will not know it');
|
|
483
|
+
const settings = read('templates/.claude/settings.json');
|
|
484
|
+
assert.ok(!settings.includes('check-webhook-contract'),
|
|
485
|
+
'it must not be wired to an event: this package\'s hooks are non-blocking by contract, so a '
|
|
486
|
+
+ 'hook could only print — it could never refuse anything');
|
|
487
|
+
|
|
488
|
+
const statusline = read('templates/.claude/hooks/statusline.cjs');
|
|
489
|
+
const hooks = statusline.match(/hooksExpected:\s*(\d+)/);
|
|
490
|
+
assert.ok(hooks, 'statusline must declare hooksExpected');
|
|
491
|
+
assert.equal(Number(hooks[1]), Object.keys(COMPONENTS.hooks.items).length,
|
|
492
|
+
'the status line would report a phantom missing hook: ' + hooks[1]);
|
|
493
|
+
const onDisk = fs.readdirSync(path.join(TPL, 'hooks')).filter((f) => f.endsWith('.cjs'));
|
|
494
|
+
assert.equal(onDisk.length, Object.keys(COMPONENTS.hooks.items).length,
|
|
495
|
+
'the shipped directory and the declared contract disagree: ' + onDisk.join(', '));
|
|
496
|
+
|
|
497
|
+
// The rule counter has the same three-way agreement, and this change moved it too.
|
|
498
|
+
const rules = statusline.match(/rulesExpected:\s*(\d+)/);
|
|
499
|
+
assert.ok(rules, 'statusline must declare rulesExpected');
|
|
500
|
+
const registry = JSON.parse(read('src/rule-components.json'));
|
|
501
|
+
assert.ok(registry['incoming-webhooks'], 'the rule must be in the canonical registry');
|
|
502
|
+
assert.equal(Number(rules[1]), Object.keys(registry).length,
|
|
503
|
+
'the status line would report a phantom missing rule: ' + rules[1]);
|
|
504
|
+
assert.equal(Number(rules[1]), Object.keys(COMPONENTS.rules.items).length);
|
|
505
|
+
const ruleFiles = fs.readdirSync(path.join(TPL, 'rules')).filter((f) => f.endsWith('.md'));
|
|
506
|
+
assert.equal(ruleFiles.length, Object.keys(registry).length,
|
|
507
|
+
'the shipped rules directory and the registry disagree: ' + ruleFiles.join(', '));
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('P24 - the mutation registry carries this feature\'s guards', () => {
|
|
511
|
+
const registry = JSON.parse(read('test/mutation-registry.json'));
|
|
512
|
+
const expected = {
|
|
513
|
+
'webhook-cannot-check-is-not-clean': 'templates/.claude/hooks/check-webhook-contract.cjs',
|
|
514
|
+
'webhook-check-then-insert-is-a-race': 'templates/.claude/hooks/check-webhook-contract.cjs',
|
|
515
|
+
'webhook-named-test-file-must-exist': 'templates/.claude/hooks/check-webhook-contract.cjs',
|
|
516
|
+
'webhook-rule-repeat-key-must-be-named': 'templates/.claude/rules/incoming-webhooks.md',
|
|
517
|
+
'webhook-rule-ordering-stays-in-this-rule': 'templates/.claude/rules/incoming-webhooks.md',
|
|
518
|
+
};
|
|
519
|
+
for (const [id, file] of Object.entries(expected)) {
|
|
520
|
+
const entry = registry.entries.find((e) => e.id === id);
|
|
521
|
+
assert.ok(entry, 'missing targeted mutation id ' + id);
|
|
522
|
+
assert.equal(entry.file, file, id + ' mutates the wrong surface');
|
|
523
|
+
assert.ok(entry.property && entry.property.length > 40,
|
|
524
|
+
id + ' needs a behavioral property, not a cosmetic label');
|
|
525
|
+
assert.ok(entry.mutation && entry.mutation.find && entry.mutation.replace,
|
|
526
|
+
id + ' needs a real source mutation');
|
|
527
|
+
assert.ok(Number.isInteger(entry.minFailing) && entry.minFailing >= 1,
|
|
528
|
+
id + ' must require at least one failing test');
|
|
529
|
+
// The anchor must be UNIQUE in the file it mutates, or the registered mutation is not the one
|
|
530
|
+
// that would be applied.
|
|
531
|
+
const target = read(entry.file);
|
|
532
|
+
assert.equal(target.split(entry.mutation.find).length - 1, 1,
|
|
533
|
+
id + ': mutation anchor must occur exactly once');
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
});
|
|
@@ -100,8 +100,10 @@ describe('a database port is not published to the internet (PR-012)', () => {
|
|
|
100
100
|
'the limit needs its own heading');
|
|
101
101
|
assert.match(src, /не привязана ни к какому событию — её надо позвать/,
|
|
102
102
|
'a check nobody invokes guarantees nothing, and the rule must say so');
|
|
103
|
-
assert.match(src,
|
|
104
|
-
'and name
|
|
103
|
+
assert.match(src, /соседн(?:ий|его) проект[\s\S]{0,180}интернет/i,
|
|
104
|
+
'and name the catalog exposure blind spot, so nobody infers a machine-wide guarantee');
|
|
105
|
+
assert.match(src, /check-ports\.cjs --machine/,
|
|
106
|
+
'and provide the deliberate machine-snapshot check that answers the wider question');
|
|
105
107
|
assert.match(src, /проверка НЕ\s*\n?ВЫПОЛНЕНА/,
|
|
106
108
|
'and the third exit code must be documented where a reader will look for it');
|
|
107
109
|
});
|
|
@@ -138,7 +140,16 @@ describe('a database port is not published to the internet (PR-012)', () => {
|
|
|
138
140
|
// Historical documents are exempt BY NAME: a changelog entry describing what 1.4 shipped is a
|
|
139
141
|
// true statement about the past, and rewriting it would be falsifying a record.
|
|
140
142
|
const HISTORICAL = /changelog/i;
|
|
141
|
-
|
|
143
|
+
// The fifth alternative was added 2026-09-01 with the eleventh rule: README.md's component
|
|
144
|
+
// table writes `| **Rules** | 10 |`, and `Rules[ \t]+` cannot match it — `Rules` is followed by
|
|
145
|
+
// `**`, not whitespace. That site sat one number behind the registry and the scan said nothing,
|
|
146
|
+
// which is the same silent divergence this whole test exists to remove. MEASURED: with the
|
|
147
|
+
// fourth-alternative regex the sweep reported 22 stale sites and MISSED that row; with the fifth
|
|
148
|
+
// it reports it too.
|
|
149
|
+
const COUNT_CLAIM = /(\d+)\s+rules\b|(\d+)\s+правил|Rules[ \t]+(?:●|\()?(\d+)\)?|\*\*(\d+) rules\*\*|\*\*Rules\*\*\s*\|\s*(\d+)/g;
|
|
150
|
+
const headingThenList = '## Rules\n\n1. First workflow rule';
|
|
151
|
+
assert.deepEqual([...headingThenList.matchAll(COUNT_CLAIM)], [],
|
|
152
|
+
'`Rules` heading followed by item 1 is not a claim that the package ships one rule');
|
|
142
153
|
const scanned = [];
|
|
143
154
|
const walk = (dir) => {
|
|
144
155
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -169,13 +180,18 @@ describe('a database port is not published to the internet (PR-012)', () => {
|
|
|
169
180
|
const before = text.slice(0, hit.index);
|
|
170
181
|
const headings = before.match(/^#{1,3} .*$|^<h[1-3][^>]*>.*$/gm) || [];
|
|
171
182
|
const nearest = headings.length ? headings[headings.length - 1] : '';
|
|
172
|
-
const
|
|
173
|
-
|
|
183
|
+
const headingText = nearest
|
|
184
|
+
.replace(/^#{1,3}\s*/, '')
|
|
185
|
+
.replace(/^<h[1-3][^>]*>/i, '')
|
|
186
|
+
.replace(/<\/h[1-3]>$/i, '')
|
|
187
|
+
.trim();
|
|
188
|
+
const historicalSection = /^(?:changelog|release notes|version history|история версий|\[?v?\d+\.\d+)/i;
|
|
189
|
+
if (historicalSection.test(headingText)) continue;
|
|
174
190
|
// and an explicit past-tense marker on the line itself
|
|
175
191
|
const lineStart = before.lastIndexOf('\n') + 1;
|
|
176
192
|
const line = text.slice(lineStart, text.indexOf('\n', hit.index));
|
|
177
193
|
if (/initial published version|первая опубликованная/i.test(line)) continue;
|
|
178
|
-
const claimed = Number(hit[1] || hit[2] || hit[3] || hit[4]);
|
|
194
|
+
const claimed = Number(hit[1] || hit[2] || hit[3] || hit[4] || hit[5]);
|
|
179
195
|
if (claimed !== n) {
|
|
180
196
|
wrong.push(path.relative(PKG_DIR, file) + ': "' + hit[0].trim() + '"');
|
|
181
197
|
}
|
|
@@ -213,4 +229,25 @@ describe('a database port is not published to the internet (PR-012)', () => {
|
|
|
213
229
|
assert.ok(files.filter((f) => f.endsWith('.cjs')).length >= 6,
|
|
214
230
|
'and the six shipped hooks must still be .cjs: ' + JSON.stringify(files));
|
|
215
231
|
});
|
|
232
|
+
|
|
233
|
+
test('P26 — docker-ports disclaimer names exposure and publish-address semantics', () => {
|
|
234
|
+
const src = read(RULE);
|
|
235
|
+
assert.match(src, /ЭКСПОЗИЦ/i,
|
|
236
|
+
'the consequence is security exposure, not an availability-only port conflict');
|
|
237
|
+
assert.match(src, /соседн(?:ий|его) проект/i,
|
|
238
|
+
'catalog scope must disclose the neighboring-project blind spot');
|
|
239
|
+
assert.match(src, /интернет/i, 'the consequence must name internet exposure');
|
|
240
|
+
assert.match(src, /check-ports\.cjs --machine/,
|
|
241
|
+
'the rule must give the deliberate machine-snapshot invocation');
|
|
242
|
+
assert.match(src, /HOST_IP:HOST_PORT:CONTAINER_PORT/,
|
|
243
|
+
'the three-part syntax is the address-bearing form');
|
|
244
|
+
assert.match(src, /\$\{DB_PORT:-11432\}:5432/,
|
|
245
|
+
'the common two-part form is explained directly');
|
|
246
|
+
assert.match(src, /по умолчанию[\s\S]{0,100}0\.0\.0[\s\S]{0,100}все интерфейсы/i,
|
|
247
|
+
'omitting HOST_IP defaults publication to all interfaces');
|
|
248
|
+
assert.match(src, /Redis\/Memcached[\s\S]{0,240}отсутствие[\s\S]{0,160}`1`/i,
|
|
249
|
+
'the locked catalog authentication consequence is documented');
|
|
250
|
+
assert.ok(!/конфликты с портами, уже занятыми другими контейнерами, она не ищет/.test(src),
|
|
251
|
+
'the former availability consequence must not survive as the catalog-scope disclaimer');
|
|
252
|
+
});
|
|
216
253
|
});
|