@dzhechkov/p-replicator 1.10.4 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.dz-manifest.json +312 -76
  2. package/CHANGELOG.md +232 -0
  3. package/MULTIPLATFORM_ROADMAP.md +1 -1
  4. package/README/eng/01_quickstart.md +3 -3
  5. package/README/eng/02_user_guide.md +1 -1
  6. package/README/eng/03_admin_guide.md +2 -2
  7. package/README/eng/04_api_reference.md +11 -5
  8. package/README/eng/05_architecture.md +1 -1
  9. package/README/eng/README.md +2 -1
  10. package/README/ru/01_quickstart.md +3 -3
  11. package/README/ru/02_user_guide.md +1 -1
  12. package/README/ru/03_admin_guide.md +2 -2
  13. package/README/ru/04_api_reference.md +11 -5
  14. package/README/ru/05_architecture.md +1 -1
  15. package/README/ru/README.md +2 -1
  16. package/README/ru/html/index.html +9 -9
  17. package/README.md +278 -39
  18. package/package.json +5 -4
  19. package/sbom.json +665 -75
  20. package/scripts/check-pipeline-gaps.sh +413 -0
  21. package/src/commands/doctor.js +94 -4
  22. package/src/commands/init.js +1 -1
  23. package/src/rule-components.json +15 -0
  24. package/src/utils.js +35 -11
  25. package/templates/.claude/agents/harvest-coordinator.md +10 -1
  26. package/templates/.claude/agents/product-discoverer.md +38 -0
  27. package/templates/.claude/agents/replicate-coordinator.md +11 -1
  28. package/templates/.claude/commands/feature.md +81 -9
  29. package/templates/.claude/commands/go.md +9 -0
  30. package/templates/.claude/commands/harvest.md +39 -3
  31. package/templates/.claude/commands/myinsights.md +21 -26
  32. package/templates/.claude/commands/replicate.md +171 -37
  33. package/templates/.claude/commands/start.md +29 -0
  34. package/templates/.claude/hooks/capture-source-path.cjs +795 -0
  35. package/templates/.claude/hooks/check-canon.cjs +493 -0
  36. package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
  37. package/templates/.claude/hooks/check-external-deps.cjs +288 -0
  38. package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
  39. package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
  40. package/templates/.claude/hooks/check-job-contract.cjs +501 -0
  41. package/templates/.claude/hooks/check-look-origin.cjs +240 -0
  42. package/templates/.claude/hooks/check-look-trace.cjs +385 -0
  43. package/templates/.claude/hooks/check-metric-source.cjs +296 -0
  44. package/templates/.claude/hooks/check-model-cost.cjs +470 -0
  45. package/templates/.claude/hooks/check-ports.cjs +434 -24
  46. package/templates/.claude/hooks/check-source-version.cjs +312 -0
  47. package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
  48. package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
  49. package/templates/.claude/hooks/session-insights.cjs +158 -25
  50. package/templates/.claude/hooks/statusline.cjs +2 -2
  51. package/templates/.claude/hooks/write-insight.cjs +253 -0
  52. package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
  53. package/templates/.claude/rules/docker-ports.md +41 -19
  54. package/templates/.claude/rules/embeddable-widget.md +73 -0
  55. package/templates/.claude/rules/feature-lifecycle.md +13 -3
  56. package/templates/.claude/rules/honest-configuration.md +54 -0
  57. package/templates/.claude/rules/incoming-webhooks.md +99 -0
  58. package/templates/.claude/rules/insights-capture.md +10 -5
  59. package/templates/.claude/rules/long-running-job.md +73 -0
  60. package/templates/.claude/rules/model-call-cost.md +85 -0
  61. package/templates/.claude/rules/replicate-pipeline.md +123 -52
  62. package/templates/.claude/rules/skill-interface-protocol.md +1 -0
  63. package/templates/.claude/rules/swarm-file-evidence.md +46 -0
  64. package/templates/.claude/settings.json +13 -1
  65. package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
  66. package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
  67. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
  68. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
  69. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
  70. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
  71. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
  72. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
  73. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
  74. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
  75. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
  76. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
  77. package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
  78. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
  79. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
  80. package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
  81. package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
  82. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
  83. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
  84. package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
  85. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
  86. package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
  87. package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
  88. package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
  89. package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
  90. package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
  91. package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
  92. package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
  93. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
  94. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +234 -716
  95. package/tests/e2e/lifecycle.test.js +55 -9
  96. package/tests/e2e/packed-insights-writer.test.js +308 -0
  97. package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
  98. package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
  99. package/tests/snapshot/baseline.json +72 -46
  100. package/tests/snapshot/templates.test.js +47 -0
  101. package/tests/unit/absence-is-not-emptiness.test.js +15 -1
  102. package/tests/unit/capture-source-path.test.js +492 -0
  103. package/tests/unit/check-canon.test.js +403 -0
  104. package/tests/unit/check-embed-contract.test.js +422 -0
  105. package/tests/unit/check-external-deps.test.js +363 -0
  106. package/tests/unit/check-file-ownership.test.js +388 -0
  107. package/tests/unit/check-handoff-manifest.test.js +410 -0
  108. package/tests/unit/check-job-contract.test.js +514 -0
  109. package/tests/unit/check-look-origin.test.js +180 -0
  110. package/tests/unit/check-look-trace.test.js +420 -0
  111. package/tests/unit/check-metric-source.test.js +325 -0
  112. package/tests/unit/check-model-cost.test.js +425 -0
  113. package/tests/unit/check-pipeline-gaps.test.js +94 -0
  114. package/tests/unit/check-ports.test.js +773 -2
  115. package/tests/unit/check-source-version.test.js +344 -0
  116. package/tests/unit/check-swarm-receipts.test.js +231 -0
  117. package/tests/unit/check-webhook-contract.test.js +536 -0
  118. package/tests/unit/db-port-rule.test.js +43 -6
  119. package/tests/unit/detection-ladder-contract.test.js +302 -0
  120. package/tests/unit/detection-ladder-registry.test.js +52 -0
  121. package/tests/unit/doctor-insight-flow.test.js +315 -0
  122. package/tests/unit/external-dependency-check.test.js +19 -19
  123. package/tests/unit/generator-swarm-contract.test.js +287 -0
  124. package/tests/unit/guard-honest-input-meta.test.js +64 -0
  125. package/tests/unit/honest-failure-rules.test.js +574 -0
  126. package/tests/unit/hooks-project-anchored.test.js +67 -3
  127. package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
  128. package/tests/unit/insights-dz-delegation.test.js +197 -0
  129. package/tests/unit/insights-writer.test.js +285 -0
  130. package/tests/unit/look-phase-contract.test.js +231 -0
  131. package/tests/unit/negative-conclusion-gate.test.js +300 -0
  132. package/tests/unit/quote-provenance.test.js +122 -0
  133. package/tests/unit/shipped-suite-context.test.js +3 -1
  134. package/tests/unit/traceability-machine-ids.test.js +413 -0
  135. package/tests/unit/traceability-negative-fixture.test.js +322 -0
  136. package/tests/unit/utils.test.js +40 -2
@@ -18,6 +18,7 @@ const { test, describe } = require('node:test');
18
18
  const assert = require('node:assert/strict');
19
19
  const { spawnSync } = require('node:child_process');
20
20
  const fs = require('node:fs');
21
+ const net = require('node:net');
21
22
  const os = require('node:os');
22
23
  const path = require('node:path');
23
24
 
@@ -25,7 +26,12 @@ const PKG = path.resolve(__dirname, '..', '..');
25
26
  const TPL = path.join(PKG, 'templates', '.claude');
26
27
  const CHECK = path.join(TPL, 'hooks', 'check-ports.cjs');
27
28
 
28
- const DOCKER = spawnSync('docker', ['--version'], { encoding: 'utf8' }).status === 0;
29
+ const dockerVersion = spawnSync('docker', ['--version'], { encoding: 'utf8' });
30
+ const composeVersion = spawnSync('docker', ['compose', 'version'], { encoding: 'utf8' });
31
+ const dockerInfo = spawnSync('docker', ['info'], { encoding: 'utf8' });
32
+ const DOCKER = !dockerVersion.error && dockerVersion.status === 0;
33
+ const COMPOSE = !composeVersion.error && composeVersion.status === 0;
34
+ const DOCKER_DAEMON = !dockerInfo.error && dockerInfo.status === 0;
29
35
 
30
36
  /** Write a compose file into a throwaway directory and run the real check over it. */
31
37
  function check(compose, opts) {
@@ -33,13 +39,158 @@ function check(compose, opts) {
33
39
  const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-ports-')));
34
40
  try {
35
41
  if (compose !== null) fs.writeFileSync(path.join(dir, 'docker-compose.yml'), compose);
36
- const env = Object.assign({}, process.env);
42
+ const env = Object.assign({}, process.env, o.env || {});
37
43
  if (o.noDocker) env.PATH = dir; // an empty PATH: docker cannot be found
38
44
  const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8', env });
39
45
  return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
40
46
  } finally { fs.rmSync(dir, { recursive: true, force: true }); }
41
47
  }
42
48
 
49
+ /** Run the delivered checker with stdout/stderr kept separate. */
50
+ function runCheck(args, opts) {
51
+ const o = opts || {};
52
+ const env = Object.assign({}, process.env, o.env || {});
53
+ return spawnSync(process.execPath, [CHECK].concat(args || []), {
54
+ cwd: o.cwd,
55
+ encoding: 'utf8',
56
+ env,
57
+ timeout: o.timeout || 10000,
58
+ });
59
+ }
60
+
61
+ /** A recording Docker boundary for deterministic inventory/inspect/exec failure cases. */
62
+ function fakeDockerCheck(scenario, args, opts) {
63
+ const o = opts || {};
64
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-fake-docker-')));
65
+ const scenarioFile = path.join(dir, 'scenario.json');
66
+ const historyFile = path.join(dir, 'history.jsonl');
67
+ const project = path.join(dir, 'project');
68
+ fs.mkdirSync(project);
69
+ fs.writeFileSync(path.join(project, 'docker-compose.yml'), 'services: {}\n');
70
+ fs.writeFileSync(scenarioFile, JSON.stringify(scenario));
71
+ const fake = path.join(dir, 'docker');
72
+ fs.writeFileSync(fake, `#!${process.execPath}
73
+ 'use strict';
74
+ const fs = require('node:fs');
75
+ const { spawnSync } = require('node:child_process');
76
+ const scenario = JSON.parse(fs.readFileSync(process.env.FAKE_DOCKER_SCENARIO, 'utf8'));
77
+ const args = process.argv.slice(2);
78
+ fs.appendFileSync(process.env.FAKE_DOCKER_HISTORY, JSON.stringify(args) + '\\n');
79
+ const op = args[0];
80
+ if (scenario.hang === op) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000000);
81
+ if (op === 'compose' && scenario.delegateCompose) {
82
+ const result = spawnSync(process.env.REAL_DOCKER, args, { encoding: 'utf8' });
83
+ process.stdout.write(result.stdout || '');
84
+ process.stderr.write(result.stderr || '');
85
+ process.exit(result.status === null ? 2 : result.status);
86
+ }
87
+ const configured = scenario[op];
88
+ if (configured && !Array.isArray(configured) && typeof configured === 'object'
89
+ && Object.prototype.hasOwnProperty.call(configured, 'status')) {
90
+ process.stdout.write(configured.stdout || '');
91
+ process.stderr.write(configured.stderr || '');
92
+ process.exit(configured.status);
93
+ }
94
+ if (op === 'ps') {
95
+ for (const row of scenario.ps || []) process.stdout.write(
96
+ (typeof row === 'string' ? row : JSON.stringify(row)) + '\\n');
97
+ process.exit(0);
98
+ }
99
+ if (op === 'inspect') {
100
+ process.stdout.write(JSON.stringify(scenario.inspect || []));
101
+ process.exit(0);
102
+ }
103
+ if (op === 'exec') {
104
+ const answer = (scenario.exec || {})[args[1]] || { status: 2, stderr: 'unknown container' };
105
+ process.stdout.write(answer.stdout || '');
106
+ process.stderr.write(answer.stderr || '');
107
+ process.exit(answer.status);
108
+ }
109
+ if (op === 'compose') {
110
+ process.stdout.write(scenario.compose || '');
111
+ process.exit(0);
112
+ }
113
+ process.stderr.write('unexpected fake docker operation');
114
+ process.exit(2);
115
+ `);
116
+ fs.chmodSync(fake, 0o755);
117
+ try {
118
+ const env = Object.assign({}, process.env, o.env || {}, {
119
+ PATH: dir,
120
+ FAKE_DOCKER_SCENARIO: scenarioFile,
121
+ FAKE_DOCKER_HISTORY: historyFile,
122
+ REAL_DOCKER: spawnSync('which', ['docker'], { encoding: 'utf8' }).stdout.trim(),
123
+ });
124
+ const actualArgs = (args || []).map((arg) => arg === '$PROJECT' ? project : arg);
125
+ const result = runCheck(actualArgs, { env, timeout: o.timeout || 10000 });
126
+ const history = fs.existsSync(historyFile)
127
+ ? fs.readFileSync(historyFile, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse)
128
+ : [];
129
+ return { result, history };
130
+ } finally {
131
+ fs.rmSync(dir, { recursive: true, force: true });
132
+ }
133
+ }
134
+
135
+ const inspectRecord = (id, image, options) => {
136
+ const o = options || {};
137
+ return {
138
+ Id: id,
139
+ Name: '/' + (o.name || id),
140
+ Config: { Image: image, Cmd: o.cmd || [], Env: o.env || [], Labels: o.labels || {} },
141
+ HostConfig: { NetworkMode: o.networkMode || 'bridge' },
142
+ NetworkSettings: { Ports: o.ports || {} },
143
+ Mounts: o.mounts || [],
144
+ };
145
+ };
146
+
147
+ function removeContainer(name) {
148
+ spawnSync('docker', ['rm', '-f', name], { encoding: 'utf8' });
149
+ }
150
+
151
+ function waitForContainer(name) {
152
+ for (let attempt = 0; attempt < 50; attempt += 1) {
153
+ const state = spawnSync('docker', ['inspect', '-f', '{{.State.Running}}', name], {
154
+ encoding: 'utf8',
155
+ });
156
+ if (state.status === 0 && state.stdout.trim() === 'true') return;
157
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
158
+ }
159
+ assert.fail('container did not reach running state: ' + name);
160
+ }
161
+
162
+ function preflightHostPort(port) {
163
+ return new Promise((resolve, reject) => {
164
+ const server = net.createServer();
165
+ server.once('error', (error) => reject(new Error(
166
+ 'live fixture host port ' + port + ' is not free; proof cannot run: ' + error.message)));
167
+ server.listen({ host: '0.0.0.0', port, exclusive: true }, () => {
168
+ server.close((error) => error ? reject(error) : resolve());
169
+ });
170
+ });
171
+ }
172
+
173
+ function isolatedDockerOrSkip(t) {
174
+ const running = spawnSync('docker', ['ps', '-q'], { encoding: 'utf8' });
175
+ assert.equal(running.error, undefined, 'isolated Docker inventory failed: ' + running.error);
176
+ assert.equal(running.status, 0, 'isolated Docker inventory failed: ' + running.stderr);
177
+ if (running.stdout.trim() !== '') {
178
+ // Ambient containers are not a defect of this package, but they DO change what
179
+ // `--machine` scans, so the live discrimination proof cannot be trusted here.
180
+ // A shared dev host therefore SKIPS (loudly, like the no-daemon case above);
181
+ // an isolated CI context sets DZ_REQUIRE_LIVE_PORTS=1, where skipping is forbidden
182
+ // and this becomes the hard failure it must be for the gate-tracked proof.
183
+ if (process.env.DZ_REQUIRE_LIVE_PORTS === '1') {
184
+ assert.fail('live proof requires an isolated Docker context; ambient containers make the '
185
+ + 'result non-proving, and DZ_REQUIRE_LIVE_PORTS=1 forbids skipping');
186
+ }
187
+ t.skip('ambient containers present — live proof not provable on a shared host '
188
+ + '(DZ_REQUIRE_LIVE_PORTS=1 in an isolated context forces it)');
189
+ return false;
190
+ }
191
+ return true;
192
+ }
193
+
43
194
  const storagePublished = `services:
44
195
  db:
45
196
  image: postgres:16
@@ -163,6 +314,145 @@ describe('the storage-port rule finally has a check that can fail', () => {
163
314
  }
164
315
  });
165
316
 
317
+ test('E2 — Class A and Class B exposure warnings stay mechanically distinct',
318
+ { skip: !DOCKER }, () => {
319
+ const classA = ['postgres', 'mysql', 'mariadb', 'mongo'];
320
+ const classB = ['redis', 'valkey', 'keydb', 'memcached'];
321
+ const exposure = (image) => {
322
+ const result = check('services:\n storage:\n image: ' + image + ':latest\n'
323
+ + ' ports: ["15432:5432"]\n');
324
+ assert.equal(result.code, 1, image + ' must remain a publication violation: ' + result.out);
325
+ const line = result.out.split('\n').find((row) => row.includes('• storage: порт')) || '';
326
+ assert.match(line, /storage: порт 15432 → 5432/,
327
+ image + ' warning must retain the observed service and binding: ' + result.out);
328
+ assert.match(line, /Убрать ports:.*compose-сет/,
329
+ image + ' warning must give the concrete replacement: ' + result.out);
330
+ return line;
331
+ };
332
+
333
+ for (const image of classA) {
334
+ const line = exposure(image);
335
+ assert.match(line, /слабый или подобранный пароль/,
336
+ image + ' must name the Class A password threat: ' + line);
337
+ assert.match(line, /COPY .* TO PROGRAM/,
338
+ image + ' must name the Class A server-capability consequence: ' + line);
339
+ assert.doesNotMatch(line, /доступ без аутентификации/,
340
+ image + ' must not receive the Class B mechanism: ' + line);
341
+ }
342
+
343
+ for (const image of classB) {
344
+ const line = exposure(image);
345
+ assert.match(line, /по умолчанию не требует пароля/,
346
+ image + ' must name the Class B default: ' + line);
347
+ assert.match(line, /доступ без аутентификации/,
348
+ image + ' must name the Class B immediate consequence: ' + line);
349
+ assert.doesNotMatch(line, /слабый или подобранный пароль|COPY .* TO PROGRAM/,
350
+ image + ' must never receive the Class A mechanism: ' + line);
351
+ }
352
+
353
+ for (const [image, target] of [['elasticsearch:8', 9200], ['acme/custom-store:1', 5432]]) {
354
+ const result = check('services:\n storage:\n image: ' + image + '\n'
355
+ + ' ports: ["15432:' + target + '"]\n');
356
+ assert.equal(result.code, 1, image + ' must remain recognized: ' + result.out);
357
+ const line = result.out.split('\n').find((row) => row.includes('• storage: порт')) || '';
358
+ assert.match(line, /хранилище опубликовано наружу/,
359
+ image + ' must retain the neutral exposure warning: ' + result.out);
360
+ assert.doesNotMatch(line,
361
+ /слабый или подобранный пароль|COPY .* TO PROGRAM|по умолчанию не требует пароля|доступ без аутентификации/,
362
+ image + ' must not receive an invented authentication class: ' + line);
363
+ }
364
+ });
365
+
366
+ test('E2 — recognition literals, verdict codes and loopback exception remain frozen',
367
+ { skip: !DOCKER }, () => {
368
+ const source = fs.readFileSync(CHECK, 'utf8');
369
+ const namesMatch = source.match(/const STORAGE_NAMES = \/\^\(([^)]+)\)\$\/i;/);
370
+ const portsMatch = source.match(/const STORAGE_PORT = new Set\(\[([\s\S]*?)\]\);/);
371
+ assert.ok(namesMatch, 'the existing STORAGE_NAMES literal must remain readable');
372
+ assert.ok(portsMatch, 'the existing STORAGE_PORT literal must remain readable');
373
+ assert.deepEqual(namesMatch[1].split('|'), [
374
+ 'postgres', 'postgresql', 'pgvector', 'mysql', 'mariadb', 'percona', 'mongo', 'mongodb',
375
+ 'redis', 'valkey', 'keydb', 'elasticsearch', 'opensearch', 'minio', 'rabbitmq',
376
+ 'memcached', 'clickhouse', 'cassandra', 'scylla', 'neo4j', 'influxdb', 'timescaledb',
377
+ 'mssql', 'sqlserver', 'couchdb', 'etcd', 'qdrant',
378
+ ]);
379
+ assert.deepEqual([...portsMatch[1].matchAll(/\d+/g)].map((match) => Number(match[0])), [
380
+ 5432, 3306, 27017, 6379, 9200, 9300, 5672, 11211, 9000, 8123, 1433, 9042, 7687,
381
+ 8086, 2379, 5984, 6333,
382
+ ]);
383
+ const proxiesMatch = source.match(/const PROXY_NAMES = \/\^\(([^)]+)\)\$\/i;/);
384
+ assert.ok(proxiesMatch, 'the existing PROXY_NAMES literal must remain readable');
385
+ assert.deepEqual(proxiesMatch[1].split('|'), [
386
+ 'caddy', 'nginx', 'traefik', 'haproxy', 'envoy', 'openresty',
387
+ ]);
388
+
389
+ const seen = [
390
+ check('services:\n db:\n image: postgres:16\n').code,
391
+ check(storagePublished).code,
392
+ check(null).code,
393
+ ];
394
+ assert.deepEqual(seen, [0, 1, 2], 'clean / violation / not-performed codes must stay fixed');
395
+
396
+ const portOnly = check('services:\n db:\n image: acme/custom-store:1\n'
397
+ + ' ports: ["15432:5432"]\n');
398
+ assert.equal(portOnly.code, 1, 'known storage ports must remain recognized: ' + portOnly.out);
399
+ const lookalike = check('services:\n ui:\n image: rediscommander/redis-commander:latest\n'
400
+ + ' ports: ["8081:8081"]\n');
401
+ assert.equal(lookalike.code, 0, 'lookalike image names must remain rejected: ' + lookalike.out);
402
+ for (const bind of ['127.0.0.1:55432:5432', '[::1]:55432:5432']) {
403
+ const loopback = check('services:\n db:\n image: postgres:16\n'
404
+ + ' ports: ["' + bind + '"]\n');
405
+ assert.equal(loopback.code, 0, bind + ' must remain inside the loopback exception: '
406
+ + loopback.out);
407
+ }
408
+ });
409
+
410
+ test('E2 — the same publication shape emits different Class A and Class B text',
411
+ { skip: !DOCKER }, () => {
412
+ const warningFor = (image) => {
413
+ const result = check('services:\n storage:\n image: ' + image + '\n'
414
+ + ' ports: ["15432:5432"]\n');
415
+ assert.equal(result.code, 1, result.out);
416
+ return result.out.split('\n').find((row) => row.includes('• storage: порт')) || '';
417
+ };
418
+ const classA = warningFor('postgres:16');
419
+ const classB = warningFor('redis:7');
420
+ assert.notEqual(classA, classB,
421
+ 'postgres and redis must not collapse to one generic publication warning');
422
+ assert.match(classA, /слабый или подобранный пароль/);
423
+ assert.match(classB, /доступ без аутентификации/);
424
+ });
425
+
426
+ test('E2 — checker and docker-ports rule carry the same class mechanics and remedy',
427
+ { skip: !DOCKER }, () => {
428
+ const rule = fs.readFileSync(path.join(TPL, 'rules', 'docker-ports.md'), 'utf8');
429
+ const warningFor = (image) => {
430
+ const result = check('services:\n storage:\n image: ' + image + '\n'
431
+ + ' ports: ["15432:5432"]\n');
432
+ assert.equal(result.code, 1, result.out);
433
+ return result.out.split('\n').find((row) => row.includes('• storage: порт')) || '';
434
+ };
435
+ const classA = warningFor('postgres:16');
436
+ const classB = warningFor('redis:7');
437
+
438
+ for (const text of [rule, classA]) {
439
+ assert.match(text, /слабый или подобранный пароль/,
440
+ 'Class A must retain its password threat in rule and checker');
441
+ assert.match(text, /COPY .* TO PROGRAM/,
442
+ 'Class A must retain the concrete server-capability consequence');
443
+ }
444
+ for (const text of [rule, classB]) {
445
+ assert.match(text, /по умолчанию не требует пароля/,
446
+ 'Class B must retain its no-password default in rule and checker');
447
+ assert.match(text, /доступ без аутентификации/,
448
+ 'Class B must retain its immediate consequence in rule and checker');
449
+ }
450
+ for (const text of [rule, classA, classB]) {
451
+ assert.match(text, /[Уу]брать `?ports:`?.*compose-сет/s,
452
+ 'rule and checker must prescribe removing ports: and using the compose network');
453
+ }
454
+ });
455
+
166
456
  test('P12 — several services, and the right one is named', { skip: !DOCKER }, () => {
167
457
  // Fields must not leak between services: the offender has to be identified, not just counted.
168
458
  const r = check('services:\n cache:\n image: redis:7\n ports: ["127.0.0.1:6379:6379"]\n'
@@ -275,6 +565,487 @@ describe('the storage-port rule finally has a check that can fail', () => {
275
565
  assert.match(src, /path\.resolve\(process\.cwd\(\)/,
276
566
  'the argument must be absolutised once, at the boundary');
277
567
  });
568
+
569
+ test('P18 — clean receipts name catalog or machine snapshot scope', () => {
570
+ const catalog = fakeDockerCheck({ compose: 'services:\n db:\n image: postgres:16\n' },
571
+ ['$PROJECT']);
572
+ assert.equal(catalog.result.status, 0, catalog.result.stdout + catalog.result.stderr);
573
+ assert.match(catalog.result.stdout, /каталог .*docker-compose\.yml/i,
574
+ 'catalog success must name the selected catalog file: ' + catalog.result.stdout);
575
+
576
+ const machine = fakeDockerCheck({ ps: [] }, ['--machine']);
577
+ assert.equal(machine.result.status, 0, machine.result.stdout + machine.result.stderr);
578
+ assert.match(machine.result.stdout, /снимок.*Docker-контекст/i,
579
+ 'machine success must be a point-in-time current-context receipt');
580
+ assert.ok(!/ни одно хранилище не публикует/.test(machine.result.stdout),
581
+ 'the former scope-free receipt is not a machine-wide claim');
582
+ });
583
+
584
+ test('P29 — qdrant exposure extends the recognized storage class', () => {
585
+ const qdrant = fakeDockerCheck({ compose: 'services:\n qdrant:\n'
586
+ + ' image: qdrant/qdrant:latest\n'
587
+ + ' ports:\n - target: 6333\n published: 6333\n' }, ['$PROJECT']);
588
+ assert.equal(qdrant.result.status, 1,
589
+ 'qdrant/qdrant:latest on 6333 must not retain the old green receipt: '
590
+ + qdrant.result.stdout + qdrant.result.stderr);
591
+ assert.match(qdrant.result.stdout, /qdrant/);
592
+ assert.match(qdrant.result.stdout, /6333/);
593
+ assert.doesNotMatch(qdrant.result.stdout, /✅/);
594
+ });
595
+
596
+ test('P30 — openresty makes a directly published app a proxy bypass', () => {
597
+ const bypass = fakeDockerCheck({ compose: 'services:\n gateway:\n'
598
+ + ' image: openresty/openresty:alpine\n'
599
+ + ' ports:\n - target: 80\n published: 80\n'
600
+ + ' app:\n image: node:22\n'
601
+ + ' ports:\n - target: 3000\n published: 3000\n' }, ['$PROJECT']);
602
+ assert.equal(bypass.result.status, 1,
603
+ 'OpenResty must establish the reverse-proxy class before bypass analysis: '
604
+ + bypass.result.stdout + bypass.result.stderr);
605
+ assert.match(bypass.result.stdout, /app/);
606
+ assert.match(bypass.result.stdout, /reverse-proxy.*gateway/i);
607
+ assert.doesNotMatch(bypass.result.stdout, /✅/);
608
+ });
609
+
610
+ test('P31 — honest exit 0 receipt counts scope and names an unchecked published app', () => {
611
+ const catalog = fakeDockerCheck({ compose: 'services:\n db:\n image: postgres:16\n'
612
+ + ' web:\n image: node:22\n'
613
+ + ' ports:\n - target: 3000\n published: 3000\n' }, ['$PROJECT']);
614
+ assert.equal(catalog.result.status, 0, catalog.result.stdout + catalog.result.stderr);
615
+ assert.match(catalog.result.stdout,
616
+ /из 2 сервисов распознано хранилищ 1, reverse-proxy 0/i);
617
+ assert.match(catalog.result.stdout,
618
+ /НЕ распознаны и не проверялись: web \(порт 3000 наружу\)/i);
619
+ assert.doesNotMatch(catalog.result.stdout, /ни одно хранилище не публикует/i,
620
+ 'a roster-scoped check must not certify an absolute class claim');
621
+ });
622
+
623
+ test('P19 — catalog mode preserves P12 and never invokes machine or runtime Docker commands',
624
+ { skip: !COMPOSE }, () => {
625
+ const absent = check('services:\n cache:\n image: redis:7\n');
626
+ assert.equal(absent.code, 1, 'positive absent-auth config evidence is a violation: ' + absent.out);
627
+ assert.match(absent.out, /аутентификац/i, absent.out);
628
+
629
+ const mounted = check('services:\n cache:\n image: redis:7\n'
630
+ + ' volumes:\n - "./redis.conf:/usr/local/etc/redis/redis.conf"\n');
631
+ assert.equal(mounted.code, 2, 'a mounted config makes auth indeterminate: ' + mounted.out);
632
+ assert.match(mounted.out, /проверка НЕ выполнена/, mounted.out);
633
+
634
+ const dataOnly = check('services:\n cache:\n image: redis:7\n'
635
+ + ' volumes:\n - "redis-data:/data"\nvolumes:\n redis-data:\n');
636
+ assert.equal(dataOnly.code, 1,
637
+ 'an explicit data-only target does not hide auth config: ' + dataOnly.out);
638
+
639
+ const customBuild = check('services:\n cache:\n image: redis:7\n build: .\n');
640
+ assert.equal(customBuild.code, 2, 'a custom build can embed unseen auth config: ' + customBuild.out);
641
+
642
+ const secret = 'P19_SENTINEL_DO_NOT_PRINT';
643
+ const authenticated = check('services:\n cache:\n image: redis:7\n'
644
+ + ' command: redis-server --requirepass ${REDIS_PASSWORD:?}\n',
645
+ { env: { REDIS_PASSWORD: secret } });
646
+ assert.equal(authenticated.code, 0, authenticated.out);
647
+ assert.ok(!(authenticated.out || '').includes(secret), 'config secrets must not reach output');
648
+
649
+ const envAuthenticated = check('services:\n cache:\n image: redis:7\n'
650
+ + ' environment:\n REDIS_PASSWORD: ${REDIS_PASSWORD:?}\n',
651
+ { env: { REDIS_PASSWORD: secret } });
652
+ assert.equal(envAuthenticated.code, 0, envAuthenticated.out);
653
+ assert.ok(!envAuthenticated.out.includes(secret), 'environment secrets must not reach output');
654
+
655
+ for (const environment of [
656
+ 'REDIS_PASSWORD: ""', 'REDIS_PASSWORD: " "', 'ALLOW_EMPTY_PASSWORD: "yes"',
657
+ 'REDIS_AUTH_ENABLED: "false"',
658
+ ]) {
659
+ const passwordless = check('services:\n cache:\n image: redis:7\n'
660
+ + ' environment:\n ' + environment + '\n');
661
+ assert.equal(passwordless.code, 1,
662
+ environment + ' is not positive password evidence: ' + passwordless.out);
663
+ }
664
+
665
+ const derived = check('services:\n cache:\n image: acme/redis:7\n');
666
+ assert.equal(derived.code, 2, 'a derived image can embed unseen auth config: ' + derived.out);
667
+
668
+ const emptyCommand = check('services:\n cache:\n image: redis:7\n'
669
+ + ' command: redis-server --requirepass ""\n');
670
+ assert.equal(emptyCommand.code, 1, 'an empty --requirepass is passwordless: ' + emptyCommand.out);
671
+ const whitespaceCommand = check('services:\n cache:\n image: redis:7\n'
672
+ + ' command: redis-server --requirepass " "\n');
673
+ assert.equal(whitespaceCommand.code, 1,
674
+ 'a whitespace-only --requirepass is passwordless: ' + whitespaceCommand.out);
675
+
676
+ const official = check('services:\n cache:\n image: docker.io/library/redis:7\n');
677
+ assert.equal(official.code, 1, 'the canonical official image is not an invented custom build');
678
+
679
+ const memcached = check('services:\n cache:\n image: memcached:1.6\n');
680
+ assert.equal(memcached.code, 1, 'Memcached shares the config evidence policy: ' + memcached.out);
681
+
682
+ const loopback = check('services:\n cache:\n image: redis:7\n'
683
+ + ' ports: ["127.0.0.1:6379:6379"]\n');
684
+ assert.equal(loopback.code, 0, 'P12 remains the catalog exception: ' + loopback.out);
685
+ assert.ok(!/cache/.test(loopback.out), 'the legal loopback cache must not be an offender');
686
+
687
+ const recordedMatrix = [
688
+ [1, 'services:\n cache:\n image: redis:7\n'],
689
+ [2, 'services:\n cache:\n image: redis:7\n volumes:\n - type: bind\n target: /etc/redis.conf\n'],
690
+ [1, 'services:\n cache:\n image: redis:7\n volumes:\n - type: volume\n target: /data\n'],
691
+ [2, 'services:\n cache:\n image: redis:7\n build:\n context: /fixture\n'],
692
+ [0, 'services:\n cache:\n image: redis:7\n command:\n - redis-server\n - --requirepass\n - secret\n'],
693
+ [0, 'services:\n cache:\n image: redis:7\n environment:\n REDIS_PASSWORD: secret\n'],
694
+ [1, 'services:\n cache:\n image: redis:7\n environment:\n REDIS_PASSWORD: ""\n'],
695
+ [0, 'services:\n cache:\n image: redis:7\n ports:\n - target: 6379\n published: 6379\n host_ip: 127.0.0.1\n'],
696
+ [1, 'services:\n cache:\n image: memcached:1.6\n'],
697
+ [2, 'services:\n cache:\n image: acme/redis:7\n'],
698
+ [1, 'services:\n cache:\n image: docker.io/library/redis:7\n'],
699
+ ];
700
+ for (const [expected, compose] of recordedMatrix) {
701
+ const recorded = fakeDockerCheck({ compose }, ['$PROJECT']);
702
+ assert.equal(recorded.result.status, expected, recorded.result.stdout + recorded.result.stderr);
703
+ assert.deepEqual(recorded.history.map((entry) => entry[0]), ['compose'],
704
+ 'every catalog auth branch may call compose only: ' + JSON.stringify(recorded.history));
705
+ }
706
+ });
707
+
708
+ test('P20 — partial machine observations reduce to exit 2 without hiding violations', () => {
709
+ const exposedId = 'a'.repeat(64);
710
+ const missingId = 'b'.repeat(64);
711
+ const exposed = inspectRecord(exposedId, 'registry.example/team/postgres:16', {
712
+ name: 'partial-exposed',
713
+ ports: { '5432/tcp': [{ HostIp: '0.0.0.0', HostPort: '15432' }] },
714
+ labels: { 'com.docker.compose.project': 'partial-project' },
715
+ });
716
+ const malformedInventory = fakeDockerCheck({
717
+ ps: [{ ID: exposedId, Image: 'registry.example/team/postgres:16', Names: 'partial-exposed' },
718
+ '{not-json'],
719
+ inspect: [exposed],
720
+ }, ['--machine']);
721
+ assert.equal(malformedInventory.result.status, 2,
722
+ malformedInventory.result.stdout + malformedInventory.result.stderr);
723
+ assert.match(malformedInventory.result.stdout, /partial-project/,
724
+ 'the established sibling violation and its owner must remain visible');
725
+ assert.match(malformedInventory.result.stderr, /проверка НЕ выполнена/);
726
+ assert.ok(!/✅/.test(malformedInventory.result.stdout + malformedInventory.result.stderr));
727
+ assert.deepEqual(malformedInventory.history[0],
728
+ ['ps', '--no-trunc', '--format', '{{json .}}'],
729
+ 'inventory must remain structured JSON-lines rather than human port parsing');
730
+
731
+ const partialInspect = fakeDockerCheck({
732
+ ps: [
733
+ { ID: exposedId, Image: 'postgres:16', Names: 'partial-exposed' },
734
+ { ID: missingId, Image: 'postgres:16', Names: 'vanished' },
735
+ ],
736
+ inspect: [exposed],
737
+ }, ['--machine']);
738
+ assert.equal(partialInspect.result.status, 2,
739
+ partialInspect.result.stdout + partialInspect.result.stderr);
740
+ assert.match(partialInspect.result.stdout, /storage-exposure|экспозиц|наружу/i);
741
+ assert.match(partialInspect.result.stderr, /inspect|проверка НЕ выполнена/i);
742
+
743
+ const unknownOnly = fakeDockerCheck({ ps: ['not-json-at-all'] }, ['--machine']);
744
+ assert.equal(unknownOnly.result.status, 2, unknownOnly.result.stdout + unknownOnly.result.stderr);
745
+ assert.equal(unknownOnly.history.filter((entry) => entry[0] === 'inspect').length, 0,
746
+ 'there is no trustworthy ID to inspect');
747
+
748
+ const malformedPorts = fakeDockerCheck({
749
+ ps: [{ ID: missingId, Image: 'postgres:16', Names: 'bad-ports-shape' }],
750
+ inspect: [inspectRecord(missingId, 'postgres:16', { ports: [] })],
751
+ }, ['--machine']);
752
+ assert.equal(malformedPorts.result.status, 2,
753
+ 'an array is not a valid Docker port map: '
754
+ + malformedPorts.result.stdout + malformedPorts.result.stderr);
755
+
756
+ const emptyBindings = fakeDockerCheck({
757
+ ps: [{ ID: missingId, Image: 'postgres:16', Names: 'empty-bindings' }],
758
+ inspect: [inspectRecord(missingId, 'postgres:16', {
759
+ ports: { ['5432/tcp\u202e forged']: [] },
760
+ })],
761
+ }, ['--machine']);
762
+ assert.equal(emptyBindings.result.status, 2,
763
+ 'an empty bindings array is malformed, not an unpublished clean state');
764
+ assert.ok(!(emptyBindings.result.stdout + emptyBindings.result.stderr).includes('\u202e'),
765
+ 'malformed target diagnostics remove terminal-direction controls');
766
+
767
+ const malformedAuth = inspectRecord(missingId, 'memcached:1.6', {
768
+ ports: { '11211/tcp': [{ HostIp: '127.0.0.1', HostPort: '11211' }] },
769
+ });
770
+ malformedAuth.Config.Env = [{ MEMCACHED_PASSWORD: 'invented' }];
771
+ const malformedMetadata = fakeDockerCheck({
772
+ ps: [{ ID: missingId, Image: 'memcached:1.6', Names: 'bad-auth-shape' }],
773
+ inspect: [malformedAuth],
774
+ }, ['--machine']);
775
+ assert.equal(malformedMetadata.result.status, 2,
776
+ 'malformed required auth metadata cannot become an invented verdict');
777
+ });
778
+
779
+ test('P17 — machine scope discriminates the live bind address',
780
+ { skip: !DOCKER_DAEMON }, async (t) => {
781
+ const suffix = process.pid + '-' + Date.now();
782
+ const name = 'p-rep-e1-postgres-' + suffix;
783
+ const owner = 'p-rep-e1-owner-' + suffix;
784
+ if (!isolatedDockerOrSkip(t)) return;
785
+ await preflightHostPort(15432);
786
+ const base = ['run', '-d', '--name', name,
787
+ '--label', 'com.docker.compose.project=' + owner,
788
+ '--label', 'com.docker.compose.project.config_files=/fixture/docker-compose.yml',
789
+ '-e', 'POSTGRES_PASSWORD=e1-live-fixture-password'];
790
+ try {
791
+ let started = spawnSync('docker', base.concat(
792
+ ['-p', '127.0.0.1:15432:5432', 'postgres:16-alpine']), { encoding: 'utf8' });
793
+ assert.equal(started.status, 0, 'loopback fixture failed to start: ' + started.stderr);
794
+ waitForContainer(name);
795
+ const loopback = runCheck(['--machine']);
796
+ assert.equal(loopback.status, 0, loopback.stdout + loopback.stderr);
797
+ assert.match(loopback.stdout, /снимок.*Docker-контекст/i);
798
+
799
+ removeContainer(name);
800
+ started = spawnSync('docker', base.concat(
801
+ ['-p', '15432:5432', 'postgres:16-alpine']), { encoding: 'utf8' });
802
+ assert.equal(started.status, 0, 'wildcard fixture failed to start: ' + started.stderr);
803
+ waitForContainer(name);
804
+ const wildcard = runCheck(['--machine']);
805
+ assert.equal(wildcard.status, 1, wildcard.stdout + wildcard.stderr);
806
+ assert.match(wildcard.stdout, new RegExp(owner));
807
+ assert.match(wildcard.stdout, /15432/);
808
+ } finally {
809
+ removeContainer(name);
810
+ }
811
+ });
812
+
813
+ test('P21 — runtime cache auth evidence is tri-state and secret-safe',
814
+ { skip: !DOCKER_DAEMON }, async (t) => {
815
+ const suffix = process.pid + '-' + Date.now();
816
+ const name = 'p-rep-e1-redis-' + suffix;
817
+ const owner = 'p-rep-e1-cache-' + suffix;
818
+ const secret = 'P21_SENTINEL_' + suffix;
819
+ if (!isolatedDockerOrSkip(t)) return;
820
+ await preflightHostPort(16379);
821
+ const base = ['run', '-d', '--name', name,
822
+ '--label', 'com.docker.compose.project=' + owner,
823
+ '-p', '127.0.0.1:16379:6379', 'redis:7-alpine'];
824
+ try {
825
+ let started = spawnSync('docker', base, { encoding: 'utf8' });
826
+ assert.equal(started.status, 0, 'passwordless Redis failed to start: ' + started.stderr);
827
+ waitForContainer(name);
828
+ const unprotected = runCheck(['--machine']);
829
+ assert.equal(unprotected.status, 1, unprotected.stdout + unprotected.stderr);
830
+ assert.match(unprotected.stdout, /live-cache-no-auth|живой.*без аутентификац/i);
831
+
832
+ removeContainer(name);
833
+ started = spawnSync('docker', base.concat(['redis-server', '--requirepass', secret]),
834
+ { encoding: 'utf8' });
835
+ assert.equal(started.status, 0, 'protected Redis failed to start: ' + started.stderr);
836
+ waitForContainer(name);
837
+ const protectedRun = runCheck(['--machine']);
838
+ assert.equal(protectedRun.status, 0, protectedRun.stdout + protectedRun.stderr);
839
+ assert.ok(!(protectedRun.stdout + protectedRun.stderr).includes(secret),
840
+ 'the runtime password must never reach either stream');
841
+ } finally {
842
+ removeContainer(name);
843
+ }
844
+ });
845
+
846
+ test('P21b — a failed runtime auth probe is indeterminate and secret-safe', () => {
847
+ const id = 'f'.repeat(64);
848
+ const secret = 'P21B_SENTINEL_DO_NOT_PRINT';
849
+ const failedProbe = fakeDockerCheck({
850
+ ps: [{ ID: id, Image: 'redis:7', Names: 'probe-failed' }],
851
+ inspect: [inspectRecord(id, 'redis:7', {
852
+ name: 'probe-failed',
853
+ ports: { '6379/tcp': [{ HostIp: '127.0.0.1', HostPort: '6379' }] },
854
+ })],
855
+ exec: { [id]: { status: 1, stderr: secret } },
856
+ }, ['--machine']);
857
+ assert.equal(failedProbe.result.status, 2,
858
+ failedProbe.result.stdout + failedProbe.result.stderr);
859
+ assert.match(failedProbe.result.stderr, /проверка НЕ выполнена/);
860
+ assert.ok(!(failedProbe.result.stdout + failedProbe.result.stderr).includes(secret),
861
+ 'probe errors must not relay secret-bearing stderr');
862
+
863
+ const unpublished = fakeDockerCheck({
864
+ ps: [{ ID: id, Image: 'redis:7', Names: 'private-redis' }],
865
+ inspect: [inspectRecord(id, 'redis:7', { name: 'private-redis' })],
866
+ }, ['--machine']);
867
+ assert.equal(unpublished.result.status, 0,
868
+ 'a container with no live publication has no machine auth axis: '
869
+ + unpublished.result.stdout + unpublished.result.stderr);
870
+ assert.equal(unpublished.history.filter((entry) => entry[0] === 'exec').length, 0);
871
+
872
+ const memId = '9'.repeat(64);
873
+ const loopback = { '11211/tcp': [{ HostIp: '127.0.0.1', HostPort: '11211' }] };
874
+ const memcachedCases = [
875
+ [1, inspectRecord(memId, 'memcached:1.6', { ports: loopback })],
876
+ [1, inspectRecord(memId, 'memcached:1.6', {
877
+ ports: loopback, env: ['MEMCACHED_PASSWORD=', 'OTHER=x'],
878
+ })],
879
+ [0, inspectRecord(memId, 'memcached:1.6', { ports: loopback, cmd: ['memcached', '-S'] })],
880
+ [2, inspectRecord(memId, 'memcached:1.6', {
881
+ ports: loopback, mounts: [{ Destination: '/etc/sasl2' }],
882
+ })],
883
+ ];
884
+ for (const [expected, detail] of memcachedCases) {
885
+ const run = fakeDockerCheck({
886
+ ps: [{ ID: memId, Image: 'memcached:1.6', Names: 'live-memcached' }], inspect: [detail],
887
+ }, ['--machine']);
888
+ assert.equal(run.result.status, expected, run.result.stdout + run.result.stderr);
889
+ assert.equal(run.history.filter((entry) => entry[0] === 'exec').length, 0,
890
+ 'Memcached must never borrow the Redis runtime probe');
891
+ }
892
+ });
893
+
894
+ test('P24 — live host networking is a machine exposure violation',
895
+ { skip: !DOCKER_DAEMON }, (t) => {
896
+ const suffix = process.pid + '-' + Date.now();
897
+ const name = 'p-rep-e1-hostnet-' + suffix;
898
+ const owner = 'p-rep-e1-hostnet-owner-' + suffix;
899
+ if (!isolatedDockerOrSkip(t)) return;
900
+ try {
901
+ const started = spawnSync('docker', ['run', '-d', '--name', name, '--network', 'host',
902
+ '--label', 'com.docker.compose.project=' + owner,
903
+ '--entrypoint', 'sleep', 'postgres:16-alpine', '60'], { encoding: 'utf8' });
904
+ assert.equal(started.status, 0, 'host-network fixture failed to start: ' + started.stderr);
905
+ waitForContainer(name);
906
+ const result = runCheck(['--machine']);
907
+ assert.equal(result.status, 1, result.stdout + result.stderr);
908
+ assert.match(result.stdout, /host-network|network_mode: host/i);
909
+ assert.match(result.stdout, new RegExp(owner));
910
+ } finally {
911
+ removeContainer(name);
912
+ }
913
+ });
914
+
915
+ test('P22 — machine recognition and owner attribution share the catalog seam', () => {
916
+ const storageId = 'c'.repeat(64);
917
+ const unlabeledId = '8'.repeat(64);
918
+ const emptyLabelId = '7'.repeat(64);
919
+ const lookalikeId = 'd'.repeat(64);
920
+ const scenario = {
921
+ ps: [
922
+ { ID: storageId, Image: 'registry.example:5000/team/postgres:16', Names: 'real-storage' },
923
+ { ID: unlabeledId, Image: 'postgres:16', Names: 'unlabeled-storage' },
924
+ { ID: emptyLabelId, Image: 'postgres:16', Names: 'empty-label-storage' },
925
+ { ID: lookalikeId, Image: 'rediscommander/redis-commander:latest', Names: 'redis-ui' },
926
+ ],
927
+ inspect: [inspectRecord(storageId, 'registry.example:5000/team/postgres:16', {
928
+ name: 'real-storage',
929
+ ports: { '5432/tcp': [{ HostIp: '', HostPort: '15432' }] },
930
+ labels: {
931
+ 'com.docker.compose.project': 'hostile\u202e✅ forged receipt',
932
+ 'com.docker.compose.project.config_files': '/tmp/compose.yml',
933
+ },
934
+ }), inspectRecord(unlabeledId, 'postgres:16', {
935
+ name: 'unlabeled-storage',
936
+ ports: { '5432/tcp': [{ HostIp: '0.0.0.0\u202e', HostPort: '25432' }] },
937
+ }), inspectRecord(emptyLabelId, 'postgres:16', {
938
+ name: 'empty-label-storage',
939
+ ports: { '5432/tcp': [{ HostIp: '0.0.0.0', HostPort: '35432' }] },
940
+ labels: { 'com.docker.compose.project': '' },
941
+ })],
942
+ };
943
+ const run = fakeDockerCheck(scenario, ['--machine']);
944
+ assert.equal(run.result.status, 1, run.result.stdout + run.result.stderr);
945
+ assert.match(run.result.stdout, /real-storage/);
946
+ assert.match(run.result.stdout, /unknown owner/i,
947
+ 'malformed ownership is diagnostic-only and must become unknown');
948
+ assert.ok(!/forged receipt/.test(run.result.stdout + run.result.stderr),
949
+ 'control-bearing labels cannot smuggle terminal output');
950
+ assert.ok(!(run.result.stdout + run.result.stderr).includes('\u202e'),
951
+ 'untrusted binding fields cannot retain bidi controls');
952
+ assert.match(run.result.stdout, /unlabeled-storage/);
953
+ assert.match(run.result.stdout, /empty-label-storage/);
954
+ assert.ok((run.result.stdout.match(/unknown owner/gi) || []).length >= 3,
955
+ 'malformed, absent, and empty ownership all remain diagnostic-only');
956
+ const inspectCall = run.history.find((entry) => entry[0] === 'inspect');
957
+ assert.ok(inspectCall, JSON.stringify(run.history));
958
+ assert.ok(inspectCall.includes(storageId), 'registry/path image is recognized');
959
+ assert.ok(inspectCall.includes(unlabeledId) && inspectCall.includes(emptyLabelId));
960
+ assert.ok(!inspectCall.includes(lookalikeId), 'redis-commander is not storage');
961
+
962
+ const catalog = fakeDockerCheck({ compose: 'services:\n db:\n'
963
+ + ' image: registry.example:5000/team/postgres:16\n'
964
+ + ' ports:\n - target: 5432\n published: 15432\n' }, ['$PROJECT']);
965
+ assert.equal(catalog.result.status, 1, catalog.result.stdout + catalog.result.stderr);
966
+ assert.deepEqual(catalog.history.map((entry) => entry[0]), ['compose'],
967
+ 'the same registry/path image is storage in catalog mode without machine probes');
968
+ });
969
+
970
+ test('P23 — machine subprocess timeout safeguard triggers on a hanging Docker command', () => {
971
+ const started = Date.now();
972
+ const run = fakeDockerCheck({ hang: 'ps' }, ['--machine'], { timeout: 8000 });
973
+ const elapsed = Date.now() - started;
974
+ assert.equal(run.result.status, 2, run.result.stdout + run.result.stderr);
975
+ assert.ok(run.history.some((entry) => entry[0] === 'ps'),
976
+ 'the safeguard must fire on a real hanging subprocess');
977
+ assert.ok(elapsed >= 500 && elapsed < 7000, 'timeout must trigger and remain bounded: ' + elapsed);
978
+ assert.match(run.result.stderr, /проверка НЕ выполнена/);
979
+ assert.ok(!/✅/.test(run.result.stdout + run.result.stderr));
980
+ });
981
+
982
+ test('P25 — machine verdict matrix observes 0, 1, and 2', () => {
983
+ const id = 'e'.repeat(64);
984
+ const cases = [
985
+ fakeDockerCheck({ ps: [] }, ['--machine']).result,
986
+ fakeDockerCheck({
987
+ ps: [{ ID: id, Image: 'postgres:16', Names: 'matrix-exposed' }],
988
+ inspect: [inspectRecord(id, 'postgres:16', {
989
+ name: 'matrix-exposed',
990
+ ports: { '5432/tcp': [{ HostIp: '0.0.0.0', HostPort: '15432' }] },
991
+ })],
992
+ }, ['--machine']).result,
993
+ fakeDockerCheck({ ps: { status: 1, stderr: 'daemon unavailable' } }, ['--machine']).result,
994
+ ];
995
+ assert.deepEqual(cases.map((result) => result.status), [0, 1, 2],
996
+ cases.map((result) => result.stdout + result.stderr).join('\n---\n'));
997
+ assert.match(cases[2].stderr, /проверка НЕ выполнена/);
998
+ assert.ok(!/✅/.test(cases[2].stdout + cases[2].stderr));
999
+ });
1000
+
1001
+ test('P25b — machine mode fails closed when the Docker executable is absent', () => {
1002
+ const emptyPath = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-no-docker-')));
1003
+ try {
1004
+ const result = runCheck(['--machine'], { env: { PATH: emptyPath } });
1005
+ assert.equal(result.status, 2, result.stdout + result.stderr);
1006
+ assert.match(result.stderr, /проверка НЕ выполнена/);
1007
+ assert.match(result.stderr, /docker недоступен/i);
1008
+ assert.ok(!/✅/.test(result.stdout + result.stderr));
1009
+ } finally { fs.rmSync(emptyPath, { recursive: true, force: true }); }
1010
+ });
1011
+
1012
+ test('P25c — machine mode fails closed on an unbound Docker resource', { skip: !DOCKER }, () => {
1013
+ const result = runCheck(['--machine'], { env: {
1014
+ DOCKER_HOST: 'tcp://127.0.0.1:1', DOCKER_CONTEXT: '', DOCKER_TLS_VERIFY: '',
1015
+ } });
1016
+ assert.equal(result.status, 2, result.stdout + result.stderr);
1017
+ assert.match(result.stderr, /проверка НЕ выполнена/);
1018
+ assert.match(result.stderr, /docker ps/i);
1019
+ assert.ok(!/✅/.test(result.stdout + result.stderr));
1020
+ });
1021
+
1022
+ test('P27 — machine argv is unambiguous', () => {
1023
+ for (const args of [['--machine', 'extra'], ['--machines'], ['--unknown']]) {
1024
+ const run = fakeDockerCheck({ ps: [] }, args);
1025
+ assert.equal(run.result.status, 2, JSON.stringify(args) + ': '
1026
+ + run.result.stdout + run.result.stderr);
1027
+ assert.match(run.result.stderr, /проверка НЕ выполнена/i);
1028
+ assert.match(run.result.stderr, /usage|использование/i);
1029
+ assert.deepEqual(run.history, [], 'malformed argv must make no Docker probe: ' + JSON.stringify(args));
1030
+ }
1031
+ });
1032
+
1033
+ test('P28 — package README documents catalog and machine receipts', () => {
1034
+ const readme = fs.readFileSync(path.join(PKG, 'README.md'), 'utf8');
1035
+ const start = readme.indexOf('### `check-ports.cjs`');
1036
+ const end = readme.indexOf('\n### ', start + 1);
1037
+ assert.ok(start >= 0 && end > start, 'README must retain the check-ports section');
1038
+ const section = readme.slice(start, end);
1039
+ assert.match(section, /check-ports\.cjs \.?\s*(?:#.*)?$/m, 'catalog invocation is documented');
1040
+ assert.match(section, /check-ports\.cjs --machine/, 'machine invocation is documented');
1041
+ assert.match(section, /каталог|catalog/i, 'catalog receipt is bounded');
1042
+ assert.match(section, /снимок|point-in-time/i, 'machine receipt is a snapshot, not monitoring');
1043
+ assert.match(section, /Docker-контекст|Docker context/i, 'the active context boundary is explicit');
1044
+ assert.match(section, /Redis\/Memcached[\s\S]{0,240}absence[\s\S]{0,120}`1`/i,
1045
+ 'catalog authentication consequences are discoverable in the package README');
1046
+ assert.match(section, /`2`[^\n]*(?:НЕ ВЫПОЛНЕНА|did not run)/i,
1047
+ 'exit 2 remains not-performed, not a violation or clean result');
1048
+ });
278
1049
  test('P9 — a clean compose exits 0', { skip: !DOCKER }, () => {
279
1050
  const r = check('services:\n db:\n image: postgres:16\n'
280
1051
  + ' web:\n image: node:22\n ports: ["3000:3000"]\n');