@dzhechkov/p-replicator 1.12.0 → 1.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +281 -65
- package/CHANGELOG.md +148 -1
- package/LICENSE +21 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +2 -2
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/05_architecture.md +1 -1
- package/README/eng/README.md +2 -1
- package/README/ru/01_quickstart.md +2 -2
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +8 -8
- package/README.md +150 -9
- package/bin/cli.js +0 -0
- package/package.json +10 -11
- package/sbom.json +604 -64
- package/scripts/check-pipeline-gaps.sh +510 -21
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +5 -1
- package/src/utils.js +33 -3
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +72 -6
- package/templates/.claude/commands/go.md +6 -8
- package/templates/.claude/commands/harvest.md +5 -7
- package/templates/.claude/commands/replicate.md +169 -44
- package/templates/.claude/commands/start.md +28 -7
- package/templates/.claude/hooks/capture-source-path.cjs +795 -0
- package/templates/.claude/hooks/check-canon.cjs +493 -0
- package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
- package/templates/.claude/hooks/check-external-deps.cjs +288 -0
- package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
- package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
- package/templates/.claude/hooks/check-job-contract.cjs +501 -0
- package/templates/.claude/hooks/check-look-origin.cjs +240 -0
- package/templates/.claude/hooks/check-look-trace.cjs +385 -0
- package/templates/.claude/hooks/check-metric-source.cjs +296 -0
- package/templates/.claude/hooks/check-model-cost.cjs +470 -0
- package/templates/.claude/hooks/check-ports.cjs +27 -6
- package/templates/.claude/hooks/check-review-contract.cjs +205 -0
- package/templates/.claude/hooks/check-source-version.cjs +312 -0
- package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
- package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +5 -6
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/long-running-job.md +73 -0
- package/templates/.claude/rules/model-call-cost.md +85 -0
- package/templates/.claude/rules/replicate-pipeline.md +122 -52
- package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
- package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
- package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
- package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
- package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
- package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
- package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
- package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
- package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
- package/templates/.claude/skills/requirements-validator/SKILL.md +18 -5
- package/templates/.claude/skills/requirements-validator/references/feature-report-contracts.md +67 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +15 -6
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +173 -725
- package/tests/e2e/feature-contour.test.js +176 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/01_specification.md +15 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/02_pseudocode.md +19 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/03_architecture.md +3 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/04_refinement.md +3 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/05_completion.md +7 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/review-report.md +11 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/validation-report.md +10 -0
- package/tests/fixtures/feature-contour/tests/demo.test.js +16 -0
- package/tests/snapshot/baseline.json +64 -40
- package/tests/unit/capture-source-path.test.js +492 -0
- package/tests/unit/check-canon.test.js +403 -0
- package/tests/unit/check-embed-contract.test.js +422 -0
- package/tests/unit/check-external-deps.test.js +363 -0
- package/tests/unit/check-file-ownership.test.js +388 -0
- package/tests/unit/check-handoff-manifest.test.js +410 -0
- package/tests/unit/check-job-contract.test.js +514 -0
- package/tests/unit/check-look-origin.test.js +180 -0
- package/tests/unit/check-look-trace.test.js +420 -0
- package/tests/unit/check-metric-source.test.js +325 -0
- package/tests/unit/check-model-cost.test.js +425 -0
- package/tests/unit/check-ports.test.js +46 -2
- package/tests/unit/check-review-contract.test.js +181 -0
- package/tests/unit/check-source-version.test.js +344 -0
- package/tests/unit/check-swarm-receipts.test.js +231 -0
- package/tests/unit/check-webhook-contract.test.js +536 -0
- package/tests/unit/db-port-rule.test.js +8 -2
- package/tests/unit/detection-ladder-registry.test.js +2 -2
- package/tests/unit/generator-swarm-contract.test.js +287 -0
- package/tests/unit/guard-honest-input-meta.test.js +64 -0
- package/tests/unit/honest-failure-rules.test.js +106 -9
- package/tests/unit/look-phase-contract.test.js +231 -0
- package/tests/unit/negative-conclusion-gate.test.js +300 -0
- package/tests/unit/quote-provenance.test.js +122 -0
- package/tests/unit/sync-templates-guard.test.js +46 -3
- package/tests/unit/traceability-completion-gate.test.js +267 -0
- package/tests/unit/utils.test.js +40 -3
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* check-job-contract.cjs — долгая работа: у неё есть ручка, три состояния и продолжение?
|
|
6
|
+
*
|
|
7
|
+
* NOT an event hook. Like `check-ports.cjs`, `check-look-trace.cjs`, `check-growth-trace.cjs`,
|
|
8
|
+
* `check-docs-complete.cjs`, `check-swarm-receipts.cjs` and `check-embed-contract.cjs`, it lives
|
|
9
|
+
* here because this directory already carries plain Node utilities; nothing registers it in
|
|
10
|
+
* settings.json. That is deliberate: this package's hooks are NON-BLOCKING by contract (pinned by
|
|
11
|
+
* tests/unit/hooks-project-anchored.test.js, which requires exit 0), so a hook could never refuse
|
|
12
|
+
* anything — it could only print. Invoke it:
|
|
13
|
+
*
|
|
14
|
+
* node .claude/hooks/check-job-contract.cjs [path-to-project]
|
|
15
|
+
*
|
|
16
|
+
* WHY IT EXISTS — the failure, before the technology.
|
|
17
|
+
*
|
|
18
|
+
* Some products do work that takes MINUTES, not milliseconds: transcribe an hour of audio and cut
|
|
19
|
+
* it, generate an image, render a video. A plain request-response over the web cannot carry that BY
|
|
20
|
+
* CONSTRUCTION, and the three ways it breaks are all the SAME confusion — NO ANSWER is not the same
|
|
21
|
+
* fact as STILL RUNNING, and a design that spells them identically cannot tell them apart later:
|
|
22
|
+
*
|
|
23
|
+
* 1. РАЗРЫВ an intermediary (proxy, load balancer, CDN, the browser itself) drops the
|
|
24
|
+
* request at its idle timeout. The work COMPLETED; the answer had nowhere to go.
|
|
25
|
+
* The user sees an error over money that was actually spent.
|
|
26
|
+
* 2. ПОВТОР-ЗАНОВО the client retries and the server STARTS AGAIN instead of continuing, so the
|
|
27
|
+
* external bill doubles per attempt. This is kin to webhook redelivery, but the
|
|
28
|
+
* mechanism differs: there the repeat arrives from OUTSIDE; here your own client
|
|
29
|
+
* initiates it. The neighbouring rule about INBOUND webhooks owns that half.
|
|
30
|
+
* 3. ТРЕТЬЯ-КОПИЯ the user cannot see any state, presses the button again, and a third copy of
|
|
31
|
+
* the same work starts.
|
|
32
|
+
*
|
|
33
|
+
* All three descend from one indistinguishability: silence is produced EQUALLY by a live job, a
|
|
34
|
+
* dead worker and a broken intermediary. Read silence as «running» and a dead job waits forever,
|
|
35
|
+
* a retry doubles the bill, and the user is told nothing.
|
|
36
|
+
*
|
|
37
|
+
* WHAT THIS FILE CAN AND CANNOT DECIDE — read before trusting exit 0.
|
|
38
|
+
*
|
|
39
|
+
* It reads a DECLARATION, `docs/long-job-contract.md`, and decides only what a declaration can
|
|
40
|
+
* settle: that the handle exists and is issued BEFORE the work rather than with its result, that
|
|
41
|
+
* the three states are all answered and are DIFFERENT to the user, that silence is declared UNKNOWN
|
|
42
|
+
* and not RUNNING, and that a repeat continues by a NAMED mechanism. It does not run a job, does not
|
|
43
|
+
* cut a connection and does not prove the server survives a real disconnect. That half is layer 3/4
|
|
44
|
+
* and the rule `.claude/rules/long-running-job.md` says so in the same words.
|
|
45
|
+
*
|
|
46
|
+
* THE EXACT FORM OF `docs/long-job-contract.md` — the rule delegates it here on purpose: this file
|
|
47
|
+
* is not part of the always-loaded corpus, so the long form costs nothing per run, while the rule
|
|
48
|
+
* keeps only the decision the reader must carry.
|
|
49
|
+
*
|
|
50
|
+
* **Долгие задачи:** да (да | нет — `нет` is a legitimate answer)
|
|
51
|
+
* **Идентификатор задачи:** job_id (a FIELD NAME, not a description)
|
|
52
|
+
* **Где живёт:** ответ `POST /api/clips` → поле `job_id`; чтение `GET /api/jobs/{job_id}`
|
|
53
|
+
* **Выдаётся:** до начала работы (до начала работы | после завершения)
|
|
54
|
+
* **Ответ на создание:** идентификатор (идентификатор | результат)
|
|
55
|
+
* **Предельное время задачи:** 15 мин (a number AND a unit)
|
|
56
|
+
* **Таймаут посредника:** 60 с (the SHORTEST timeout on the path to the client)
|
|
57
|
+
* **Молчание:** неизвестно (неизвестно | выполняется)
|
|
58
|
+
* **Продолжение при повторе:** идемпотентный-ключ
|
|
59
|
+
* **Проверка выполнена:** ВЫПОЛНЕНА (ВЫПОЛНЕНА | НЕ ВЫПОЛНЕНА)
|
|
60
|
+
* **Причина:** — (required when НЕ ВЫПОЛНЕНА; one of the closed REASONS below)
|
|
61
|
+
*
|
|
62
|
+
* ## Состояния
|
|
63
|
+
*
|
|
64
|
+
* | Состояние | Статус | Что видит пользователь | Доказательство |
|
|
65
|
+
* |---|---|---|---|
|
|
66
|
+
* | выполняется | ПРОВЕРЕН | «идёт нарезка, 2 из 7» | GET /api/jobs/j-42 → {"state":"running"}, job_id=j-42, 2026-09-01 |
|
|
67
|
+
* | успех | ПРОВЕРЕН | список готовых клипов со ссылками | GET /api/jobs/j-42 → {"state":"done"}, job_id=j-42, 2026-09-01 |
|
|
68
|
+
* | отказ | ПРОВЕРЕН | «нарезка не удалась: ffmpeg код 1» + кнопка «повторить» | GET /api/jobs/j-43 → {"state":"failed"}, job_id=j-43, 2026-09-01 |
|
|
69
|
+
*
|
|
70
|
+
* Exit codes — three, and the third is the point:
|
|
71
|
+
* 0 the handle is issued before the work, all three states are answered and DIFFER to the user,
|
|
72
|
+
* every proof names the identifier, and a repeat continues by a named mechanism
|
|
73
|
+
* 1 a defect is PROVEN and named: silence declared as «выполняется», an identifier issued only
|
|
74
|
+
* with the result, a repeat that restarts, a missing state, two states the user cannot tell
|
|
75
|
+
* apart, a `ПРОВЕРЕН` row whose proof does not name the identifier, or a synchronous answer to
|
|
76
|
+
* work that cannot fit inside the intermediary's window
|
|
77
|
+
* 2 THE CHECK DID NOT RUN — no contract, an unrecognised value, a duration without a unit, and
|
|
78
|
+
* the legitimate answers «нет долгих задач» / «проверка НЕ ВЫПОЛНЕНА, причина такая-то» /
|
|
79
|
+
* «работа укладывается в окно посредника, требовать нечего»
|
|
80
|
+
*
|
|
81
|
+
* TWO deliberate asymmetries, both named so they are not mistaken for oversights:
|
|
82
|
+
*
|
|
83
|
+
* A PROVEN defect OUTRANKS an unanswered question: the four single-comparison defects are decided
|
|
84
|
+
* BEFORE the state table is parsed, so a malformed table cannot hide a declared «молчание =
|
|
85
|
+
* выполняется». `1` beats `2`, exactly as in check-look-trace.cjs.
|
|
86
|
+
*
|
|
87
|
+
* The BOUNDED exception: answering synchronously is legal when the declared ceiling is strictly
|
|
88
|
+
* SHORTER than the intermediary's window — a three-second job behind a sixty-second proxy really
|
|
89
|
+
* does fit. Without that exception the check would be a permanent `1` for every product that has
|
|
90
|
+
* one slow-ish endpoint, and a check that refuses the correct configuration gets switched off.
|
|
91
|
+
* The escape it leaves is visible, not silent: an understated ceiling is a lie IN THE DECLARATION,
|
|
92
|
+
* and both numbers are printed next to the verdict every time.
|
|
93
|
+
*
|
|
94
|
+
* A checker that answers "clean" when it could not look converts an unknown into a reassurance —
|
|
95
|
+
* which for this feature is the exact charge-the-user-for-a-lost-result outage it exists to prevent.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
const fs = require('node:fs');
|
|
99
|
+
const path = require('node:path');
|
|
100
|
+
|
|
101
|
+
const CONTRACT = path.join('docs', 'long-job-contract.md');
|
|
102
|
+
|
|
103
|
+
/** Does the product run work that outlives a request at all? A CLOSED set — `нет` is legitimate and
|
|
104
|
+
* it exits 2, never 0: there is nothing to check, and «нечего проверять» must not be spelled the
|
|
105
|
+
* same way as «проверено». */
|
|
106
|
+
const LONG = { 'ДА': true, 'НЕТ': false };
|
|
107
|
+
|
|
108
|
+
/** Was the check performed? CLOSED, and the negative answer is honest, not a failure: CFG-I4 of
|
|
109
|
+
* `honest-configuration` — an unreachable truth yields UNKNOWN, never a plausible value. */
|
|
110
|
+
const RUN_STATUS = { 'ВЫПОЛНЕНА': 'done', 'НЕ ВЫПОЛНЕНА': 'not-done' };
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Why the check did not happen. CLOSED list — free text is not a reason here, because the entire
|
|
114
|
+
* value of the list is that each entry names a DIFFERENT repair:
|
|
115
|
+
* no-worker — start the background worker · not-deployed — deploy, then re-check
|
|
116
|
+
* no-long-run — no job long enough to observe · out-of-scope — decide and record the decision
|
|
117
|
+
*/
|
|
118
|
+
const REASONS = ['no-worker', 'not-deployed', 'no-long-run', 'out-of-scope'];
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* WHEN the client receives its handle. This is the whole of failure class 1 in one field: an
|
|
122
|
+
* identifier that arrives only WITH the result dies with the response that was cut, and the client
|
|
123
|
+
* is left with completed work it can never ask about again.
|
|
124
|
+
*/
|
|
125
|
+
const ISSUED = { 'ДО НАЧАЛА РАБОТЫ': 'before', 'ПОСЛЕ ЗАВЕРШЕНИЯ': 'after' };
|
|
126
|
+
|
|
127
|
+
/** What the create call returns. `результат` IS the synchronous request-response under another
|
|
128
|
+
* name, and it is bounded by the ceiling-vs-window comparison, not refused outright. */
|
|
129
|
+
const CREATE = { 'ИДЕНТИФИКАТОР': 'id', 'РЕЗУЛЬТАТ': 'result' };
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* How the SILENCE is read. The single most load-bearing field in the contract.
|
|
133
|
+
*
|
|
134
|
+
* A live job, a dead worker and a severed proxy are all silent; only a state READ tells them apart.
|
|
135
|
+
* Declaring silence as «выполняется» is not optimism, it is the erasure of the third state, and it
|
|
136
|
+
* is what makes a dead job wait forever while the user starts a second copy.
|
|
137
|
+
*/
|
|
138
|
+
const SILENCE = { 'НЕИЗВЕСТНО': 'unknown', 'ВЫПОЛНЯЕТСЯ': 'running' };
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What makes a repeat CONTINUE instead of starting over. CLOSED, each entry a different repair, and
|
|
142
|
+
* `нет` is a spellable answer precisely so the defect can be declared and then NAMED:
|
|
143
|
+
* идемпотентный-ключ — the same key from the client returns the SAME job, never a second one
|
|
144
|
+
* запись-в-хранилище — the job row is the source of truth; a repeat with the same id reads it
|
|
145
|
+
* аренда-исполнителя — a lease stops a second worker from picking up a job already taken
|
|
146
|
+
* нет — the repeat starts the work again: failure class 2, declared
|
|
147
|
+
*/
|
|
148
|
+
const RESUME = {
|
|
149
|
+
'ИДЕМПОТЕНТНЫЙ-КЛЮЧ': 'idempotency-key',
|
|
150
|
+
'ЗАПИСЬ-В-ХРАНИЛИЩЕ': 'stored-record',
|
|
151
|
+
'АРЕНДА-ИСПОЛНИТЕЛЯ': 'worker-lease',
|
|
152
|
+
'НЕТ': 'none',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The three states, as a CLOSED and MANDATORY set.
|
|
157
|
+
*
|
|
158
|
+
* Mandatory is the load-bearing half. TWO states are the defect this rule exists for: with only
|
|
159
|
+
* «идёт» and «готово», a failure has nowhere to be reported and is served to the user as eternal
|
|
160
|
+
* progress. Two of three answered is not an unknown — it is a PROVEN omission we can name.
|
|
161
|
+
*/
|
|
162
|
+
const STATES = ['выполняется', 'успех', 'отказ'];
|
|
163
|
+
|
|
164
|
+
/** Per-state verdict. CLOSED: an unmapped spelling is refused and the recognised ones printed. */
|
|
165
|
+
const STATE_STATUS = { 'ПРОВЕРЕН': 'checked', 'НЕ ПРОВЕРЕН': 'unchecked' };
|
|
166
|
+
|
|
167
|
+
/** Time units. A bare number is REFUSED: «60» is sixty seconds and sixty minutes written the same
|
|
168
|
+
* way, and the whole comparison this file makes is between two durations. */
|
|
169
|
+
const UNITS = {
|
|
170
|
+
'с': 1, 'сек': 1, 'секунда': 1, 'секунды': 1, 'секунд': 1, 's': 1, 'sec': 1,
|
|
171
|
+
'мин': 60, 'минута': 60, 'минуты': 60, 'минут': 60, 'm': 60, 'min': 60,
|
|
172
|
+
'ч': 3600, 'час': 3600, 'часа': 3600, 'часов': 3600, 'h': 3600,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
function say(s) { process.stdout.write(s + '\n'); }
|
|
176
|
+
|
|
177
|
+
/** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
|
|
178
|
+
function cannotCheck(reason, hint) {
|
|
179
|
+
say('⚠️ проверка НЕ выполнена: ' + reason);
|
|
180
|
+
if (hint) say(' ' + hint);
|
|
181
|
+
process.exit(2);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Exit 1 with the defect NAMED. A violation that cannot be named is a 2, not a 1. */
|
|
185
|
+
function proven(title, lines, tail) {
|
|
186
|
+
say('❌ ' + title);
|
|
187
|
+
for (const line of lines) say(' • ' + line);
|
|
188
|
+
if (tail) say(' ' + tail);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The value of a `**Label:** value` header line, or null when the label is absent entirely.
|
|
194
|
+
* An EMPTY value is returned as '' and is never collapsed into "absent" — those are different
|
|
195
|
+
* mistakes with different repairs (`honest-configuration` CFG-I2).
|
|
196
|
+
*/
|
|
197
|
+
function header(text, label) {
|
|
198
|
+
const re = new RegExp('^\\s*\\*\\*' + label + ':?\\*\\*\\s*:?(.*)$', 'im');
|
|
199
|
+
const m = re.exec(text);
|
|
200
|
+
return m ? m[1].trim().replace(/^[«"`]|[»"`]$/g, '').trim() : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A header value read against a CLOSED map, with both failure modes kept apart. */
|
|
204
|
+
function closedHeader(text, label, map, what) {
|
|
205
|
+
const raw = header(text, label);
|
|
206
|
+
if (raw === null) {
|
|
207
|
+
cannotCheck('в контракте нет строки `**' + label + ':**`',
|
|
208
|
+
what + ' — допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
209
|
+
}
|
|
210
|
+
const key = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
211
|
+
if (!Object.prototype.hasOwnProperty.call(map, key)) {
|
|
212
|
+
cannotCheck('нераспознанное значение `' + label + '`: ' + (key === '' ? '(пусто)' : key),
|
|
213
|
+
'допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
214
|
+
}
|
|
215
|
+
return map[key];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A duration in SECONDS, or null when it is not a number-plus-unit.
|
|
220
|
+
*
|
|
221
|
+
* The unit is mandatory and the reason is the comparison this file exists to make: the ceiling and
|
|
222
|
+
* the intermediary window are compared to each other, and a unitless number makes that comparison
|
|
223
|
+
* a coin toss with a printed verdict.
|
|
224
|
+
*/
|
|
225
|
+
function seconds(raw) {
|
|
226
|
+
const m = /^\s*(\d+(?:[.,]\d+)?)\s*([A-Za-zА-Яа-яЁё]+)\s*$/.exec(String(raw || ''));
|
|
227
|
+
if (!m) return null;
|
|
228
|
+
const unit = m[2].toLowerCase().replace(/ё/g, 'е');
|
|
229
|
+
if (!Object.prototype.hasOwnProperty.call(UNITS, unit)) return null;
|
|
230
|
+
return Number(m[1].replace(',', '.')) * UNITS[unit];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One required duration header, refused three ways: absent, empty, or not number-plus-unit. */
|
|
234
|
+
function requiredDuration(text, label, hint) {
|
|
235
|
+
const raw = header(text, label);
|
|
236
|
+
if (raw === null || raw === '') {
|
|
237
|
+
cannotCheck('в контракте нет строки `**' + label + ':**` (или она пуста)', hint);
|
|
238
|
+
}
|
|
239
|
+
const value = seconds(raw);
|
|
240
|
+
if (value === null || !Number.isFinite(value) || value <= 0) {
|
|
241
|
+
cannotCheck('`' + label + '` не разбирается как длительность: ' + raw,
|
|
242
|
+
'нужны число И единица, например `15 мин` или `60 с`; распознаются: '
|
|
243
|
+
+ [...new Set(Object.keys(UNITS))].join(' | ')
|
|
244
|
+
+ ' — голое число это две разные длительности, записанные одинаково');
|
|
245
|
+
}
|
|
246
|
+
return { seconds: value, raw };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Compare two user-visible descriptions the way a USER would: case and spacing do not separate
|
|
250
|
+
* two states, and neither does trailing punctuation. */
|
|
251
|
+
function normalize(s) {
|
|
252
|
+
return String(s || '').toLowerCase().replace(/[\s.,;:!?«»"'`()]+/g, ' ').trim();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The state table, as the contract records it.
|
|
257
|
+
*
|
|
258
|
+
* A row is a markdown table row whose FIRST cell is one of the three state names. The template ships
|
|
259
|
+
* an example row, so a cell that is still a bracketed placeholder is a TEMPLATE cell and is read as
|
|
260
|
+
* EMPTY — never as a filled-in one.
|
|
261
|
+
*/
|
|
262
|
+
function stateRows(text) {
|
|
263
|
+
const cell = (v) => (/^\[.*\]$/.test(v) ? '' : v);
|
|
264
|
+
const rows = [];
|
|
265
|
+
for (const raw of text.split('\n')) {
|
|
266
|
+
const line = raw.trim();
|
|
267
|
+
if (!line.startsWith('|')) continue;
|
|
268
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
269
|
+
const name = (cells[1] || '').toLowerCase();
|
|
270
|
+
if (!STATES.includes(name)) continue;
|
|
271
|
+
rows.push({
|
|
272
|
+
name,
|
|
273
|
+
status: (cells[2] || '').toUpperCase().replace(/\s+/g, ' ').trim(),
|
|
274
|
+
seen: cell(cells[3] || ''),
|
|
275
|
+
evidence: cell(cells[4] || ''),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return rows;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function main() {
|
|
282
|
+
const root = process.argv[2] || '.';
|
|
283
|
+
try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
|
|
284
|
+
catch { cannotCheck('путь не существует: ' + root); }
|
|
285
|
+
|
|
286
|
+
const abs = path.join(root, CONTRACT);
|
|
287
|
+
let text;
|
|
288
|
+
try {
|
|
289
|
+
if (!fs.statSync(abs).isFile()) cannotCheck(CONTRACT + ' существует, но это не файл');
|
|
290
|
+
text = fs.readFileSync(abs, 'utf-8');
|
|
291
|
+
} catch (e) {
|
|
292
|
+
if (e && e.code === 'ENOENT') {
|
|
293
|
+
cannotCheck('нет файла ' + CONTRACT,
|
|
294
|
+
'это значит, что вопрос о долгих задачах НЕ ЗАДАВАЛСЯ — а НЕ что их нет; '
|
|
295
|
+
+ 'продукт без долгой работы отвечает `**Долгие задачи:** нет`, и это законный ответ');
|
|
296
|
+
}
|
|
297
|
+
cannotCheck('не читается ' + CONTRACT + ': ' + ((e && e.message) || e));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 1. Is there long work at all? «нет» is legitimate and has nothing to check → 2.
|
|
301
|
+
const long = closedHeader(text, 'Долгие задачи', LONG,
|
|
302
|
+
'без этой строки «долгих задач нет» неотличимо от «про долгие задачи забыли»');
|
|
303
|
+
if (!long) {
|
|
304
|
+
cannotCheck('контракт говорит «Долгие задачи: нет» — вся работа укладывается в один ответ',
|
|
305
|
+
'это законный ответ, а не нарушение; проверять нечего, поэтому не 0 и не 1');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 2. Was the check performed? A named refusal is honest and exits 2.
|
|
309
|
+
const run = closedHeader(text, 'Проверка выполнена', RUN_STATUS,
|
|
310
|
+
'без этой строки «не проверяли» неотличимо от «проверили»');
|
|
311
|
+
if (run === 'not-done') {
|
|
312
|
+
const raw = header(text, 'Причина');
|
|
313
|
+
if (raw === null || raw === '') {
|
|
314
|
+
cannotCheck('проверка НЕ ВЫПОЛНЕНА без строки `**Причина:**`',
|
|
315
|
+
'причина обязательна и берётся из закрытого списка: ' + REASONS.join(' | ')
|
|
316
|
+
+ ' — каждая означает СВОЙ ремонт');
|
|
317
|
+
}
|
|
318
|
+
const picked = REASONS.filter((r) => new RegExp('(^|[^a-z-])' + r + '([^a-z-]|$)', 'i').test(raw));
|
|
319
|
+
if (picked.length !== 1) {
|
|
320
|
+
cannotCheck('причина «' + raw + '» не из закрытого списка (или названо сразу несколько)',
|
|
321
|
+
'допустимы ровно: ' + REASONS.join(' | '));
|
|
322
|
+
}
|
|
323
|
+
cannotCheck('проверка НЕ ВЫПОЛНЕНА, причина: ' + picked[0],
|
|
324
|
+
'честное «неизвестно», а не «клиент переживает разрыв»; до закрытия причины ни одно из '
|
|
325
|
+
+ 'трёх состояний не проверено');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 3. The handle. Everything below refers to it, so a missing NAME is a 2.
|
|
329
|
+
const idRaw = header(text, 'Идентификатор задачи');
|
|
330
|
+
if (idRaw === null || idRaw === '') {
|
|
331
|
+
cannotCheck('в контракте нет строки `**Идентификатор задачи:**` (или она пуста)',
|
|
332
|
+
'назовите ПОЛЕ, по которому клиент второй раз находит свою задачу — без него после разрыва '
|
|
333
|
+
+ 'спросить не о чем');
|
|
334
|
+
}
|
|
335
|
+
const id = idRaw.replace(/^[`"']|[`"']$/g, '').trim();
|
|
336
|
+
if (!/^[A-Za-z_][\w.-]*$/.test(id)) {
|
|
337
|
+
cannotCheck('`Идентификатор задачи` это не имя поля: ' + idRaw,
|
|
338
|
+
'нужно ИМЯ ПОЛЯ, например `job_id` или `taskId`, а не описание — по описанию нельзя '
|
|
339
|
+
+ 'проверить, что след проверки говорит о ТОЙ ЖЕ задаче');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 4. Two durations. The bounded exception below is a comparison, so an unparseable value is a 2.
|
|
343
|
+
const ceiling = requiredDuration(text, 'Предельное время задачи',
|
|
344
|
+
'сколько задача работает в худшем случае — без этого нельзя сказать, помещается ли она в окно');
|
|
345
|
+
const window = requiredDuration(text, 'Таймаут посредника',
|
|
346
|
+
'САМЫЙ КОРОТКИЙ таймаут на пути к клиенту: обратный прокси, балансировщик, CDN или браузер');
|
|
347
|
+
|
|
348
|
+
// 5. THE LOAD-BEARING COMPARISON. Silence is not a state; declaring it one erases the third state.
|
|
349
|
+
const silence = closedHeader(text, 'Молчание', SILENCE,
|
|
350
|
+
'как читается ОТСУТСТВИЕ ответа: как «неизвестно» или как «выполняется»');
|
|
351
|
+
if (silence === 'running') {
|
|
352
|
+
proven('молчание объявлено состоянием «выполняется» — а это НЕ состояние',
|
|
353
|
+
['**Молчание:** выполняется'],
|
|
354
|
+
'живая задача, умерший исполнитель и оборванный посредник молчат ОДИНАКОВО: их различает '
|
|
355
|
+
+ 'только ЧТЕНИЕ состояния по идентификатору `' + id + '`. Пока молчание читается как '
|
|
356
|
+
+ '«выполняется», умершая задача ждёт вечно, повтор удваивает счёт, а пользователю нечего '
|
|
357
|
+
+ 'показать. Правильный ответ — «неизвестно», и опрос состояния.');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 6. The handle must outlive the response that gets cut.
|
|
361
|
+
const issued = closedHeader(text, 'Выдаётся', ISSUED,
|
|
362
|
+
'КОГДА клиент получает идентификатор: до начала работы или вместе с результатом');
|
|
363
|
+
if (issued === 'after') {
|
|
364
|
+
proven('идентификатор выдаётся только вместе с результатом',
|
|
365
|
+
['**Выдаётся:** после завершения'],
|
|
366
|
+
'разрыв уносит ответ, а вместе с ответом — и единственную ручку к уже выполненной работе: '
|
|
367
|
+
+ 'спросить «что с моей задачей» больше нечем, и деньги за внешние вызовы уже потрачены. '
|
|
368
|
+
+ 'Идентификатор обязан быть выдан ДО начала работы.');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 7. A repeat that restarts is failure class 2, and it is declarable.
|
|
372
|
+
const resume = closedHeader(text, 'Продолжение при повторе', RESUME,
|
|
373
|
+
'чем обеспечено, что повтор ПРОДОЛЖАЕТ, а не начинает заново');
|
|
374
|
+
if (resume === 'none') {
|
|
375
|
+
proven('повтор начинает работу заново',
|
|
376
|
+
['**Продолжение при повторе:** нет'],
|
|
377
|
+
'каждая повторная попытка — это второй счёт за те же внешние вызовы, и пользователь нажимает '
|
|
378
|
+
+ 'её именно тогда, когда первая молчит. Назовите механизм: '
|
|
379
|
+
+ Object.keys(RESUME).filter((k) => k !== 'НЕТ').join(' | ').toLowerCase()
|
|
380
|
+
+ '. Родня с повторной доставкой ВХОДЯЩИХ вебхуков — соседнее правило, отдельный механизм: '
|
|
381
|
+
+ 'там повтор приходит извне, здесь его порождает ваш же клиент.');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 8. The synchronous answer, BOUNDED: legal only when the work provably fits inside the window.
|
|
385
|
+
const create = closedHeader(text, 'Ответ на создание', CREATE,
|
|
386
|
+
'что возвращает вызов создания: идентификатор или готовый результат');
|
|
387
|
+
const fits = ceiling.seconds < window.seconds;
|
|
388
|
+
if (create === 'result' && !fits) {
|
|
389
|
+
proven('ответом на создание объявлен РЕЗУЛЬТАТ, а работа не помещается в окно посредника',
|
|
390
|
+
['предельное время задачи: ' + ceiling.raw + ' (' + ceiling.seconds + ' с)',
|
|
391
|
+
'таймаут посредника: ' + window.raw + ' (' + window.seconds + ' с)'],
|
|
392
|
+
'это обычный запрос-ответ под другим названием: посредник оборвёт соединение раньше, чем '
|
|
393
|
+
+ 'работа закончится, и результат будет потерян ПОСЛЕ того, как за него заплатили. '
|
|
394
|
+
+ 'Возвращайте идентификатор сразу, результат — отдельным чтением.');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 9. The handle must have a place to live, and that place must be about THIS field.
|
|
398
|
+
const lives = header(text, 'Где живёт');
|
|
399
|
+
if (lives === null || lives === '') {
|
|
400
|
+
cannotCheck('в контракте нет строки `**Где живёт:**` (или она пуста)',
|
|
401
|
+
'где идентификатор ВЫДАЁТСЯ и где он ЧИТАЕТСЯ — назвать поле и не назвать место значит '
|
|
402
|
+
+ 'не дать клиенту способа им воспользоваться');
|
|
403
|
+
}
|
|
404
|
+
if (!lives.toLowerCase().includes(id.toLowerCase())) {
|
|
405
|
+
proven('строка `Где живёт` не упоминает названное поле',
|
|
406
|
+
['идентификатор: ' + id, 'где живёт: ' + lives],
|
|
407
|
+
'поле названо в одном месте контракта, а место его жизни описывает что-то другое — значит '
|
|
408
|
+
+ 'по контракту нельзя сказать, откуда клиент возьмёт ручку и куда её вернёт.');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 10. All three states, each answered, each proof naming the identifier, each DIFFERENT to the user.
|
|
412
|
+
const rows = stateRows(text);
|
|
413
|
+
const seen = rows.map((r) => r.name);
|
|
414
|
+
const dupes = [...new Set(seen.filter((n, i) => seen.indexOf(n) !== i))];
|
|
415
|
+
if (dupes.length) {
|
|
416
|
+
cannotCheck('в таблице состояний повторяются строки: ' + dupes.join(', '),
|
|
417
|
+
'одно состояние — одна строка; иначе один зачёт закрывает сразу два разных вопроса');
|
|
418
|
+
}
|
|
419
|
+
const bad = rows.filter((r) => !Object.prototype.hasOwnProperty.call(STATE_STATUS, r.status));
|
|
420
|
+
if (bad.length) {
|
|
421
|
+
cannotCheck('нераспознанный статус состояния: '
|
|
422
|
+
+ bad.map((r) => r.name + ' → ' + (r.status || '(пусто)')).join(', '),
|
|
423
|
+
'допустимы ровно: ' + Object.keys(STATE_STATUS).join(' | '));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const missing = STATES.filter((s) => !seen.includes(s));
|
|
427
|
+
if (missing.length) {
|
|
428
|
+
proven('состояние не названо вовсе (' + missing.length + ' из ' + STATES.length + ')',
|
|
429
|
+
missing,
|
|
430
|
+
'три состояния это ЗАКРЫТЫЙ и ОБЯЗАТЕЛЬНЫЙ набор. Два («идёт» и «готово») — это и есть '
|
|
431
|
+
+ 'дефект: отказу негде появиться, и он выдаётся пользователю как вечный прогресс.');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const unchecked = rows.filter((r) => STATE_STATUS[r.status] === 'unchecked');
|
|
435
|
+
if (unchecked.length) {
|
|
436
|
+
proven('проверка объявлена ВЫПОЛНЕННОЙ, но состояние осталось НЕ ПРОВЕРЕНО',
|
|
437
|
+
unchecked.map((r) => r.name),
|
|
438
|
+
'либо проверьте состояние, либо объявите всю проверку НЕ ВЫПОЛНЕННОЙ с причиной — '
|
|
439
|
+
+ 'частичный прогон под вывеской выполненного и есть ложная квитанция.');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const blind = rows.filter((r) => normalize(r.seen) === '');
|
|
443
|
+
if (blind.length) {
|
|
444
|
+
proven('состояние без наблюдаемого признака — для пользователя его не существует',
|
|
445
|
+
blind.map((r) => r.name),
|
|
446
|
+
'колонка «что видит пользователь» и есть разница между тремя состояниями; пустая, она '
|
|
447
|
+
+ 'оставляет пользователя перед той же тишиной, ради которой всё это и заведено.');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const collisions = [];
|
|
451
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
452
|
+
for (let j = i + 1; j < rows.length; j += 1) {
|
|
453
|
+
if (normalize(rows[i].seen) === normalize(rows[j].seen)) {
|
|
454
|
+
collisions.push(rows[i].name + ' и ' + rows[j].name + ': «' + rows[i].seen + '»');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (collisions.length) {
|
|
459
|
+
proven('два состояния выглядят для пользователя ОДИНАКОВО — значит это одно состояние',
|
|
460
|
+
collisions,
|
|
461
|
+
'различимость — это и есть требование: пользователь, который не отличает отказ от работы, '
|
|
462
|
+
+ 'нажимает кнопку ещё раз и порождает третью копию.');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const noProof = [];
|
|
466
|
+
const noId = [];
|
|
467
|
+
for (const row of rows) {
|
|
468
|
+
if (!row.evidence.trim()) { noProof.push(row.name); continue; }
|
|
469
|
+
if (!row.evidence.toLowerCase().includes(id.toLowerCase())) noId.push(row.name);
|
|
470
|
+
}
|
|
471
|
+
if (noProof.length) {
|
|
472
|
+
proven('состояние объявлено ПРОВЕРЕННЫМ без всякого доказательства', noProof,
|
|
473
|
+
'пустая клетка и незаполненный шаблон читаются одинаково: как отсутствие следа.');
|
|
474
|
+
}
|
|
475
|
+
if (noId.length) {
|
|
476
|
+
proven('доказательство состояния не называет идентификатор `' + id + '`', noId,
|
|
477
|
+
'след без идентификатора не отличает «я прочитал состояние СВОЕЙ задачи» от «сервер вообще '
|
|
478
|
+
+ 'ответил»: ровно та же подмена, что подтверждение развёртывания обращением к localhost.');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
say('✅ все ' + STATES.length + ' состояния различимы и проверены по идентификатору `' + id + '`');
|
|
482
|
+
say(' ручка: выдаётся ДО начала работы · повтор продолжает: '
|
|
483
|
+
+ (header(text, 'Продолжение при повторе') || resume)
|
|
484
|
+
+ ' · потолок ' + ceiling.raw + ' против окна ' + window.raw);
|
|
485
|
+
if (create === 'result' && fits) {
|
|
486
|
+
say(' Ограниченное исключение: ответ синхронный, но заявленный потолок КОРОЧЕ окна посредника '
|
|
487
|
+
+ '(' + ceiling.seconds + ' с < ' + window.seconds + ' с). Занижённый потолок — ложь в самой '
|
|
488
|
+
+ 'декларации, и оба числа напечатаны здесь именно поэтому.');
|
|
489
|
+
}
|
|
490
|
+
say(' Ограничение: это доказывает, что ДЕКЛАРАЦИЯ непротиворечива и что следы названы по '
|
|
491
|
+
+ 'идентификатору — а НЕ что сервер переживает настоящий разрыв. Это доказывает только прогон '
|
|
492
|
+
+ 'с оборванным соединением и повтором.');
|
|
493
|
+
process.exit(0);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
main();
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// Even an unexpected failure must not read as "clean".
|
|
500
|
+
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
|
|
501
|
+
}
|