@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,470 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* check-model-cost.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 here
|
|
9
|
+
* 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-model-cost.cjs [path-to-project]
|
|
15
|
+
*
|
|
16
|
+
* WHY IT EXISTS — the failure, before the technology.
|
|
17
|
+
*
|
|
18
|
+
* An external model call costs money PER CALL, and in the products this pipeline builds the call is
|
|
19
|
+
* triggered by a STRANGER, not by the developer: a Cal AI clone runs a multimodal model on every
|
|
20
|
+
* photo ANY visitor uploads; a podcast tool runs Whisper on every episode ANY user submits; a
|
|
21
|
+
* knowledge-base tool embeds the WHOLE base a client pastes in. The meter is turned by someone whose
|
|
22
|
+
* behaviour you do not control and whose good faith you cannot assume.
|
|
23
|
+
*
|
|
24
|
+
* THE PROPERTY THAT MAKES THIS CLASS DIFFERENT FROM EVERY OTHER DEFECT IN THIS PACKAGE: the failure
|
|
25
|
+
* CANNOT BE ROLLED BACK. A wrong port is closed and the story ends; a wrong widget origin is
|
|
26
|
+
* re-tested and the story ends. Here one unclosed loop or one hostile visitor produces an INVOICE —
|
|
27
|
+
* the money left, and no edit to the code brings it back. Everything below is shaped by that: the
|
|
28
|
+
* question is never "will we notice?", it is "what refused BEFORE the call was made?".
|
|
29
|
+
*
|
|
30
|
+
* TWO SOURCES OF SPEND, and they fail differently — this is the reason for the `Кто запускает`
|
|
31
|
+
* column and not a stylistic distinction:
|
|
32
|
+
*
|
|
33
|
+
* свой-код you ruin YOURSELF: a loop, a retry, a backfill over the whole table. Bounded by
|
|
34
|
+
* your own code, so a per-day ceiling is a sufficient bound.
|
|
35
|
+
* посторонний you are ruined BY SOMEONE ELSE: the visitor decides how many times the meter turns.
|
|
36
|
+
* A per-day ceiling alone is NOT sufficient — one visitor can eat the whole day's
|
|
37
|
+
* budget before lunch — so the per-USER ceiling must be able to BIND, which requires
|
|
38
|
+
* naming what counts as one user for someone who never logged in.
|
|
39
|
+
*
|
|
40
|
+
* THE CONTRADICTION THIS FILE EXISTS TO CATCH: `Вход: без-входа` together with
|
|
41
|
+
* `Единица счёта: аккаунт`. A per-account limit for a caller who has no account is a limit that can
|
|
42
|
+
* never bind — it is written like a defence and behaves like none. That is the same shape as the
|
|
43
|
+
* widget checked on its own origin: a measurement that cannot observe the failure it names.
|
|
44
|
+
*
|
|
45
|
+
* WHY «не число» IS EXIT 1 AND NOT EXIT 2. Everywhere else in this package an unparseable value is
|
|
46
|
+
* "the check did not run". Here it is a PROVEN defect, deliberately: `Предел на пользователя:
|
|
47
|
+
* разумный` is not a malformed field, it is EXACTLY the defect the rule names — an intention
|
|
48
|
+
* standing where a number must stand. Nobody can enforce «разумный» at runtime, so the ceiling does
|
|
49
|
+
* not exist. Reading that as "could not check" would file the defect as an unknown.
|
|
50
|
+
*
|
|
51
|
+
* WHAT THIS FILE CAN AND CANNOT DECIDE — read before trusting exit 0.
|
|
52
|
+
*
|
|
53
|
+
* It reads a DECLARATION, `docs/model-cost-contract.md`, and decides only what a declaration can
|
|
54
|
+
* settle: that every call is enumerated, that each ceiling is a POSITIVE INTEGER rather than an
|
|
55
|
+
* intention, that a per-user ceiling is reachable (≤ the daily one) and attachable (a counting unit
|
|
56
|
+
* that exists for that caller), that reaching a ceiling REFUSES rather than degrades, that an
|
|
57
|
+
* unconfigured ceiling refuses at boot rather than meaning infinity, and that the spend has an
|
|
58
|
+
* ADDRESS somebody can open. It does NOT execute your code, does NOT count a single call and does
|
|
59
|
+
* NOT read anyone's invoice — see «Слой 3–4» in `.claude/rules/model-call-cost.md`, which says in
|
|
60
|
+
* the same words why no deterministic half of that can exist here.
|
|
61
|
+
*
|
|
62
|
+
* THE EXACT FORM OF `docs/model-cost-contract.md` — the rule delegates it here on purpose: this file
|
|
63
|
+
* is not part of the always-loaded corpus, so the long form costs nothing per run, while the rule
|
|
64
|
+
* keeps only the decision the reader must carry.
|
|
65
|
+
*
|
|
66
|
+
* **Внешние вызовы модели:** да (да | нет — `нет` is a legitimate answer)
|
|
67
|
+
* **Где виден расход:** https://console.example.com/usage (an address, not a genre of place)
|
|
68
|
+
* **Счёт ведётся по:** попыткам (попыткам | успехам)
|
|
69
|
+
* **Потолок не сконфигурирован:** ОТКАЗ ПРИ СТАРТЕ (ОТКАЗ ПРИ СТАРТЕ | БЕЗ ОГРАНИЧЕНИЙ)
|
|
70
|
+
* **Проверка пределов:** ВЫПОЛНЕНА (ВЫПОЛНЕНА | НЕ ВЫПОЛНЕНА)
|
|
71
|
+
* **Причина:** — (required when НЕ ВЫПОЛНЕНА; one of the closed REASONS below)
|
|
72
|
+
*
|
|
73
|
+
* ## Внешние вызовы
|
|
74
|
+
*
|
|
75
|
+
* | Вызов | Кто запускает | Вход | Единица счёта | Предел на пользователя | Предел в сутки | При достижении |
|
|
76
|
+
* |---|---|---|---|---|---|---|
|
|
77
|
+
* | распознавание-фото | посторонний | без-входа | сессия | 20 | 2000 | ОТКАЗ |
|
|
78
|
+
* | ночной-пересчёт | свой-код | — | — | 500 | 500 | ОТКАЗ |
|
|
79
|
+
*
|
|
80
|
+
* Exit codes — three, and the third is the point:
|
|
81
|
+
* 0 every call is enumerated and every ceiling is a number that can actually bind
|
|
82
|
+
* 1 a defect is PROVEN and named: a ceiling that is an intention, a non-positive or unlimited
|
|
83
|
+
* ceiling, a per-user ceiling above the daily one, an outsider-triggered call with no counting
|
|
84
|
+
* unit or with one that cannot exist for it, silent degradation instead of refusal, an
|
|
85
|
+
* unconfigured ceiling declared as «без ограничений», billing counted by successes, a spend
|
|
86
|
+
* place with no address, or an empty call table under «Внешние вызовы модели: да»
|
|
87
|
+
* 2 THE CHECK DID NOT RUN — no contract, an unrecognised value, duplicate rows, or the legitimate
|
|
88
|
+
* answers «внешних вызовов модели нет» / «проверка НЕ ВЫПОЛНЕНА, причина такая-то»
|
|
89
|
+
*
|
|
90
|
+
* A checker that answers "clean" when it could not look converts an unknown into a reassurance —
|
|
91
|
+
* and for THIS feature the reassurance is paid for with money that does not come back.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
const fs = require('node:fs');
|
|
95
|
+
const path = require('node:path');
|
|
96
|
+
|
|
97
|
+
const CONTRACT = path.join('docs', 'model-cost-contract.md');
|
|
98
|
+
|
|
99
|
+
/** Does the product call an external model at all? A CLOSED set — `нет` is a legitimate answer and
|
|
100
|
+
* it exits 2, never 0: there is nothing to bound, and «нечего ограничивать» must not be spelled the
|
|
101
|
+
* same way as «ограничено». */
|
|
102
|
+
const CALLS_MODEL = { 'ДА': true, 'НЕТ': false };
|
|
103
|
+
|
|
104
|
+
/** Was the limit review performed? CLOSED, and the negative answer is honest, not a failure:
|
|
105
|
+
* CFG-I4 of `honest-configuration` — an unreachable truth yields UNKNOWN, never a plausible value. */
|
|
106
|
+
const RUN_STATUS = { 'ВЫПОЛНЕНА': 'done', 'НЕ ВЫПОЛНЕНА': 'not-done' };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Why the limits were not settled. CLOSED list — free text is not a reason here, because the entire
|
|
110
|
+
* value of the list is that each entry names a DIFFERENT repair:
|
|
111
|
+
* не-подключено — wire the provider, then re-check
|
|
112
|
+
* нет-доступа-к-счёту — get access to the billing console, then name the place
|
|
113
|
+
* решение-отложено — take the decision (this is the one that quietly becomes an invoice)
|
|
114
|
+
* вне-объёма — decide it is out of scope and record the decision
|
|
115
|
+
*/
|
|
116
|
+
const REASONS = ['не-подключено', 'нет-доступа-к-счёту', 'решение-отложено', 'вне-объёма'];
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What the code does when the ceiling is NOT configured. CLOSED, and the whole extension of
|
|
120
|
+
* `honest-configuration` to money lives in this one field: an absent limit is a REFUSAL, never
|
|
121
|
+
* «ограничений нет». CFG-S1 — an absent required value refuses and names the external consequence;
|
|
122
|
+
* here the external consequence is an invoice.
|
|
123
|
+
*/
|
|
124
|
+
const UNSET_CEILING = { 'ОТКАЗ ПРИ СТАРТЕ': 'refuse', 'БЕЗ ОГРАНИЧЕНИЙ': 'unlimited' };
|
|
125
|
+
|
|
126
|
+
/** Are attempts billed, or only successes? CLOSED. A failed attempt is billed by the provider all
|
|
127
|
+
* the same, so counting successes leaves the retry path unmetered. */
|
|
128
|
+
const BILLED_ON = { 'ПОПЫТКАМ': 'attempts', 'УСПЕХАМ': 'successes' };
|
|
129
|
+
|
|
130
|
+
/** Who turns the meter. CLOSED — the two halves fail differently and need different ceilings. */
|
|
131
|
+
const TRIGGER = { 'свой-код': 'own', 'посторонний': 'outsider' };
|
|
132
|
+
|
|
133
|
+
/** Can the trigger be reached without logging in? CLOSED. `—` is «not applicable», legitimate only
|
|
134
|
+
* for `свой-код`; for an outsider it is an unanswered question, and it is the question. */
|
|
135
|
+
const AUTH = { 'без-входа': 'anonymous', 'после-входа': 'authenticated', '—': 'n/a' };
|
|
136
|
+
|
|
137
|
+
/** What counts as ONE user. CLOSED — `аккаунт` does not exist for an anonymous caller, and that
|
|
138
|
+
* impossibility is the defect this file is built around. */
|
|
139
|
+
const UNIT = { 'аккаунт': 'account', 'сессия': 'session', 'адрес': 'address', 'ключ': 'key', '—': 'none' };
|
|
140
|
+
|
|
141
|
+
/** What happens at the ceiling. CLOSED, and only the first passes: a ceiling that degrades quietly
|
|
142
|
+
* is not a ceiling, and a queue postpones the call without cancelling the spend. */
|
|
143
|
+
const ON_LIMIT = { 'ОТКАЗ': 'refuse', 'ДЕГРАДАЦИЯ': 'degrade', 'ОЧЕРЕДЬ': 'queue' };
|
|
144
|
+
|
|
145
|
+
/** Spellings that mean "no ceiling". Named separately from "not a number" so the printed repair is
|
|
146
|
+
* the right one: an infinity was CHOSEN, a typo was not. */
|
|
147
|
+
const UNLIMITED = /^(∞|inf|infinity|unlimited|без\s*ограничений|неограничен\w*|-\s*1|нет)$/i;
|
|
148
|
+
|
|
149
|
+
function say(s) { process.stdout.write(s + '\n'); }
|
|
150
|
+
|
|
151
|
+
/** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
|
|
152
|
+
function cannotCheck(reason, hint) {
|
|
153
|
+
say('⚠️ проверка НЕ выполнена: ' + reason);
|
|
154
|
+
if (hint) say(' ' + hint);
|
|
155
|
+
process.exit(2);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Exit 1 with the defect NAMED. A violation that cannot be named is a 2, not a 1. */
|
|
159
|
+
function proven(title, lines, tail) {
|
|
160
|
+
say('❌ ' + title);
|
|
161
|
+
for (const line of lines) say(' • ' + line);
|
|
162
|
+
if (tail) say(' ' + tail);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The value of a `**Label:** value` header line, or null when the label is absent entirely.
|
|
168
|
+
* An EMPTY value is returned as '' and is never collapsed into "absent" — those are different
|
|
169
|
+
* mistakes with different repairs (`honest-configuration` CFG-I2).
|
|
170
|
+
*/
|
|
171
|
+
function header(text, label, { strip = true } = {}) {
|
|
172
|
+
const re = new RegExp('^\\s*\\*\\*' + label + ':?\\*\\*\\s*:?(.*)$', 'im');
|
|
173
|
+
const m = re.exec(text);
|
|
174
|
+
if (!m) return null;
|
|
175
|
+
const value = m[1].trim();
|
|
176
|
+
// `strip: false` is not a stylistic choice: the ONLY value in this contract whose backticks CARRY
|
|
177
|
+
// MEANING is the spend address (a runnable command is one of the three things a person can open),
|
|
178
|
+
// and stripping them would turn `make spend-report` into prose the address test then refuses.
|
|
179
|
+
return strip ? value.replace(/^[«"`]|[»"`]$/g, '').trim() : value;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A header value read against a CLOSED map, with both failure modes kept apart. */
|
|
183
|
+
function closedHeader(text, label, map, what) {
|
|
184
|
+
const raw = header(text, label);
|
|
185
|
+
if (raw === null) {
|
|
186
|
+
cannotCheck('в контракте нет строки `**' + label + ':**`',
|
|
187
|
+
what + ' — допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
188
|
+
}
|
|
189
|
+
const key = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
190
|
+
if (!Object.prototype.hasOwnProperty.call(map, key)) {
|
|
191
|
+
cannotCheck('нераспознанное значение `' + label + '`: ' + (key === '' ? '(пусто)' : key),
|
|
192
|
+
'допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
193
|
+
}
|
|
194
|
+
return map[key];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A ceiling, read as three DIFFERENT answers rather than one boolean.
|
|
199
|
+
*
|
|
200
|
+
* {kind:'number'} a positive integer — the only value anything can enforce
|
|
201
|
+
* {kind:'unlimited'} an infinity was chosen and written down
|
|
202
|
+
* {kind:'intent'} prose, a placeholder, or an empty cell standing where a number must stand
|
|
203
|
+
* {kind:'zero'} 0 or a negative count
|
|
204
|
+
*
|
|
205
|
+
* Thousand separators (space, thin space, underscore) are stripped: `2 000` is a number a person
|
|
206
|
+
* writes and a machine can still read, and refusing it would push people to omit the separator
|
|
207
|
+
* rather than to name the ceiling.
|
|
208
|
+
*/
|
|
209
|
+
function ceiling(raw) {
|
|
210
|
+
const s = String(raw == null ? '' : raw).trim();
|
|
211
|
+
if (s === '' || /^\[.*\]$/.test(s) || s === '—' || s === '-') return { kind: 'intent', raw: s };
|
|
212
|
+
if (UNLIMITED.test(s)) return { kind: 'unlimited', raw: s };
|
|
213
|
+
const compact = s.replace(/[\s _]/g, '');
|
|
214
|
+
if (!/^\d+$/.test(compact)) return { kind: 'intent', raw: s };
|
|
215
|
+
const value = Number(compact);
|
|
216
|
+
if (!Number.isSafeInteger(value)) return { kind: 'intent', raw: s };
|
|
217
|
+
if (value <= 0) return { kind: 'zero', raw: s, value };
|
|
218
|
+
return { kind: 'number', raw: s, value };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Does the spend have an ADDRESS, or only a genre of place?
|
|
223
|
+
*
|
|
224
|
+
* The kinship with the widget contract's evidence cell: without an address, «расход виден» and
|
|
225
|
+
* «расход не виден» are written identically. A URL, or a path with a separator, or a runnable
|
|
226
|
+
* command — all three are things a person can OPEN. «в логах», «в консоли провайдера», «у меня в
|
|
227
|
+
* голове» are not.
|
|
228
|
+
*/
|
|
229
|
+
function hasAddress(raw) {
|
|
230
|
+
const s = String(raw || '');
|
|
231
|
+
if (/\b[a-z][a-z0-9+.-]*:\/\/\S+/i.test(s)) return true; // https://…
|
|
232
|
+
if (/(^|\s)[.~]?\/[\w.@-]+(\/[\w.@-]*)*/.test(s)) return true; // /var/log/spend.jsonl, ./usage
|
|
233
|
+
if (/`[^`]+`/.test(s)) return true; // `make spend-report`
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The call table, as the contract records it.
|
|
239
|
+
*
|
|
240
|
+
* A row is a markdown table row whose SECOND cell is one of the two triggers — the header row and
|
|
241
|
+
* the `|---|` separator therefore cannot be mistaken for data, and a row whose trigger is misspelled
|
|
242
|
+
* is reported rather than silently dropped.
|
|
243
|
+
*/
|
|
244
|
+
function callRows(text) {
|
|
245
|
+
const rows = [];
|
|
246
|
+
for (const raw of text.split('\n')) {
|
|
247
|
+
const line = raw.trim();
|
|
248
|
+
if (!line.startsWith('|')) continue;
|
|
249
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
250
|
+
if (cells.length < 9) continue; // '' + 7 columns + ''
|
|
251
|
+
const name = cells[1];
|
|
252
|
+
if (!name || /^-+$/.test(name) || name.toLowerCase() === 'вызов') continue;
|
|
253
|
+
rows.push({
|
|
254
|
+
name,
|
|
255
|
+
trigger: cells[2].toLowerCase(),
|
|
256
|
+
auth: cells[3].toLowerCase(),
|
|
257
|
+
unit: cells[4].toLowerCase(),
|
|
258
|
+
perUser: cells[5],
|
|
259
|
+
perDay: cells[6],
|
|
260
|
+
onLimit: cells[7].toUpperCase().replace(/\s+/g, ' ').trim(),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return rows;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function main() {
|
|
267
|
+
const root = process.argv[2] || '.';
|
|
268
|
+
try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
|
|
269
|
+
catch { cannotCheck('путь не существует: ' + root); }
|
|
270
|
+
|
|
271
|
+
const abs = path.join(root, CONTRACT);
|
|
272
|
+
let text;
|
|
273
|
+
try {
|
|
274
|
+
if (!fs.statSync(abs).isFile()) cannotCheck(CONTRACT + ' существует, но это не файл');
|
|
275
|
+
text = fs.readFileSync(abs, 'utf-8');
|
|
276
|
+
} catch (e) {
|
|
277
|
+
if (e && e.code === 'ENOENT') {
|
|
278
|
+
cannotCheck('нет файла ' + CONTRACT,
|
|
279
|
+
'это значит, что вопрос о стоимости НЕ ЗАДАВАЛСЯ — а НЕ что вызовов нет; продукт без '
|
|
280
|
+
+ 'внешних вызовов модели отвечает `**Внешние вызовы модели:** нет`, и это законный ответ');
|
|
281
|
+
}
|
|
282
|
+
cannotCheck('не читается ' + CONTRACT + ': ' + ((e && e.message) || e));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 1. Does the product call a model at all? «нет» is legitimate and has nothing to bound → 2.
|
|
286
|
+
const calls = closedHeader(text, 'Внешние вызовы модели', CALLS_MODEL,
|
|
287
|
+
'без этой строки «вызовов нет» неотличимо от «про вызовы забыли»');
|
|
288
|
+
if (!calls) {
|
|
289
|
+
cannotCheck('контракт говорит «Внешние вызовы модели: нет» — платного вызова наружу нет',
|
|
290
|
+
'это законный ответ, а не нарушение; ограничивать нечего, поэтому не 0 и не 1');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 2. Was the limit review performed? A named refusal is honest and exits 2.
|
|
294
|
+
const run = closedHeader(text, 'Проверка пределов', RUN_STATUS,
|
|
295
|
+
'без этой строки «не считали» неотличимо от «посчитали»');
|
|
296
|
+
if (run === 'not-done') {
|
|
297
|
+
const raw = header(text, 'Причина');
|
|
298
|
+
if (raw === null || raw === '') {
|
|
299
|
+
cannotCheck('проверка пределов НЕ ВЫПОЛНЕНА без строки `**Причина:**`',
|
|
300
|
+
'причина обязательна и берётся из закрытого списка: ' + REASONS.join(' | ')
|
|
301
|
+
+ ' — каждая означает СВОЙ ремонт');
|
|
302
|
+
}
|
|
303
|
+
const picked = REASONS.filter((r) => raw.includes(r));
|
|
304
|
+
if (picked.length !== 1) {
|
|
305
|
+
cannotCheck('причина «' + raw + '» не из закрытого списка (или названо сразу несколько)',
|
|
306
|
+
'допустимы ровно: ' + REASONS.join(' | '));
|
|
307
|
+
}
|
|
308
|
+
cannotCheck('проверка пределов НЕ ВЫПОЛНЕНА, причина: ' + picked[0],
|
|
309
|
+
'честное «неизвестно», а не «расход ограничен»; пока причина не закрыта, счёт ничем '
|
|
310
|
+
+ 'не ограничен, и потраченного не вернуть');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 3. ПРАВИЛО №0. An unconfigured ceiling is a REFUSAL, never infinity — the whole extension of
|
|
314
|
+
// honest-configuration to money, and the cheapest deterministic bite in this file.
|
|
315
|
+
const unset = closedHeader(text, 'Потолок не сконфигурирован', UNSET_CEILING,
|
|
316
|
+
'что делает код, когда предел не задан');
|
|
317
|
+
if (unset === 'unlimited') {
|
|
318
|
+
proven('несконфигурированный потолок объявлен как «без ограничений»',
|
|
319
|
+
['**Потолок не сконфигурирован:** БЕЗ ОГРАНИЧЕНИЙ'],
|
|
320
|
+
'отсутствующий предел — это ОТКАЗ, а не отсутствие предела (`honest-configuration`, CFG-S1): '
|
|
321
|
+
+ 'пустая переменная окружения не должна значить бесконечность. Запуск обязан ПАДАТЬ, называя '
|
|
322
|
+
+ 'ненастроенный вызов, — потому что второй шанс здесь оплачивается счётом.');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 4. Attempts, not successes: a failed call is billed by the provider all the same.
|
|
326
|
+
const billed = closedHeader(text, 'Счёт ведётся по', BILLED_ON,
|
|
327
|
+
'считаются попытки или только успешные вызовы');
|
|
328
|
+
if (billed === 'successes') {
|
|
329
|
+
proven('счёт ведётся по УСПЕХАМ — неудачные вызовы не попадают под предел',
|
|
330
|
+
['**Счёт ведётся по:** успехам'],
|
|
331
|
+
'провайдер выставляет счёт за ПОПЫТКУ: таймаут, отказ модели и повтор оплачены так же, как '
|
|
332
|
+
+ 'успех. Счётчик по успехам оставляет путь повторов вне предела — а повтор долгой фоновой '
|
|
333
|
+
+ 'задачи удваивает счёт (см. правило этого пакета о долгих фоновых задачах).');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 5. Where the spend is visible. A ceiling nobody can observe is learned about from the invoice.
|
|
337
|
+
const where = header(text, 'Где виден расход', { strip: false });
|
|
338
|
+
if (where === null || where === '') {
|
|
339
|
+
cannotCheck('в контракте нет строки `**Где виден расход:**` (или она пуста)',
|
|
340
|
+
'адрес панели, файла или команды — предел, о котором нельзя узнать до счёта, не предел');
|
|
341
|
+
}
|
|
342
|
+
if (!hasAddress(where)) {
|
|
343
|
+
proven('место расхода названо жанром, а не адресом', ['«' + where + '»'],
|
|
344
|
+
'нужен адрес, который можно ОТКРЫТЬ: `https://…`, путь вида `/var/log/model-spend.jsonl` '
|
|
345
|
+
+ 'или команда в обратных кавычках. Без адреса «расход виден» и «расход не виден» пишутся '
|
|
346
|
+
+ 'одинаково — ровно тот же дефект, что доказательство проверки без адреса.');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 6. The calls themselves.
|
|
350
|
+
const rows = callRows(text);
|
|
351
|
+
if (rows.length === 0) {
|
|
352
|
+
proven('объявлены внешние вызовы модели, но ни один не назван', ['таблица `## Внешние вызовы` пуста'],
|
|
353
|
+
'нельзя назвать предел вызову, которого нет в списке. Пустая таблица под «да» — доказанный '
|
|
354
|
+
+ 'пропуск, а не неизвестность.');
|
|
355
|
+
}
|
|
356
|
+
const names = rows.map((r) => r.name.toLowerCase());
|
|
357
|
+
const dupes = [...new Set(names.filter((n, i) => names.indexOf(n) !== i))];
|
|
358
|
+
if (dupes.length) {
|
|
359
|
+
cannotCheck('в таблице вызовов повторяются строки: ' + dupes.join(', '),
|
|
360
|
+
'один вызов — одна строка; иначе один предел закрывает сразу два разных счётчика');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const [field, map, label] of [['trigger', TRIGGER, 'Кто запускает'], ['auth', AUTH, 'Вход'],
|
|
364
|
+
['unit', UNIT, 'Единица счёта'], ['onLimit', ON_LIMIT, 'При достижении']]) {
|
|
365
|
+
const bad = rows.filter((r) => !Object.prototype.hasOwnProperty.call(map, r[field]));
|
|
366
|
+
if (bad.length) {
|
|
367
|
+
cannotCheck('нераспознанное значение `' + label + '`: '
|
|
368
|
+
+ bad.map((r) => r.name + ' → ' + (r[field] || '(пусто)')).join(', '),
|
|
369
|
+
'допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 6a. Reaching the ceiling must REFUSE. Degradation and a queue are named apart because they are
|
|
374
|
+
// different mistakes: one hides the ceiling, the other postpones the spend without cancelling it.
|
|
375
|
+
const degrading = rows.filter((r) => ON_LIMIT[r.onLimit] === 'degrade');
|
|
376
|
+
if (degrading.length) {
|
|
377
|
+
proven('при достижении предела объявлена ДЕГРАДАЦИЯ, а не отказ', degrading.map((r) => r.name),
|
|
378
|
+
'тихая деградация — это предел, о котором пользователь не узнал, а вы узнаете из счёта: '
|
|
379
|
+
+ 'система продолжает звать модель «поменьше» и продолжает платить. Достижение предела '
|
|
380
|
+
+ 'обязано быть ОТКАЗОМ, названным вслух.');
|
|
381
|
+
}
|
|
382
|
+
const queued = rows.filter((r) => ON_LIMIT[r.onLimit] === 'queue');
|
|
383
|
+
if (queued.length) {
|
|
384
|
+
proven('при достижении предела объявлена ОЧЕРЕДЬ, а не отказ', queued.map((r) => r.name),
|
|
385
|
+
'очередь ОТКЛАДЫВАЕТ вызов, но не отменяет расход: когда очередь разойдётся, счёт будет тот '
|
|
386
|
+
+ 'же. Предел, который переносит трату на завтра, не ограничивает трату.');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 6b. Every ceiling must be a NUMBER — the requirement the rule states in its first line.
|
|
390
|
+
const intents = [];
|
|
391
|
+
const infinities = [];
|
|
392
|
+
const zeros = [];
|
|
393
|
+
for (const row of rows) {
|
|
394
|
+
for (const [field, label] of [['perUser', 'на пользователя'], ['perDay', 'в сутки']]) {
|
|
395
|
+
const c = ceiling(row[field]);
|
|
396
|
+
row[field + 'C'] = c;
|
|
397
|
+
if (c.kind === 'intent') intents.push(row.name + ' / ' + label + ': «' + (c.raw || '(пусто)') + '»');
|
|
398
|
+
if (c.kind === 'unlimited') infinities.push(row.name + ' / ' + label + ': «' + c.raw + '»');
|
|
399
|
+
if (c.kind === 'zero') zeros.push(row.name + ' / ' + label + ': ' + c.raw);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (intents.length) {
|
|
403
|
+
proven('предел назван намерением, а не числом', intents,
|
|
404
|
+
'ни один код не умеет применить «разумный», «по ситуации» или пустую клетку: такого предела '
|
|
405
|
+
+ 'нет. Число — единственная форма, которую можно сравнить со счётчиком.');
|
|
406
|
+
}
|
|
407
|
+
if (infinities.length) {
|
|
408
|
+
proven('предел объявлен бесконечным', infinities,
|
|
409
|
+
'бесконечный предел — это отсутствие предела, записанное так, будто предел есть. Если '
|
|
410
|
+
+ 'ограничивать действительно не нужно, это решение, и его место — `**Причина:** вне-объёма` '
|
|
411
|
+
+ 'при НЕ ВЫПОЛНЕННОЙ проверке, а не число ∞ в графе предела.');
|
|
412
|
+
}
|
|
413
|
+
if (zeros.length) {
|
|
414
|
+
proven('предел не положителен', zeros,
|
|
415
|
+
'ноль или отрицательное значение чаще всего означает «поле не заполнили», а ведёт себя как '
|
|
416
|
+
+ '«вызов запрещён навсегда». Если вызов действительно выключен — уберите строку.');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 6c. A per-user ceiling ABOVE the daily one can never bind: the daily one always fires first.
|
|
420
|
+
const dead = rows.filter((r) => r.perUserC.value > r.perDayC.value);
|
|
421
|
+
if (dead.length) {
|
|
422
|
+
proven('предел на пользователя выше суточного — он не сработает никогда',
|
|
423
|
+
dead.map((r) => r.name + ': на пользователя ' + r.perUserC.value + ' > в сутки ' + r.perDayC.value),
|
|
424
|
+
'суточный предел упрётся первым, поэтому персональный написан, но не действует: один '
|
|
425
|
+
+ 'посетитель по-прежнему может съесть весь дневной бюджет. Предел на пользователя обязан '
|
|
426
|
+
+ 'быть НЕ БОЛЬШЕ суточного, иначе он декоративен.');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 6d. THE LOAD-BEARING CHECK. For an outsider-triggered call the per-user ceiling must be able to
|
|
430
|
+
// BIND, and that is decided by the counting unit — the one thing an anonymous caller may lack.
|
|
431
|
+
const outsiders = rows.filter((r) => TRIGGER[r.trigger] === 'outsider');
|
|
432
|
+
const unanswered = outsiders.filter((r) => AUTH[r.auth] === 'n/a');
|
|
433
|
+
if (unanswered.length) {
|
|
434
|
+
proven('вызов запускает ПОСТОРОННИЙ, но не сказано, нужен ли ему вход', unanswered.map((r) => r.name),
|
|
435
|
+
'именно вход отделяет «нас разоряет свой цикл» от «нас разоряет любой прохожий»: без входа '
|
|
436
|
+
+ 'счётчик посетителей ничем не ограничен сверху. `—` в этой графе законен только для '
|
|
437
|
+
+ '`свой-код`.');
|
|
438
|
+
}
|
|
439
|
+
const unattached = outsiders.filter((r) => UNIT[r.unit] === 'none');
|
|
440
|
+
if (unattached.length) {
|
|
441
|
+
proven('вызов запускает ПОСТОРОННИЙ, но предел на пользователя не к чему привязать',
|
|
442
|
+
unattached.map((r) => r.name),
|
|
443
|
+
'чтобы считать «на одного», надо назвать, что такое один: аккаунт, сессия, адрес или ключ. '
|
|
444
|
+
+ 'Без единицы счёта суточный предел остаётся единственным, и один посетитель законно '
|
|
445
|
+
+ 'выбирает его целиком.');
|
|
446
|
+
}
|
|
447
|
+
const impossible = outsiders.filter((r) => AUTH[r.auth] === 'anonymous' && UNIT[r.unit] === 'account');
|
|
448
|
+
if (impossible.length) {
|
|
449
|
+
proven('единица счёта не существует для того, кто вызов запускает',
|
|
450
|
+
impossible.map((r) => r.name + ': вход `без-входа`, счёт `аккаунт`'),
|
|
451
|
+
'у анонимного посетителя нет аккаунта, поэтому предел «на аккаунт» не может связать НИ ОДИН '
|
|
452
|
+
+ 'его вызов: он написан как защита и ведёт себя как её отсутствие. Считайте по сессии, '
|
|
453
|
+
+ 'адресу или ключу — либо потребуйте вход и тогда считайте по аккаунту.');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const anon = outsiders.filter((r) => AUTH[r.auth] === 'anonymous').length;
|
|
457
|
+
say('✅ ' + rows.length + ' вызов(ов) названы, у каждого предел на пользователя и в сутки — числа, '
|
|
458
|
+
+ 'достижение предела отказывает');
|
|
459
|
+
say(' Из них запускаются посторонними: ' + outsiders.length + ' (из них без входа: ' + anon + ')');
|
|
460
|
+
say(' Ограничение: это доказывает, что предел ОБЪЯВЛЕН и способен связать — а не что код его '
|
|
461
|
+
+ 'применяет. Применение доказывает только прогон, упершийся в предел, и счёт после него.');
|
|
462
|
+
process.exit(0);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
main();
|
|
467
|
+
} catch (err) {
|
|
468
|
+
// Even an unexpected failure must not read as "clean".
|
|
469
|
+
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
|
|
470
|
+
}
|
|
@@ -43,12 +43,12 @@ const SUBPROCESS_OPTIONS = {
|
|
|
43
43
|
// called `rediscommander/redis-commander` storage (it is a web UI), and missed
|
|
44
44
|
// `mcr.microsoft.com/mssql/server` on its standard 1433 entirely. So: take the image NAME —
|
|
45
45
|
// the last path component, without registry, tag or digest — and anchor to it.
|
|
46
|
-
const STORAGE_NAMES = /^(postgres|postgresql|pgvector|mysql|mariadb|percona|mongo|mongodb|redis|valkey|keydb|elasticsearch|opensearch|minio|rabbitmq|memcached|clickhouse|cassandra|scylla|neo4j|influxdb|timescaledb|mssql|sqlserver|couchdb|etcd)$/i;
|
|
46
|
+
const STORAGE_NAMES = /^(postgres|postgresql|pgvector|mysql|mariadb|percona|mongo|mongodb|redis|valkey|keydb|elasticsearch|opensearch|minio|rabbitmq|memcached|clickhouse|cassandra|scylla|neo4j|influxdb|timescaledb|mssql|sqlserver|couchdb|etcd|qdrant)$/i;
|
|
47
47
|
const STORAGE_PORT = new Set([5432, 3306, 27017, 6379, 9200, 9300, 5672, 11211, 9000, 8123,
|
|
48
|
-
1433, 9042, 7687, 8086, 2379, 5984]);
|
|
48
|
+
1433, 9042, 7687, 8086, 2379, 5984, 6333]);
|
|
49
49
|
const PASSWORD_STORAGE_NAMES = /^(postgres|mysql|mariadb|mongo)$/i;
|
|
50
50
|
const NO_PASSWORD_STORAGE_NAMES = /^(redis|valkey|keydb|memcached)$/i;
|
|
51
|
-
const PROXY_NAMES = /^(caddy|nginx|traefik|haproxy|envoy)$/i;
|
|
51
|
+
const PROXY_NAMES = /^(caddy|nginx|traefik|haproxy|envoy|openresty)$/i;
|
|
52
52
|
|
|
53
53
|
const PASSWORD_STORAGE_EXPOSURE = 'хранилище опубликовано наружу: слабый или подобранный пароль '
|
|
54
54
|
+ 'вместе с возможностями сервера (например, COPY … TO PROGRAM) даёт путь к компрометации. '
|
|
@@ -251,6 +251,26 @@ const isStorage = (svc) =>
|
|
|
251
251
|
isStorageImage(svc.image) || svc.ports.some((p) => STORAGE_PORT.has(Number(p.target)));
|
|
252
252
|
const isProxy = (svc) => imageParts(svc.image).some((part) => PROXY_NAMES.test(part));
|
|
253
253
|
|
|
254
|
+
function unrecognizedServiceText(svc) {
|
|
255
|
+
const publicPorts = svc.ports
|
|
256
|
+
.filter((port) => port.published && !isLoopback(port.hostIp))
|
|
257
|
+
.map((port) => port.published);
|
|
258
|
+
const surfaces = [];
|
|
259
|
+
if (svc.networkMode === 'host') surfaces.push('network_mode: host');
|
|
260
|
+
if (publicPorts.length === 1) surfaces.push('порт ' + publicPorts[0] + ' наружу');
|
|
261
|
+
if (publicPorts.length > 1) surfaces.push('порты ' + publicPorts.join(', ') + ' наружу');
|
|
262
|
+
return svc.name + (surfaces.length ? ' (' + surfaces.join('; ') + ')' : '');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function catalogScopeText(services, storage, proxies) {
|
|
266
|
+
const unrecognized = services.filter((svc) => !isStorage(svc) && !isProxy(svc));
|
|
267
|
+
const counts = 'из ' + services.length + ' сервисов распознано хранилищ ' + storage.length
|
|
268
|
+
+ ', reverse-proxy ' + proxies.length;
|
|
269
|
+
if (!unrecognized.length) return counts + '; все сервисы распознаны в этой области проверки';
|
|
270
|
+
return counts + '; НЕ распознаны и не проверялись: '
|
|
271
|
+
+ unrecognized.map(unrecognizedServiceText).join(', ');
|
|
272
|
+
}
|
|
273
|
+
|
|
254
274
|
function storageExposureMessage(image) {
|
|
255
275
|
const name = imageName(image);
|
|
256
276
|
if (NO_PASSWORD_STORAGE_NAMES.test(name)) {
|
|
@@ -338,6 +358,8 @@ function checkCatalog(arg) {
|
|
|
338
358
|
|
|
339
359
|
const bad = [];
|
|
340
360
|
const unknown = [];
|
|
361
|
+
const storage = services.filter(isStorage);
|
|
362
|
+
const proxies = services.filter(isProxy);
|
|
341
363
|
|
|
342
364
|
for (const svc of services) {
|
|
343
365
|
// network_mode: host publishes everything the container listens on, with no ports: entry at all.
|
|
@@ -371,7 +393,6 @@ function checkCatalog(arg) {
|
|
|
371
393
|
|
|
372
394
|
// Behind a reverse-proxy the proxy is the only door; anything published beside it can be reached
|
|
373
395
|
// directly, bypassing everything the proxy guarantees.
|
|
374
|
-
const proxies = services.filter(isProxy);
|
|
375
396
|
if (proxies.length) {
|
|
376
397
|
for (const svc of services) {
|
|
377
398
|
if (isProxy(svc)) continue;
|
|
@@ -389,8 +410,8 @@ function checkCatalog(arg) {
|
|
|
389
410
|
process.exit(2);
|
|
390
411
|
}
|
|
391
412
|
if (bad.length) process.exit(1);
|
|
392
|
-
say('✅ каталог ' + file
|
|
393
|
-
+
|
|
413
|
+
say('✅ каталог ' + file + ' проверен в заявленной области: '
|
|
414
|
+
+ catalogScopeText(services, storage, proxies));
|
|
394
415
|
process.exit(0);
|
|
395
416
|
}
|
|
396
417
|
|