@dzhechkov/p-replicator 1.5.18 → 1.6.0
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 +126 -46
- package/CHANGELOG.md +135 -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 +6 -2
- package/README/eng/README.md +1 -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 +1 -1
- package/README/ru/html/index.html +7 -7
- package/README.md +36 -15
- package/bin/cli.js +0 -0
- package/package.json +11 -10
- package/sbom.json +245 -45
- package/src/utils.js +2 -0
- package/templates/.claude/agents/doc-validator.md +2 -1
- package/templates/.claude/agents/product-discoverer.md +1 -1
- package/templates/.claude/commands/next.md +16 -0
- package/templates/.claude/commands/replicate.md +126 -8
- package/templates/.claude/commands/start.md +19 -1
- package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
- package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
- package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
- package/templates/.claude/hooks/check-ports.cjs +232 -0
- package/templates/.claude/hooks/session-insights.cjs +13 -1
- package/templates/.claude/hooks/state-update.cjs +13 -1
- package/templates/.claude/hooks/statusline.cjs +145 -18
- package/templates/.claude/rules/docker-ports.md +123 -0
- package/templates/.claude/rules/replicate-pipeline.md +5 -2
- package/templates/.claude/settings.json +5 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
- package/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
- package/tests/e2e/lifecycle.test.js +21 -10
- package/tests/snapshot/baseline.json +29 -27
- package/tests/snapshot/update-baseline.js +2 -1
- package/tests/unit/adr-decision-coverage.test.js +137 -0
- package/tests/unit/adr-scanner-contract.test.js +108 -0
- package/tests/unit/autocommit-deletion.test.js +242 -0
- package/tests/unit/check-ports.test.js +184 -0
- package/tests/unit/db-port-rule.test.js +216 -0
- package/tests/unit/detect-parse-anchor.test.js +109 -0
- package/tests/unit/external-dependency-check.test.js +209 -0
- package/tests/unit/growth-module-b2b-gate.test.js +104 -0
- package/tests/unit/hooks-project-anchored.test.js +223 -0
- package/tests/unit/hooks-report-failures.test.js +207 -0
- package/tests/unit/pipeline-file-ownership.test.js +95 -0
- package/tests/unit/roadmap-one-schema.test.js +179 -0
- package/tests/unit/sparc-reconciliation.test.js +117 -0
- package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
- package/tests/unit/statusline-honest-labels.test.js +178 -0
- package/tests/unit/statusline-two-roots.test.js +237 -0
- package/tests/unit/sync-templates-guard.test.js +209 -0
- package/tests/unit/utils.test.js +2 -2
- package/tests/unit/validation-gate-teeth.test.js +158 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* check-ports.cjs — enforce `.claude/rules/docker-ports.md` («Правило №0») against a real compose.
|
|
6
|
+
*
|
|
7
|
+
* NOT an event hook. Like `state-update.cjs`, it lives here because this directory already carries
|
|
8
|
+
* plain Node utilities; nothing registers it in settings.json and nothing runs it on a schedule.
|
|
9
|
+
* Invoke it deliberately:
|
|
10
|
+
*
|
|
11
|
+
* node .claude/hooks/check-ports.cjs [path-to-project | path-to-compose-file]
|
|
12
|
+
*
|
|
13
|
+
* Exit codes — three, and the third is the point:
|
|
14
|
+
* 0 the rule holds
|
|
15
|
+
* 1 the rule is violated (each violation is printed with the service and the remedy)
|
|
16
|
+
* 2 THE CHECK DID NOT RUN — no compose, no docker, or a config that would not parse
|
|
17
|
+
*
|
|
18
|
+
* A guard that answers "clean" when it could not look is worse than no guard: it converts an unknown
|
|
19
|
+
* into a reassurance. That is why an absent compose, a missing docker and an unreadable config all
|
|
20
|
+
* exit 2 rather than 0.
|
|
21
|
+
*
|
|
22
|
+
* It implements the RULE, not any prior script. In particular it checks `network_mode: host`, which
|
|
23
|
+
* publishes everything a container listens on without any `ports:` entry at all.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const { spawnSync } = require('node:child_process');
|
|
29
|
+
|
|
30
|
+
// Storage recognised the way the rule names it — by image and by well-known port. Both lists live
|
|
31
|
+
// here, side by side, so a reader extending one can see the other.
|
|
32
|
+
// Recognition is a HEURISTIC and stays one — but it must not be a substring match. A substring
|
|
33
|
+
// called `rediscommander/redis-commander` storage (it is a web UI), and missed
|
|
34
|
+
// `mcr.microsoft.com/mssql/server` on its standard 1433 entirely. So: take the image NAME —
|
|
35
|
+
// the last path component, without registry, tag or digest — and anchor to it.
|
|
36
|
+
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;
|
|
37
|
+
const STORAGE_PORT = new Set([5432, 3306, 27017, 6379, 9200, 9300, 5672, 11211, 9000, 8123,
|
|
38
|
+
1433, 9042, 7687, 8086, 2379, 5984]);
|
|
39
|
+
const PROXY_NAMES = /^(caddy|nginx|traefik|haproxy|envoy)$/i;
|
|
40
|
+
|
|
41
|
+
/** `mcr.microsoft.com/mssql/server:2022` → `server`; `postgres:16` → `postgres`. */
|
|
42
|
+
function imageName(image) {
|
|
43
|
+
const noDigest = String(image || '').split('@')[0];
|
|
44
|
+
const last = noDigest.split('/').pop() || '';
|
|
45
|
+
return last.split(':')[0];
|
|
46
|
+
}
|
|
47
|
+
/** The whole path matters too: mssql/server names the engine one component up. */
|
|
48
|
+
function imageParts(image) {
|
|
49
|
+
const noDigest = String(image || '').split('@')[0].split(':')[0];
|
|
50
|
+
return noDigest.split('/').filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function say(s) { process.stdout.write(s + '\n'); }
|
|
54
|
+
|
|
55
|
+
/** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
|
|
56
|
+
function cannotCheck(reason, hint) {
|
|
57
|
+
say('⚠️ проверка НЕ выполнена: ' + reason);
|
|
58
|
+
if (hint) say(' ' + hint);
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveCompose(arg) {
|
|
63
|
+
const target = arg || '.';
|
|
64
|
+
let file = target;
|
|
65
|
+
try {
|
|
66
|
+
if (fs.statSync(target).isDirectory()) file = path.join(target, 'docker-compose.yml');
|
|
67
|
+
} catch {
|
|
68
|
+
cannotCheck('путь не существует: ' + target);
|
|
69
|
+
}
|
|
70
|
+
if (!fs.existsSync(file)) cannotCheck('нет файла ' + file);
|
|
71
|
+
return file;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The normalised config. Parsing the raw YAML would re-implement `extends`, interpolation and the
|
|
75
|
+
* short `"5432:5432"` form — and the short form is exactly where a hand parser gets host_ip wrong. */
|
|
76
|
+
function normalisedConfig(file) {
|
|
77
|
+
const r = spawnSync('docker', ['compose', '-f', file, 'config'],
|
|
78
|
+
{ encoding: 'utf8', cwd: path.dirname(path.resolve(file)) });
|
|
79
|
+
if (r.error && r.error.code === 'ENOENT') {
|
|
80
|
+
cannotCheck('docker недоступен на этой машине',
|
|
81
|
+
'без него нормализованный конфиг получить нечем, а разбирать YAML руками — значит ошибиться на короткой форме портов');
|
|
82
|
+
}
|
|
83
|
+
if (r.status !== 0) {
|
|
84
|
+
const why = String(r.stderr || '').trim().split('\n')[0] || 'причина неизвестна';
|
|
85
|
+
cannotCheck('docker compose config вернул ошибку: ' + why,
|
|
86
|
+
'обычно это незаданная переменная; посмотреть: docker compose -f ' + file + ' config');
|
|
87
|
+
}
|
|
88
|
+
return String(r.stdout || '');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Services with what this check needs.
|
|
93
|
+
*
|
|
94
|
+
* The parser must know WHICH FIELD it is inside. A first version started a port record at every YAML
|
|
95
|
+
* sequence item and matched `published:` anywhere on a line, so this file — a perfectly legal one —
|
|
96
|
+
* reported a violation that does not exist:
|
|
97
|
+
*
|
|
98
|
+
* services:
|
|
99
|
+
* db:
|
|
100
|
+
* image: postgres:16
|
|
101
|
+
* command:
|
|
102
|
+
* - postgres
|
|
103
|
+
* - -c
|
|
104
|
+
* - "log_line_prefix=published: 6543"
|
|
105
|
+
*
|
|
106
|
+
* A check that invents violations is worse than one that misses them: it teaches people to ignore it.
|
|
107
|
+
* So: indentation decides the service, the field under a service is tracked by name, and only inside
|
|
108
|
+
* `ports:` does a sequence item begin a port record. Every field pattern is anchored to the start of
|
|
109
|
+
* the line.
|
|
110
|
+
*/
|
|
111
|
+
function parseServices(yaml) {
|
|
112
|
+
const services = [];
|
|
113
|
+
let cur = null;
|
|
114
|
+
let inServices = false;
|
|
115
|
+
let field = ''; // the service-level key we are inside right now
|
|
116
|
+
let fieldIndent = 0;
|
|
117
|
+
let port = null;
|
|
118
|
+
|
|
119
|
+
const flush = () => { if (port && cur) { cur.ports.push(port); } port = null; };
|
|
120
|
+
|
|
121
|
+
for (const raw of yaml.split('\n')) {
|
|
122
|
+
if (/^services:\s*$/.test(raw)) { inServices = true; continue; }
|
|
123
|
+
if (/^\S/.test(raw)) { flush(); inServices = /^services:/.test(raw); cur = null; field = ''; continue; }
|
|
124
|
+
if (!inServices || !raw.trim()) continue;
|
|
125
|
+
|
|
126
|
+
const indent = raw.length - raw.replace(/^\s+/, '').length;
|
|
127
|
+
|
|
128
|
+
const svc = raw.match(/^ {2}([A-Za-z0-9_.-]+):\s*$/);
|
|
129
|
+
if (svc) {
|
|
130
|
+
flush();
|
|
131
|
+
cur = { name: svc[1], image: '', networkMode: '', ports: [] };
|
|
132
|
+
services.push(cur);
|
|
133
|
+
field = ''; fieldIndent = 0;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!cur) continue;
|
|
137
|
+
|
|
138
|
+
// A service-level key: ` image:`, ` ports:`, ` command:` …
|
|
139
|
+
const key = raw.match(/^ {4}([A-Za-z0-9_]+):\s*(.*)$/);
|
|
140
|
+
if (key) {
|
|
141
|
+
flush();
|
|
142
|
+
field = key[1];
|
|
143
|
+
fieldIndent = indent;
|
|
144
|
+
if (field === 'image') cur.image = key[2].trim();
|
|
145
|
+
if (field === 'network_mode') cur.networkMode = key[2].trim().replace(/^"|"$/g, '');
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (indent <= fieldIndent && field) { /* still inside the same field's block */ }
|
|
149
|
+
|
|
150
|
+
// Only inside `ports:` does a sequence item mean anything to this check. Under `command:`,
|
|
151
|
+
// `environment:` or `labels:` a line may contain any text at all, including the word published.
|
|
152
|
+
if (field !== 'ports') continue;
|
|
153
|
+
|
|
154
|
+
if (/^\s+-\s/.test(raw)) { flush(); port = { hostIp: '', published: '', target: '' }; }
|
|
155
|
+
if (!port) continue;
|
|
156
|
+
const hip = raw.match(/^\s+host_ip:\s*"?([^"\s]+)"?\s*$/);
|
|
157
|
+
if (hip) { port.hostIp = hip[1]; continue; }
|
|
158
|
+
const pub = raw.match(/^\s+published:\s*"?([^"\s]+)"?\s*$/);
|
|
159
|
+
if (pub) { port.published = pub[1]; continue; }
|
|
160
|
+
const tgt = raw.match(/^\s+target:\s*"?([0-9]+)"?\s*$/);
|
|
161
|
+
if (tgt) { port.target = tgt[1]; continue; }
|
|
162
|
+
// The short form survives normalisation in some versions: `- "127.0.0.1:55432:5432"`.
|
|
163
|
+
const short = raw.match(/^\s+-\s+"?(?:(\[?[0-9a-fA-F.:]+\]?):)?([0-9]+):([0-9]+)"?\s*$/);
|
|
164
|
+
if (short) { port.hostIp = short[1] || ''; port.published = short[2]; port.target = short[3]; }
|
|
165
|
+
}
|
|
166
|
+
flush();
|
|
167
|
+
return services;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const isLoopback = (hostIp) => /^127\./.test(hostIp) || hostIp === '::1' || hostIp === '[::1]';
|
|
171
|
+
const isStorage = (svc) =>
|
|
172
|
+
imageParts(svc.image).some((part) => STORAGE_NAMES.test(part))
|
|
173
|
+
|| svc.ports.some((p) => STORAGE_PORT.has(Number(p.target)));
|
|
174
|
+
const isProxy = (svc) => imageParts(svc.image).some((part) => PROXY_NAMES.test(part));
|
|
175
|
+
|
|
176
|
+
function main() {
|
|
177
|
+
const file = resolveCompose(process.argv[2]);
|
|
178
|
+
const services = parseServices(normalisedConfig(file));
|
|
179
|
+
if (!services.length) {
|
|
180
|
+
cannotCheck('в нормализованном конфиге не нашлось ни одного сервиса',
|
|
181
|
+
'разбор мог не совпасть с форматом вывода вашей версии docker — это не «нарушений нет»');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const bad = [];
|
|
185
|
+
|
|
186
|
+
for (const svc of services) {
|
|
187
|
+
// network_mode: host publishes everything the container listens on, with no ports: entry at all.
|
|
188
|
+
// The rule forbids it for storage; the script this was rewritten from never checked it.
|
|
189
|
+
if (isStorage(svc) && svc.networkMode === 'host') {
|
|
190
|
+
bad.push(svc.name + ': network_mode: host — контейнер слушает прямо на хосте, публикации не '
|
|
191
|
+
+ 'видно, а порт наружу. Хранилищу этот режим не подходит');
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (!isStorage(svc)) continue;
|
|
195
|
+
for (const p of svc.ports) {
|
|
196
|
+
if (!p.published) continue;
|
|
197
|
+
if (isLoopback(p.hostIp)) continue; // the loopback exception IS part of the rule
|
|
198
|
+
bad.push(svc.name + ': порт ' + p.published + ' → ' + (p.target || '?')
|
|
199
|
+
+ (p.hostIp ? ' (host_ip ' + p.hostIp + ')' : ' (без адреса — значит все интерфейсы)')
|
|
200
|
+
+ ' — хранилище опубликовано наружу. Убрать ports: целиком, либо привязать к 127.0.0.1');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Behind a reverse-proxy the proxy is the only door; anything published beside it can be reached
|
|
205
|
+
// directly, bypassing everything the proxy guarantees.
|
|
206
|
+
const proxies = services.filter(isProxy);
|
|
207
|
+
if (proxies.length) {
|
|
208
|
+
for (const svc of services) {
|
|
209
|
+
if (isProxy(svc)) continue;
|
|
210
|
+
const pub = svc.ports.filter((p) => p.published && !isLoopback(p.hostIp));
|
|
211
|
+
if (pub.length) {
|
|
212
|
+
bad.push(svc.name + ': публикуется рядом с reverse-proxy ('
|
|
213
|
+
+ proxies.map((p) => p.name).join(', ') + ') — прокси обходится прямым обращением');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (bad.length) {
|
|
219
|
+
say('❌ Правило №0 нарушено (.claude/rules/docker-ports.md):');
|
|
220
|
+
for (const b of bad) say(' • ' + b);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
say('✅ ни одно хранилище не публикует порт наружу, обходов reverse-proxy нет');
|
|
224
|
+
process.exit(0);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
main();
|
|
229
|
+
} catch (err) {
|
|
230
|
+
// Even an unexpected failure must not read as "clean".
|
|
231
|
+
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
|
|
232
|
+
}
|
|
@@ -11,7 +11,19 @@
|
|
|
11
11
|
const fs = require('node:fs');
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// The project root, never the process cwd: a `cd` inside any tool call moves cwd for the rest of
|
|
15
|
+
// the session, and these hooks are non-blocking, so a wrong anchor fails SILENTLY. CLAUDE_PROJECT_DIR
|
|
16
|
+
// first — the host is authoritative about what the project is. `__dirname` second: a hook always
|
|
17
|
+
// lives at <project>/.claude/hooks/<x>.cjs, so its own location settles the root with no cooperation
|
|
18
|
+
// from anyone, which is what keeps this working when the variable is absent (hand-run, older host).
|
|
19
|
+
const ENV_ROOT = process.env.CLAUDE_PROJECT_DIR;
|
|
20
|
+
// isAbsolute, not just truthy: a RELATIVE value would still be resolved against the drifting
|
|
21
|
+
// cwd, which is the very bug this anchor exists to remove.
|
|
22
|
+
const ROOT = (ENV_ROOT && path.isAbsolute(ENV_ROOT))
|
|
23
|
+
? ENV_ROOT
|
|
24
|
+
: path.resolve(__dirname, '..', '..');
|
|
25
|
+
|
|
26
|
+
const INDEX = path.resolve(ROOT, '.claude', 'insights', 'index.md');
|
|
15
27
|
|
|
16
28
|
try {
|
|
17
29
|
if (!fs.existsSync(INDEX)) process.exit(0);
|
|
@@ -26,7 +26,19 @@
|
|
|
26
26
|
const fs = require('node:fs');
|
|
27
27
|
const path = require('node:path');
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// The project root, never the process cwd: a `cd` inside any tool call moves cwd for the rest of
|
|
30
|
+
// the session, and these hooks are non-blocking, so a wrong anchor fails SILENTLY. CLAUDE_PROJECT_DIR
|
|
31
|
+
// first — the host is authoritative about what the project is. `__dirname` second: a hook always
|
|
32
|
+
// lives at <project>/.claude/hooks/<x>.cjs, so its own location settles the root with no cooperation
|
|
33
|
+
// from anyone, which is what keeps this working when the variable is absent (hand-run, older host).
|
|
34
|
+
const ENV_ROOT = process.env.CLAUDE_PROJECT_DIR;
|
|
35
|
+
// isAbsolute, not just truthy: a RELATIVE value would still be resolved against the drifting
|
|
36
|
+
// cwd, which is the very bug this anchor exists to remove.
|
|
37
|
+
const ROOT = (ENV_ROOT && path.isAbsolute(ENV_ROOT))
|
|
38
|
+
? ENV_ROOT
|
|
39
|
+
: path.resolve(__dirname, '..', '..');
|
|
40
|
+
|
|
41
|
+
const STATE_FILE = path.resolve(ROOT, '.claude', '.p-replicator-state.json');
|
|
30
42
|
|
|
31
43
|
function parseArgs(argv) {
|
|
32
44
|
const out = {};
|
|
@@ -17,7 +17,25 @@
|
|
|
17
17
|
const fs = require('node:fs');
|
|
18
18
|
const path = require('node:path');
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// The project root, never the process cwd: a `cd` inside any tool call moves cwd for the rest of
|
|
21
|
+
// the session, and these hooks are non-blocking, so a wrong anchor fails SILENTLY. CLAUDE_PROJECT_DIR
|
|
22
|
+
// first — the host is authoritative about what the project is. `__dirname` second: a hook always
|
|
23
|
+
// lives at <project>/.claude/hooks/<x>.cjs, so its own location settles the root with no cooperation
|
|
24
|
+
// from anyone, which is what keeps this working when the variable is absent (hand-run, older host).
|
|
25
|
+
const ENV_ROOT = process.env.CLAUDE_PROJECT_DIR;
|
|
26
|
+
// isAbsolute, not just truthy: a RELATIVE value would still be resolved against the drifting
|
|
27
|
+
// cwd, which is the very bug this anchor exists to remove.
|
|
28
|
+
const ROOT = (ENV_ROOT && path.isAbsolute(ENV_ROOT))
|
|
29
|
+
? ENV_ROOT
|
|
30
|
+
: path.resolve(__dirname, '..', '..');
|
|
31
|
+
|
|
32
|
+
const CWD = ROOT;
|
|
33
|
+
|
|
34
|
+
// Two questions, two names. WHERE THE INSTRUMENTS ARE is answered by the hook's own location and is
|
|
35
|
+
// correct from any cwd; WHICH PROJECT'S ROADMAP TO SHOW is a different question, answered by the
|
|
36
|
+
// survey below. Collapsing them into one anchor is what made the status line report half the truth
|
|
37
|
+
// from every directory: the toolkit half from the project directory, the roadmap half from the root.
|
|
38
|
+
const TOOLKIT_ROOT = ROOT;
|
|
21
39
|
const NOW = Date.now();
|
|
22
40
|
|
|
23
41
|
// ─── ANSI helpers ─────────────────────────────────────────────────────────
|
|
@@ -77,8 +95,8 @@ function parseState() {
|
|
|
77
95
|
return state;
|
|
78
96
|
}
|
|
79
97
|
|
|
80
|
-
function
|
|
81
|
-
const r = safeReadJson(
|
|
98
|
+
function summariseRoadmap(file) {
|
|
99
|
+
const r = safeReadJson(file);
|
|
82
100
|
if (!r || !Array.isArray(r.features)) return null;
|
|
83
101
|
const features = r.features;
|
|
84
102
|
const total = features.length;
|
|
@@ -87,7 +105,48 @@ function parseRoadmap() {
|
|
|
87
105
|
const blocked = features.filter((f) => f.status === 'blocked').length;
|
|
88
106
|
const mvp = features.filter((f) => f.priority === 'mvp');
|
|
89
107
|
const mvpDone = mvp.filter((f) => f.status === 'done').length;
|
|
90
|
-
|
|
108
|
+
// A roadmap that does not match the schema used to render "mvp 0/0" and say nothing, so the
|
|
109
|
+
// divergence became permanent: the number was right, the reader learned nothing. `priority` is a
|
|
110
|
+
// CLOSED set (.claude/commands/next.md). Anything else — a value like "critical", or MVP moved
|
|
111
|
+
// into `tags` leaving no `priority` at all — is marked, never silently counted as zero.
|
|
112
|
+
const PRIORITIES = ['mvp', 'high', 'medium', 'low'];
|
|
113
|
+
// EVERY feature must carry a priority from the set. Counting only the ones that already have a
|
|
114
|
+
// string hid the mixed case: one valid entry beside a missing or non-string sibling passed
|
|
115
|
+
// silently, which is the most likely real roadmap of all.
|
|
116
|
+
const offSchemaCount = features.filter(
|
|
117
|
+
(f) => typeof f.priority !== 'string' || !PRIORITIES.includes(f.priority)).length;
|
|
118
|
+
return { total, done, inProgress, blocked, mvpTotal: mvp.length, mvpDone, offSchemaCount };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseRoadmap() {
|
|
122
|
+
return summariseRoadmap(path.join(CWD, '.claude', 'feature-roadmap.json'));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A SURVEY, not a guess. The roadmap may live one level DOWN (a course with projects/01-app), which
|
|
126
|
+
// is why walking upward does not treat this symptom. Enumerating is honest where guessing is not,
|
|
127
|
+
// and a cwd-based guess is exactly the drift this whole line of work removed.
|
|
128
|
+
// This runs on EVERY prompt, so the survey is bounded on both axes: how many entries it will look
|
|
129
|
+
// at, and how large a file it will parse. Without those bounds a projects/ directory with hundreds
|
|
130
|
+
// of entries, or one enormous roadmap, would add its cost to every keystroke's status line.
|
|
131
|
+
const SUB_SCAN_LIMIT = 24; // entries examined per render
|
|
132
|
+
const SUB_ROADMAP_MAX_BYTES = 512 * 1024;
|
|
133
|
+
|
|
134
|
+
function parseSubProjects() {
|
|
135
|
+
const base = path.join(CWD, 'projects');
|
|
136
|
+
const out = [];
|
|
137
|
+
let looked = 0;
|
|
138
|
+
for (const name of safeListDir(base).sort()) { // sorted: the same render every time
|
|
139
|
+
if (looked >= SUB_SCAN_LIMIT) break;
|
|
140
|
+
looked += 1;
|
|
141
|
+
const dir = path.join(base, name);
|
|
142
|
+
if (!safeRun(() => fs.statSync(dir).isDirectory(), false)) continue; // a FILE named projects/x
|
|
143
|
+
const file = path.join(dir, '.claude', 'feature-roadmap.json');
|
|
144
|
+
const size = safeRun(() => fs.statSync(file).size, -1);
|
|
145
|
+
if (size < 0 || size > SUB_ROADMAP_MAX_BYTES) continue; // absent or absurd
|
|
146
|
+
const sum = summariseRoadmap(file);
|
|
147
|
+
if (sum) out.push({ name, ...sum }); // malformed → skipped
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
91
150
|
}
|
|
92
151
|
|
|
93
152
|
function parseSparcDocs() {
|
|
@@ -155,7 +214,7 @@ function parseInsights() {
|
|
|
155
214
|
}
|
|
156
215
|
|
|
157
216
|
function parseToolkit() {
|
|
158
|
-
const dir = path.join(
|
|
217
|
+
const dir = path.join(TOOLKIT_ROOT, '.claude');
|
|
159
218
|
const skills = safeListDir(path.join(dir, 'skills')).filter((d) => {
|
|
160
219
|
return safeRun(() => fs.statSync(path.join(dir, 'skills', d)).isDirectory(), false);
|
|
161
220
|
}).length;
|
|
@@ -176,13 +235,13 @@ function parseExpectedToolkit() {
|
|
|
176
235
|
skillsExpected: 10,
|
|
177
236
|
commandsExpected: 11,
|
|
178
237
|
agentsExpected: 4, // pre-shipped only (project agents are extra)
|
|
179
|
-
rulesExpected:
|
|
180
|
-
hooksExpected:
|
|
238
|
+
rulesExpected: 6, // pre-shipped only (project rules are extra)
|
|
239
|
+
hooksExpected: 7, // 4 v1.4.1 hooks + statusline + state-update + check-ports
|
|
181
240
|
};
|
|
182
241
|
}
|
|
183
242
|
|
|
184
243
|
function parseSettingsStatus(manifest) {
|
|
185
|
-
const settingsPath = path.join(
|
|
244
|
+
const settingsPath = path.join(TOOLKIT_ROOT, '.claude', 'settings.json');
|
|
186
245
|
if (!exists(settingsPath)) return 'missing';
|
|
187
246
|
const cur = safeReadJson(settingsPath);
|
|
188
247
|
if (!cur) return 'corrupt';
|
|
@@ -211,11 +270,53 @@ function parseKeysarium() {
|
|
|
211
270
|
function parseDomain() {
|
|
212
271
|
const claudeMd = safeReadText(path.join(CWD, 'CLAUDE.md'));
|
|
213
272
|
if (!claudeMd) return null;
|
|
214
|
-
// Heuristic keyword search
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
273
|
+
// Heuristic keyword search, with UNICODE word boundaries.
|
|
274
|
+
//
|
|
275
|
+
// Without them this was wrong in four ways at once, all reproduced: "healthchecks" made a project
|
|
276
|
+
// medical, "translate" and "slack" made it enterprise (`sla` sits inside both), and "embankment"
|
|
277
|
+
// made it a bank.
|
|
278
|
+
//
|
|
279
|
+
// `\b` is NOT the fix and would quietly break the Russian half: it is defined over \w =
|
|
280
|
+
// [A-Za-z0-9_], so there is no word boundary between a space and `б`. MEASURED —
|
|
281
|
+
// /\bбанк/.test('банк России') is FALSE. Hence Unicode lookarounds with the `u` flag.
|
|
282
|
+
//
|
|
283
|
+
// Two kinds of term, deliberately distinguished. A STEM is meant to match inflections — `банк` in
|
|
284
|
+
// «банкинг», `финт` in «финтех», `рекоменд` in «рекомендательный» — so it takes a LEFT boundary
|
|
285
|
+
// only. A WHOLE WORD (`sla`, `bank`, `conversion`) takes both, because matching it inside another
|
|
286
|
+
// word is exactly the defect.
|
|
287
|
+
const L = '(?<![\\p{L}\\p{N}])'; // start of a word, in any alphabet
|
|
288
|
+
const R = '(?![\\p{L}\\p{N}])'; // end of a word
|
|
289
|
+
const stem = (alts) => new RegExp(L + '(?:' + alts + ')', 'iu');
|
|
290
|
+
const word = (alts) => new RegExp(L + '(?:' + alts + ')' + R, 'iu');
|
|
291
|
+
|
|
292
|
+
// A LEFT-only boundary is not enough for every Russian stem, and cross-family QE named the
|
|
293
|
+
// counter-examples: «Сервис организации банкетов» is not a bank, «Финты в футболе» is not fintech,
|
|
294
|
+
// «ЦБС городской библиотеки» is not the central bank. So the ambiguous stems are spelled out with
|
|
295
|
+
// their real inflections and right-bounded, and the two-letter acronym `цб` is dropped outright —
|
|
296
|
+
// two letters cannot be made safe by any boundary.
|
|
297
|
+
const bankingStem = stem('банковск|финтех|фз-152|фстэк');
|
|
298
|
+
const bankingWord = word('банк(?:а|у|ом|е|и|ов|ам|ами|ах|инг)?|bank(?:ing|s|er|ers)?'
|
|
299
|
+
+ '|fintech|gigachat|yandexgpt');
|
|
300
|
+
// `retail` IS a stem — "Retailer inventory analytics" is retail and whole-word-only missed it.
|
|
301
|
+
// `conversion` alone is not: "Video conversion service" is not commerce. It needs its qualifier.
|
|
302
|
+
const retailStem = stem('ритейл|рекоменд|retail');
|
|
303
|
+
// `\\w` is [A-Za-z0-9_] and does NOT cover Cyrillic — the same trap as `\\b`, one level down:
|
|
304
|
+
// «конверси» + \\w* + a right boundary fails on «конверсию», because `ю` is neither \\w nor a
|
|
305
|
+
// boundary. Use \\p{L}. And the Russian term needs its qualifier for the same reason the English
|
|
306
|
+
// one does: «конверсия файлов» is no more commerce than "video conversion" is.
|
|
307
|
+
const retailWord = word('e-?commerce|conversion\\s+rate'
|
|
308
|
+
+ '|конверси\\p{L}*\\s+(?:воронк|продаж|лид)\\p{L}*');
|
|
309
|
+
const enterpriseWord = word('enterprise(?:s)?|b2b|legacy|sla|change\\s*management');
|
|
310
|
+
// `health` alone cannot be made right: bounded it stops matching "healthcare", unbounded it matches
|
|
311
|
+
// "healthcheck". So it is replaced by the forms that actually mean the domain — including the
|
|
312
|
+
// space-separated "health tech", which the first version missed.
|
|
313
|
+
const healthcareStem = stem('медицин|клиник|фз-323');
|
|
314
|
+
const healthcareWord = word('health[\\s-]?(?:care|tech)|healthcare|medical|hipaa');
|
|
315
|
+
|
|
316
|
+
const banking = { test: (t) => bankingStem.test(t) || bankingWord.test(t) };
|
|
317
|
+
const retail = { test: (t) => retailStem.test(t) || retailWord.test(t) };
|
|
318
|
+
const enterprise = enterpriseWord;
|
|
319
|
+
const healthcare = { test: (t) => healthcareStem.test(t) || healthcareWord.test(t) };
|
|
219
320
|
if (banking.test(claudeMd)) return 'banking';
|
|
220
321
|
if (retail.test(claudeMd)) return 'retail';
|
|
221
322
|
if (enterprise.test(claudeMd)) return 'enterprise';
|
|
@@ -281,16 +382,37 @@ function buildPipeline(state) {
|
|
|
281
382
|
return parts.join(' ' + dim('│') + ' ');
|
|
282
383
|
}
|
|
283
384
|
|
|
284
|
-
function buildRoadmap(roadmap, domain) {
|
|
385
|
+
function buildRoadmap(roadmap, domain, subProjects) {
|
|
386
|
+
const subs = Array.isArray(subProjects) ? subProjects : [];
|
|
285
387
|
if (!roadmap) {
|
|
388
|
+
// No roadmap at the root. If sub-projects have one, report THEIRS — labelled as theirs. The old
|
|
389
|
+
// line said "no roadmap yet" while thirteen features sat one directory down.
|
|
390
|
+
if (subs.length > 0) {
|
|
391
|
+
const total = subs.reduce((n, s) => n + s.total, 0);
|
|
392
|
+
const done = subs.reduce((n, s) => n + s.done, 0);
|
|
393
|
+
const where = subs.length === 1 ? subs[0].name : subs.length + ' projects';
|
|
394
|
+
return [
|
|
395
|
+
`🎯 ${bold('Roadmap')}`,
|
|
396
|
+
`${dim('in')} ${cyan(where)}`,
|
|
397
|
+
`${dotBar(done, total, 8)} ${dim('Done')} ${green(done + '/' + total)}`,
|
|
398
|
+
].join(` ${dim('│')} `);
|
|
399
|
+
}
|
|
286
400
|
return `🎯 ${bold('Roadmap')} ${dim('— no roadmap yet (run /next or /replicate)')}`;
|
|
287
401
|
}
|
|
288
|
-
const { total, done, inProgress, blocked, mvpTotal, mvpDone } = roadmap;
|
|
402
|
+
const { total, done, inProgress, blocked, mvpTotal, mvpDone, offSchemaCount } = roadmap;
|
|
289
403
|
const dotbar = dotBar(done, total, 8);
|
|
290
404
|
const parts = [
|
|
291
405
|
`🎯 ${bold('Roadmap')}`,
|
|
292
|
-
|
|
293
|
-
|
|
406
|
+
// The bar is drawn from done/total, so it is captioned Done. It used to sit beside `mvp`, which
|
|
407
|
+
// it never showed — one glyph and two different quantities, read as one statement. The bar is
|
|
408
|
+
// CORRECT and stays; the caption is what was wrong, and hiding a working indicator to mask a
|
|
409
|
+
// wrong caption would have been the wrong half of the fix.
|
|
410
|
+
`${dotbar} ${dim('Done')} ${green(done + '/' + total)}`,
|
|
411
|
+
offSchemaCount > 0
|
|
412
|
+
// The known count is still knowledge — replacing it with '?' throws away what WAS established
|
|
413
|
+
// and tells the reader less than before. Show both: what is counted, and how much was not.
|
|
414
|
+
? `${dim('mvp')} ${green(mvpDone + '/' + mvpTotal)} ${yellow('⚠' + offSchemaCount)} ${dim('schema')}`
|
|
415
|
+
: `${dim('mvp')} ${green(mvpDone + '/' + mvpTotal)}`,
|
|
294
416
|
];
|
|
295
417
|
if (inProgress) {
|
|
296
418
|
parts.push(`${dim('▶')} ${cyan(inProgress.id)}`);
|
|
@@ -298,6 +420,10 @@ function buildRoadmap(roadmap, domain) {
|
|
|
298
420
|
if (blocked > 0) {
|
|
299
421
|
parts.push(`${red('Blocked')} ${blocked}`);
|
|
300
422
|
}
|
|
423
|
+
if (subs.length > 0) {
|
|
424
|
+
const subTotal = subs.reduce((n, s) => n + s.total, 0);
|
|
425
|
+
parts.push(`${dim('+' + subs.length + ' sub')} ${green(String(subTotal))}`);
|
|
426
|
+
}
|
|
301
427
|
if (domain) {
|
|
302
428
|
parts.push(`${dim('Domain:')} ${cyan(domain)}`);
|
|
303
429
|
}
|
|
@@ -367,6 +493,7 @@ function main() {
|
|
|
367
493
|
const manifest = safeRun(() => parseManifest(), null);
|
|
368
494
|
const state = safeRun(() => parseState(), null);
|
|
369
495
|
const roadmap = safeRun(() => parseRoadmap(), null);
|
|
496
|
+
const subProjects = safeRun(() => parseSubProjects(), []);
|
|
370
497
|
const sparc = safeRun(() => parseSparcDocs(), { present: 0, total: 11 });
|
|
371
498
|
const validation = safeRun(() => parseValidationScore(), null);
|
|
372
499
|
const adrs = safeRun(() => parseAdrs(), 0);
|
|
@@ -384,7 +511,7 @@ function main() {
|
|
|
384
511
|
const lines = [
|
|
385
512
|
buildHeader(manifest),
|
|
386
513
|
buildPipeline(state),
|
|
387
|
-
buildRoadmap(roadmap, domain),
|
|
514
|
+
buildRoadmap(roadmap, domain, subProjects),
|
|
388
515
|
buildDocs(sparc, validation, plans, adrs, lastHarvest),
|
|
389
516
|
buildToolkit(toolkit, expected),
|
|
390
517
|
buildStatus(insights, lastTest, mcpServers, settingsStatus, keysarium),
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Docker-порты: что смотрит наружу, а что нет
|
|
2
|
+
|
|
3
|
+
## Правило №0 — хранилище не публикуется в интернет
|
|
4
|
+
|
|
5
|
+
**Инвариант:** у хранилища НЕТ публикации на хост — кроме привязки к петле. Postgres, MySQL,
|
|
6
|
+
MariaDB, MongoDB, Redis, Elasticsearch/OpenSearch, MinIO, RabbitMQ, Memcached, ClickHouse.
|
|
7
|
+
|
|
8
|
+
Формулировка «не на все интерфейсы» была бы слишком узкой и пропускала бы половину случаев. Запрещено
|
|
9
|
+
всё это, а не только первое:
|
|
10
|
+
|
|
11
|
+
| Строка | Почему нарушение |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `"5432:5432"` | без адреса Docker привязывает ко ВСЕМ интерфейсам |
|
|
14
|
+
| `"0.0.0.0:5432:5432"` | то же самое, явно |
|
|
15
|
+
| `"[::]:5432:5432"` | IPv6-джокер — та же дыра, часто забывают |
|
|
16
|
+
| `"203.0.113.7:5432:5432"` | ОДИН явный публичный адрес: не «все интерфейсы», но из интернета доступно ровно так же |
|
|
17
|
+
| `network_mode: host` | публикации нет вовсе, а порт всё равно наружу: контейнер слушает прямо на хосте. Хранилищу этот режим не подходит |
|
|
18
|
+
|
|
19
|
+
Разрешено ровно два состояния: публикации нет, либо она привязана к петле (`127.0.0.1` или `::1`).
|
|
20
|
+
|
|
21
|
+
**Почему это правило №0, а не пожелание.** Тестовый Postgres, поднятый с `-p 55432:5432` и паролем
|
|
22
|
+
`postgres`, был взломан из интернета примерно за час: через `COPY … TO PROGRAM` на машину въехал
|
|
23
|
+
червь-майнер. Порт был открыт «на время отладки». Цена ошибки здесь — компрометация машины, а не
|
|
24
|
+
неудобство, поэтому правило безусловное.
|
|
25
|
+
|
|
26
|
+
## Что писать вместо
|
|
27
|
+
|
|
28
|
+
Достаточно **убрать `ports:` целиком**:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
services:
|
|
32
|
+
db:
|
|
33
|
+
image: postgres:16
|
|
34
|
+
# никакого ports: — и это ПОЛНОЕ решение
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Соседи по сети compose достучатся до сервиса по его имени** (`db:5432`) без всякой публикации:
|
|
38
|
+
внутри сети видны все порты, которые контейнер слушает. Публикация нужна только для доступа С ХОСТА,
|
|
39
|
+
а не между контейнерами.
|
|
40
|
+
|
|
41
|
+
`expose:` в современном Compose ничего не открывает и ничего не требуется — это документирующая
|
|
42
|
+
запись, не более:
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
expose:
|
|
46
|
+
- "5432" # необязательно; читается как «этот сервис слушает 5432»
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Писать её можно, полагаться на неё как на «то, что включает доступ» — нельзя: доступ внутри сети есть
|
|
50
|
+
и без неё.
|
|
51
|
+
|
|
52
|
+
## Исключение: петля — это законно
|
|
53
|
+
|
|
54
|
+
Привязка к петлевому адресу разрешена и является нормальным способом поднять тестовое окружение,
|
|
55
|
+
где тесты идут с хоста:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
ports:
|
|
59
|
+
- "127.0.0.1:55432:5432" # ЗАКОННО: доступно только с самой машины
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Петлевых форм две и они равноправны: `127.0.0.1:` и `[::1]:`. Всё остальное — нарушение, включая
|
|
63
|
+
`0.0.0.0`, `[::]`, отсутствие адреса вообще и один явный внешний адрес. Docker показывает привязку
|
|
64
|
+
как `host_ip:`; «доступно мне» от «доступно всем» отличает именно она.
|
|
65
|
+
|
|
66
|
+
Правило без этого исключения запрещало бы ровно то, что предписывает сам конвейер, поэтому исключение
|
|
67
|
+
такая же часть правила, как и запрет.
|
|
68
|
+
|
|
69
|
+
## За reverse-proxy приложение тоже не публикуется
|
|
70
|
+
|
|
71
|
+
Если в compose есть `caddy`/`nginx`/`traefik`, то он и есть единственная дверь. Опубликованное рядом
|
|
72
|
+
приложение позволяет обойти прокси — а вместе с ним и всё, что прокси гарантирует: TLS,
|
|
73
|
+
rate-limiting, заголовки. Известный конкретный случай: код, доверяющий последнему элементу
|
|
74
|
+
`X-Forwarded-For`, потому что его дописывает прокси, при прямом доступе получает этот элемент от
|
|
75
|
+
клиента — и лимит обходится сменой заголовка.
|
|
76
|
+
|
|
77
|
+
## Чем это правило НЕ является
|
|
78
|
+
|
|
79
|
+
**Проверка теперь есть — но она не запускается сама.** В пакете отгружается
|
|
80
|
+
`.claude/hooks/check-ports.cjs`: он читает нормализованный конфиг (`docker compose config`) и
|
|
81
|
+
проверяет ровно этот инвариант, включая `network_mode: host` и обход reverse-proxy.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
node .claude/hooks/check-ports.cjs . # или путь к конкретному compose-файлу
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Коды возврата — три, и третий здесь главный: `0` правило соблюдено, `1` нарушено, **`2` проверка НЕ
|
|
88
|
+
ВЫПОЛНЕНА** (нет compose, нет docker, конфиг не читается). Проверка, которая на нечитаемом конфиге
|
|
89
|
+
отвечает «чисто», хуже отсутствия проверки: она превращает неизвестность в заверение.
|
|
90
|
+
|
|
91
|
+
**Чего она НЕ делает.** Она не привязана ни к какому событию — её надо позвать. Она смотрит на ВАШ
|
|
92
|
+
проект, а не на машину: конфликты с портами, уже занятыми другими контейнерами, она не ищет. И она
|
|
93
|
+
проверяет конфиг, а не запущенный стек.
|
|
94
|
+
|
|
95
|
+
Детерминированная проверка существует отдельно (`check-port-conflicts.sh`, «Правило №0») и в этот
|
|
96
|
+
пакет не входит. Причина: она на bash, а ХУКИ пакета принципиально на кросс-платформенном Node —
|
|
97
|
+
ради Windows, — и проверка портов по форме именно хук. (Точности ради: пара вспомогательных `.sh`
|
|
98
|
+
в пакете уже есть, внутри навыка `brutal-honesty-review`; обещание Node относится к хукам, а не ко
|
|
99
|
+
всему пакету.) Если проверка вам нужна, её надо взять и подключить осознанно.
|
|
100
|
+
|
|
101
|
+
Не выводите из присутствия этого правила, что кто-то за вас смотрит. Пока проверки нет, единственный
|
|
102
|
+
контроль — глаза на ревью, и знать об этом важнее, чем иметь правило.
|
|
103
|
+
|
|
104
|
+
## Быстрая самопроверка
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
docker compose config
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Смотреть надо весь нормализованный вывод, а не окно вокруг `published`: Docker печатает `host_ip`
|
|
111
|
+
отдельной строкой, и она не обязана попасть в фиксированные `-A2` — проверка по окну молча пропустит
|
|
112
|
+
нарушение.
|
|
113
|
+
|
|
114
|
+
Для каждого сервиса-хранилища ответьте на два вопроса:
|
|
115
|
+
|
|
116
|
+
1. есть ли у него `published:` (или `network_mode: host`)?
|
|
117
|
+
2. если есть — стоит ли рядом `host_ip: 127.0.0.1` либо `host_ip: ::1`?
|
|
118
|
+
|
|
119
|
+
«Да» на первый и «нет» на второй — нарушение правила №0. Обе петлевые формы равноправны: проверка
|
|
120
|
+
только по `127.0.0.1` отвергла бы `::1`, который это же правило разрешает.
|
|
121
|
+
|
|
122
|
+
Детерминированная версия этой проверки существует и разбирает вывод с отслеживанием состояния именно
|
|
123
|
+
потому, что построчного окна недостаточно.
|