@dzhechkov/p-replicator 1.12.0 → 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 (106) hide show
  1. package/.dz-manifest.json +225 -61
  2. package/CHANGELOG.md +148 -1
  3. package/LICENSE +21 -0
  4. package/MULTIPLATFORM_ROADMAP.md +1 -1
  5. package/README/eng/01_quickstart.md +2 -2
  6. package/README/eng/02_user_guide.md +1 -1
  7. package/README/eng/03_admin_guide.md +2 -2
  8. package/README/eng/05_architecture.md +1 -1
  9. package/README/eng/README.md +2 -1
  10. package/README/ru/01_quickstart.md +2 -2
  11. package/README/ru/02_user_guide.md +1 -1
  12. package/README/ru/03_admin_guide.md +2 -2
  13. package/README/ru/05_architecture.md +1 -1
  14. package/README/ru/README.md +2 -1
  15. package/README/ru/html/index.html +8 -8
  16. package/README.md +132 -9
  17. package/bin/cli.js +0 -0
  18. package/package.json +10 -11
  19. package/sbom.json +470 -60
  20. package/scripts/check-pipeline-gaps.sh +0 -0
  21. package/src/commands/init.js +1 -1
  22. package/src/rule-components.json +5 -1
  23. package/src/utils.js +32 -3
  24. package/templates/.claude/agents/product-discoverer.md +38 -0
  25. package/templates/.claude/agents/replicate-coordinator.md +11 -1
  26. package/templates/.claude/commands/feature.md +29 -5
  27. package/templates/.claude/commands/go.md +6 -8
  28. package/templates/.claude/commands/harvest.md +5 -7
  29. package/templates/.claude/commands/replicate.md +169 -44
  30. package/templates/.claude/commands/start.md +28 -7
  31. package/templates/.claude/hooks/capture-source-path.cjs +795 -0
  32. package/templates/.claude/hooks/check-canon.cjs +493 -0
  33. package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
  34. package/templates/.claude/hooks/check-external-deps.cjs +288 -0
  35. package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
  36. package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
  37. package/templates/.claude/hooks/check-job-contract.cjs +501 -0
  38. package/templates/.claude/hooks/check-look-origin.cjs +240 -0
  39. package/templates/.claude/hooks/check-look-trace.cjs +385 -0
  40. package/templates/.claude/hooks/check-metric-source.cjs +296 -0
  41. package/templates/.claude/hooks/check-model-cost.cjs +470 -0
  42. package/templates/.claude/hooks/check-ports.cjs +27 -6
  43. package/templates/.claude/hooks/check-source-version.cjs +312 -0
  44. package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
  45. package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
  46. package/templates/.claude/hooks/statusline.cjs +2 -2
  47. package/templates/.claude/rules/embeddable-widget.md +73 -0
  48. package/templates/.claude/rules/feature-lifecycle.md +5 -6
  49. package/templates/.claude/rules/incoming-webhooks.md +99 -0
  50. package/templates/.claude/rules/long-running-job.md +73 -0
  51. package/templates/.claude/rules/model-call-cost.md +85 -0
  52. package/templates/.claude/rules/replicate-pipeline.md +121 -52
  53. package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
  54. package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
  55. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
  56. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
  57. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
  58. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
  59. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
  60. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
  61. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
  62. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
  63. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
  64. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
  65. package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
  66. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
  67. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
  68. package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
  69. package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
  70. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
  71. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
  72. package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
  73. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
  74. package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
  75. package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
  76. package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
  77. package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
  78. package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
  79. package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
  80. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
  81. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +173 -725
  82. package/tests/snapshot/baseline.json +60 -38
  83. package/tests/unit/capture-source-path.test.js +492 -0
  84. package/tests/unit/check-canon.test.js +403 -0
  85. package/tests/unit/check-embed-contract.test.js +422 -0
  86. package/tests/unit/check-external-deps.test.js +363 -0
  87. package/tests/unit/check-file-ownership.test.js +388 -0
  88. package/tests/unit/check-handoff-manifest.test.js +410 -0
  89. package/tests/unit/check-job-contract.test.js +514 -0
  90. package/tests/unit/check-look-origin.test.js +180 -0
  91. package/tests/unit/check-look-trace.test.js +420 -0
  92. package/tests/unit/check-metric-source.test.js +325 -0
  93. package/tests/unit/check-model-cost.test.js +425 -0
  94. package/tests/unit/check-ports.test.js +46 -2
  95. package/tests/unit/check-source-version.test.js +344 -0
  96. package/tests/unit/check-swarm-receipts.test.js +231 -0
  97. package/tests/unit/check-webhook-contract.test.js +536 -0
  98. package/tests/unit/db-port-rule.test.js +8 -2
  99. package/tests/unit/detection-ladder-registry.test.js +2 -2
  100. package/tests/unit/generator-swarm-contract.test.js +287 -0
  101. package/tests/unit/guard-honest-input-meta.test.js +64 -0
  102. package/tests/unit/honest-failure-rules.test.js +91 -9
  103. package/tests/unit/look-phase-contract.test.js +231 -0
  104. package/tests/unit/negative-conclusion-gate.test.js +300 -0
  105. package/tests/unit/quote-provenance.test.js +122 -0
  106. package/tests/unit/utils.test.js +40 -3
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * check-embed-contract.cjs — виджет проверяли на ЧУЖОЙ странице или на своей?
6
+ *
7
+ * NOT an event hook. Like `check-ports.cjs`, `check-look-trace.cjs`, `check-growth-trace.cjs`,
8
+ * `check-docs-complete.cjs` and `check-swarm-receipts.cjs`, it lives here because this directory
9
+ * already carries plain Node utilities; nothing registers it in settings.json. That is deliberate:
10
+ * this package's hooks are NON-BLOCKING by contract (pinned by
11
+ * tests/unit/hooks-project-anchored.test.js, which requires exit 0), so a hook could never refuse
12
+ * anything — it could only print. Invoke it:
13
+ *
14
+ * node .claude/hooks/check-embed-contract.cjs [path-to-project]
15
+ *
16
+ * WHY IT EXISTS — the failure, before the technology.
17
+ *
18
+ * When the product IS an embeddable widget (a review collector, a chat bubble), the widget runs on
19
+ * SOMEONE ELSE'S page. Three failure classes then live entirely on that page, and every one of them
20
+ * is INVISIBLE while you test on your own:
21
+ *
22
+ * перекрёстный-запрос the client's browser refuses your API call from a foreign origin
23
+ * протечка-стилей the host page's CSS reaches into your widget (or yours into the host)
24
+ * политика-безопасности the host's Content-Security-Policy refuses to load your script at all
25
+ *
26
+ * The first and the third produce a client-side outage under a fully GREEN check at home, and the
27
+ * mechanism is one sentence: THE CONDITIONS OF FAILURE BELONG TO THE HOST PAGE, AND YOU TESTED
28
+ * YOURS. Same-origin removes the CORS preflight entirely; your own page ships no hostile CSS and no
29
+ * restrictive CSP. "We opened our demo page and the widget rendered" is therefore not a weak check —
30
+ * it is a check of a page on which none of the three failures CAN occur.
31
+ *
32
+ * This is the same shape as the measured deployment finding the package already carries: a check
33
+ * must use the address the system ISSUED, never the one the checker already knows. A deployment
34
+ * confirmed against localhost is confirmed against the one origin whose behaviour does not matter.
35
+ *
36
+ * WHAT THIS FILE CAN AND CANNOT DECIDE — read before trusting exit 0.
37
+ *
38
+ * It reads a DECLARATION, `docs/embed-contract.md`, and decides only what a declaration can settle:
39
+ * that the proof names an origin the widget does not serve, that all three classes were answered,
40
+ * and that the declared CORS pair is one the browser will actually accept. It does NOT open a
41
+ * browser and does NOT prove the widget survives a hostile page. That half is layer 3/4 and the
42
+ * rule `.claude/rules/embeddable-widget.md` says so in the same words.
43
+ *
44
+ * THE EXACT FORM OF `docs/embed-contract.md` — the rule `embeddable-widget.md` delegates it here on
45
+ * purpose: this file is not part of the always-loaded corpus, so the long form costs nothing per
46
+ * run, while the rule keeps only the decision the reader must carry.
47
+ *
48
+ * **Встраиваемый виджет:** да (да | нет — `нет` is a legitimate answer)
49
+ * **Origin виджета:** https://widget.example.com
50
+ * **Origin хозяйской страницы:** http://localhost:8099
51
+ * **Учётные данные:** нет (да | нет — cookie / Authorization)
52
+ * **Разрешённые origin:** https://client-one.example
53
+ * **Проверка на чужой странице:** ВЫПОЛНЕНА (ВЫПОЛНЕНА | НЕ ВЫПОЛНЕНА)
54
+ * **Причина:** — (required when НЕ ВЫПОЛНЕНА; one of the closed REASONS below)
55
+ *
56
+ * ## Классы отказа
57
+ *
58
+ * | Класс | Статус | Признак у клиента | Лечение | Доказательство |
59
+ * |---|---|---|---|---|
60
+ * | перекрёстный-запрос | ПРОВЕРЕН | … | … | http://localhost:8099/host.html, 2026-09-01 |
61
+ * | протечка-стилей | ПРОВЕРЕН | … | … | http://localhost:8099/host.html, 2026-09-01 |
62
+ * | политика-безопасности | ПРОВЕРЕН | … | … | http://localhost:8099/host.html, 2026-09-01 |
63
+ *
64
+ * Exit codes — three, and the third is the point:
65
+ * 0 all three classes answered, and every proof names a FOREIGN origin
66
+ * 1 a defect is PROVEN and named: the proof is same-origin, a class went unanswered,
67
+ * a `ПРОВЕРЕН` row names no address, or the declared CORS pair is one browsers refuse
68
+ * 2 THE CHECK DID NOT RUN — no contract, an unrecognised value, an unparseable origin, or the
69
+ * legitimate answers «продукт не встраивается» / «проверка НЕ ВЫПОЛНЕНА, причина такая-то»
70
+ *
71
+ * A checker that answers "clean" when it could not look converts an unknown into a reassurance —
72
+ * which for this feature would be the exact client-side outage it exists to prevent.
73
+ */
74
+
75
+ const fs = require('node:fs');
76
+ const path = require('node:path');
77
+
78
+ const CONTRACT = path.join('docs', 'embed-contract.md');
79
+
80
+ /** Does the product ship a widget onto foreign pages at all? A CLOSED set — `нет` is a legitimate
81
+ * answer and it exits 2, never 0: there is nothing to check, and «нечего проверять» must not be
82
+ * spelled the same way as «проверено». */
83
+ const EMBEDDABLE = { 'ДА': true, 'НЕТ': false };
84
+
85
+ /** Was the foreign-page check performed? CLOSED, and the negative answer is honest, not a failure:
86
+ * CFG-I4 of `honest-configuration` — an unreachable truth yields UNKNOWN, never a plausible value. */
87
+ const RUN_STATUS = { 'ВЫПОЛНЕНА': 'done', 'НЕ ВЫПОЛНЕНА': 'not-done' };
88
+
89
+ /**
90
+ * Why a foreign-page check did not happen. CLOSED list — free text is not a reason here, because
91
+ * the entire value of the list is that each entry names a DIFFERENT repair:
92
+ * no-host-page — write the host fixture · no-browser — install the browser
93
+ * not-deployed — deploy, then re-check · out-of-scope — decide and record the decision
94
+ */
95
+ const REASONS = ['no-host-page', 'no-browser', 'not-deployed', 'out-of-scope'];
96
+
97
+ /**
98
+ * The three failure classes, as a CLOSED and MANDATORY set.
99
+ *
100
+ * Mandatory is the load-bearing half. Two classes answered out of three is not an unknown — it is a
101
+ * PROVEN omission whose name we can print, exactly as a lost `FR-LOOK-nnn` row is. A widget that
102
+ * survives the host's CSS and dies on the host's CSP does not work at the client.
103
+ */
104
+ const CLASSES = ['перекрёстный-запрос', 'протечка-стилей', 'политика-безопасности'];
105
+
106
+ /** Per-class verdict. CLOSED: an unmapped spelling is refused and the recognised ones are printed,
107
+ * never silently read as one of them. */
108
+ const CLASS_STATUS = { 'ПРОВЕРЕН': 'checked', 'НЕ ПРОВЕРЕН': 'unchecked' };
109
+
110
+ const DEFAULT_PORT = { 'http:': '80', 'https:': '443', 'ws:': '80', 'wss:': '443' };
111
+
112
+ function say(s) { process.stdout.write(s + '\n'); }
113
+
114
+ /** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
115
+ function cannotCheck(reason, hint) {
116
+ say('⚠️ проверка НЕ выполнена: ' + reason);
117
+ if (hint) say(' ' + hint);
118
+ process.exit(2);
119
+ }
120
+
121
+ /** Exit 1 with the defect NAMED. A violation that cannot be named is a 2, not a 1. */
122
+ function proven(title, lines, tail) {
123
+ say('❌ ' + title);
124
+ for (const line of lines) say(' • ' + line);
125
+ if (tail) say(' ' + tail);
126
+ process.exit(1);
127
+ }
128
+
129
+ /**
130
+ * The value of a `**Label:** value` header line, or null when the label is absent entirely.
131
+ * An EMPTY value is returned as '' and is never collapsed into "absent" — those are different
132
+ * mistakes with different repairs (`honest-configuration` CFG-I2).
133
+ */
134
+ function header(text, label) {
135
+ const re = new RegExp('^\\s*\\*\\*' + label + ':?\\*\\*\\s*:?(.*)$', 'im');
136
+ const m = re.exec(text);
137
+ return m ? m[1].trim().replace(/^[«"`]|[»"`]$/g, '').trim() : null;
138
+ }
139
+
140
+ /** A header value read against a CLOSED map, with both failure modes kept apart. */
141
+ function closedHeader(text, label, map, what) {
142
+ const raw = header(text, label);
143
+ if (raw === null) {
144
+ cannotCheck('в контракте нет строки `**' + label + ':**`',
145
+ what + ' — допустимы ровно: ' + Object.keys(map).join(' | '));
146
+ }
147
+ const key = raw.toUpperCase().replace(/\s+/g, ' ').trim();
148
+ if (!Object.prototype.hasOwnProperty.call(map, key)) {
149
+ cannotCheck('нераспознанное значение `' + label + '`: ' + (key === '' ? '(пусто)' : key),
150
+ 'допустимы ровно: ' + Object.keys(map).join(' | '));
151
+ }
152
+ return map[key];
153
+ }
154
+
155
+ /**
156
+ * scheme://host:port — the BROWSER'S own definition of "another site", and nothing looser.
157
+ *
158
+ * A different PORT is already a different origin: `http://localhost:3000` and `http://localhost:8099`
159
+ * are foreign to each other, they do trigger a real CORS preflight, and that makes an honest host
160
+ * fixture cheap to stand up locally. Host case and the default port are normalised so that
161
+ * `https://Example.com:443` and `https://example.com` are not reported as two origins.
162
+ *
163
+ * Returns null when the text carries no absolute URL at all — the caller decides whether that is
164
+ * "malformed" (exit 2) or "a claim with no address behind it" (exit 1); they are different facts.
165
+ */
166
+ function originOf(text) {
167
+ const m = /\b([a-z][a-z0-9+.-]*):\/\/[^\s|)<>"']+/i.exec(String(text || ''));
168
+ const bare = /\bfile:\/\/\S*/i.exec(String(text || ''));
169
+ if (!m && !bare) return null;
170
+ let url;
171
+ try { url = new URL((m || bare)[0]); } catch { return null; }
172
+ const scheme = url.protocol.toLowerCase();
173
+ if (scheme === 'file:') return { scheme, origin: 'file://', isFile: true, href: url.href };
174
+ const port = url.port || DEFAULT_PORT[scheme] || '';
175
+ return {
176
+ scheme,
177
+ origin: scheme + '//' + url.hostname.toLowerCase() + (port ? ':' + port : ''),
178
+ isFile: false,
179
+ href: url.href,
180
+ };
181
+ }
182
+
183
+ /** One required origin header, refused three ways: absent, empty, or not a URL. */
184
+ function requiredOrigin(text, label, hint) {
185
+ const raw = header(text, label);
186
+ if (raw === null || raw === '') {
187
+ cannotCheck('в контракте нет строки `**' + label + ':**` (или она пуста)', hint);
188
+ }
189
+ const parsed = originOf(raw);
190
+ if (!parsed) {
191
+ cannotCheck('`' + label + '` не разбирается как адрес: ' + raw,
192
+ 'нужен абсолютный адрес со схемой, например `https://widget.example.com` — '
193
+ + 'origin это схема+хост+порт, и сравнивать можно только его');
194
+ }
195
+ return parsed;
196
+ }
197
+
198
+ /**
199
+ * The failure-class table, as the contract records it.
200
+ *
201
+ * A row is a markdown table row whose FIRST cell is one of the three class names. The template
202
+ * ships an example row, so a row whose evidence cell is still a bracketed placeholder is a TEMPLATE
203
+ * row and is read as an EMPTY proof — never as a filled-in one.
204
+ */
205
+ function classRows(text) {
206
+ const rows = [];
207
+ for (const raw of text.split('\n')) {
208
+ const line = raw.trim();
209
+ if (!line.startsWith('|')) continue;
210
+ const cells = line.split('|').map((c) => c.trim());
211
+ const name = (cells[1] || '').toLowerCase();
212
+ if (!CLASSES.includes(name)) continue;
213
+ const evidence = cells[5] || '';
214
+ rows.push({
215
+ name,
216
+ status: (cells[2] || '').toUpperCase().replace(/\s+/g, ' ').trim(),
217
+ treatment: cells[4] || '',
218
+ evidence: /^\[.*\]$/.test(evidence) ? '' : evidence,
219
+ });
220
+ }
221
+ return rows;
222
+ }
223
+
224
+ function main() {
225
+ const root = process.argv[2] || '.';
226
+ try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
227
+ catch { cannotCheck('путь не существует: ' + root); }
228
+
229
+ const abs = path.join(root, CONTRACT);
230
+ let text;
231
+ try {
232
+ if (!fs.statSync(abs).isFile()) cannotCheck(CONTRACT + ' существует, но это не файл');
233
+ text = fs.readFileSync(abs, 'utf-8');
234
+ } catch (e) {
235
+ if (e && e.code === 'ENOENT') {
236
+ cannotCheck('нет файла ' + CONTRACT,
237
+ 'это значит, что вопрос о встраивании НЕ ЗАДАВАЛСЯ — а НЕ что виджета нет; '
238
+ + 'продукт без виджета отвечает `**Встраиваемый виджет:** нет`, и это законный ответ');
239
+ }
240
+ cannotCheck('не читается ' + CONTRACT + ': ' + ((e && e.message) || e));
241
+ }
242
+
243
+ // 1. Does the product embed at all? «нет» is legitimate and has nothing to check → 2.
244
+ const embeddable = closedHeader(text, 'Встраиваемый виджет', EMBEDDABLE,
245
+ 'без этой строки нельзя отличить «виджета нет» от «про виджет забыли»');
246
+ if (!embeddable) {
247
+ cannotCheck('контракт говорит «Встраиваемый виджет: нет» — продукт не грузится на чужие страницы',
248
+ 'это законный ответ, а не нарушение; проверять нечего, поэтому не 0 и не 1');
249
+ }
250
+
251
+ // 2. Was the foreign-page check performed? A named refusal is honest and exits 2.
252
+ const run = closedHeader(text, 'Проверка на чужой странице', RUN_STATUS,
253
+ 'без этой строки «не проверяли» неотличимо от «проверили»');
254
+ if (run === 'not-done') {
255
+ const raw = header(text, 'Причина');
256
+ if (raw === null || raw === '') {
257
+ cannotCheck('проверка НЕ ВЫПОЛНЕНА без строки `**Причина:**`',
258
+ 'причина обязательна и берётся из закрытого списка: ' + REASONS.join(' | ')
259
+ + ' — каждая означает СВОЙ ремонт');
260
+ }
261
+ const picked = REASONS.filter((r) => new RegExp('(^|[^a-z-])' + r + '([^a-z-]|$)', 'i').test(raw));
262
+ if (picked.length !== 1) {
263
+ cannotCheck('причина «' + raw + '» не из закрытого списка (или названо сразу несколько)',
264
+ 'допустимы ровно: ' + REASONS.join(' | '));
265
+ }
266
+ cannotCheck('проверка на чужой странице НЕ ВЫПОЛНЕНА, причина: ' + picked[0],
267
+ 'честное «неизвестно», а не «виджет работает у клиента»; до закрытия причины виджет '
268
+ + 'не проверен ни по одному из трёх классов отказа');
269
+ }
270
+
271
+ // 3. The two origins. Everything below is a comparison, so an unparseable address is a 2.
272
+ const widget = requiredOrigin(text, 'Origin виджета',
273
+ 'откуда клиент грузит ваш скрипт — сравнивать не с чем, пока это не названо');
274
+ const host = requiredOrigin(text, 'Origin хозяйской страницы',
275
+ 'на КАКОЙ чужой странице проверяли; своя демо-страница здесь и ловится');
276
+
277
+ // 4. The whole point, and the cheapest deterministic bite in this file.
278
+ if (host.isFile) {
279
+ proven('проверка велась со страницы `file://` — это не условия клиента',
280
+ ['`' + host.href + '`'],
281
+ 'у file:// origin равен null: браузер ведёт себя иначе и по CORS, и по CSP, так что такая '
282
+ + 'страница не воспроизводит ни один из трёх классов отказа. Поднимите её по HTTP на ДРУГОМ '
283
+ + 'порту — другой порт это уже другой origin.');
284
+ }
285
+ if (host.origin === widget.origin) {
286
+ proven('проверка велась на СВОЁМ origin — это не проверка',
287
+ ['origin виджета: ' + widget.origin,
288
+ 'origin хозяйской страницы: ' + host.origin],
289
+ 'при совпадении origin предполётного запроса нет вовсе, чужой CSS отсутствует и чужой CSP '
290
+ + 'не применяется — то есть НИ ОДИН из трёх классов отказа не может проявиться. Нужна '
291
+ + 'страница другого origin: достаточно другого порта.');
292
+ }
293
+
294
+ // 5. The CORS pair the browser itself refuses. Declared, therefore checkable — and it is the one
295
+ // CORS defect that needs no server access to prove: with credentials, `*` is not a legal
296
+ // Access-Control-Allow-Origin, so the request fails at the client and nowhere else.
297
+ const credentials = closedHeader(text, 'Учётные данные', { 'ДА': true, 'НЕТ': false },
298
+ 'шлёт ли виджет cookie/Authorization на ваш сервер');
299
+ const allowed = header(text, 'Разрешённые origin');
300
+ if (allowed === null || allowed === '') {
301
+ cannotCheck('в контракте нет строки `**Разрешённые origin:**` (или она пуста)',
302
+ 'список origin хозяев, которым сервер отвечает, либо `*`');
303
+ }
304
+ if (credentials && /(^|[\s,])\*([\s,]|$)/.test(allowed)) {
305
+ proven('объявлена пара, которую браузер отвергает сам',
306
+ ['Учётные данные: да', 'Разрешённые origin: ' + allowed],
307
+ '`Access-Control-Allow-Origin: *` несовместим с `credentials` по спецификации: браузер '
308
+ + 'отклоняет ответ у клиента, а у вас на своём сайте запрос и не был перекрёстным. '
309
+ + 'Отвечайте КОНКРЕТНЫМ origin хозяина из явного списка.');
310
+ }
311
+
312
+ // 6. All three classes, each answered, each proof naming a foreign address.
313
+ const rows = classRows(text);
314
+ const seen = rows.map((r) => r.name);
315
+ const dupes = [...new Set(seen.filter((n, i) => seen.indexOf(n) !== i))];
316
+ if (dupes.length) {
317
+ cannotCheck('в таблице классов повторяются строки: ' + dupes.join(', '),
318
+ 'один класс — одна строка; иначе один зачёт закрывает сразу два разных вопроса');
319
+ }
320
+ const bad = rows.filter((r) => !Object.prototype.hasOwnProperty.call(CLASS_STATUS, r.status));
321
+ if (bad.length) {
322
+ cannotCheck('нераспознанный статус класса: '
323
+ + bad.map((r) => r.name + ' → ' + (r.status || '(пусто)')).join(', '),
324
+ 'допустимы ровно: ' + Object.keys(CLASS_STATUS).join(' | '));
325
+ }
326
+
327
+ const missing = CLASSES.filter((c) => !seen.includes(c));
328
+ if (missing.length) {
329
+ proven('класс отказа не назван вовсе (' + missing.length + ' из ' + CLASSES.length + ')',
330
+ missing,
331
+ 'три класса это ЗАКРЫТЫЙ и ОБЯЗАТЕЛЬНЫЙ набор: виджет, переживший чужой CSS и умерший на '
332
+ + 'чужом CSP, у клиента не работает. Пропуск здесь — доказанная потеря, а не неизвестность.');
333
+ }
334
+
335
+ const unchecked = rows.filter((r) => CLASS_STATUS[r.status] === 'unchecked');
336
+ if (unchecked.length) {
337
+ proven('проверка объявлена ВЫПОЛНЕННОЙ, но класс остался НЕ ПРОВЕРЕН',
338
+ unchecked.map((r) => r.name),
339
+ 'либо проверьте класс, либо объявите всю проверку НЕ ВЫПОЛНЕННОЙ с причиной — '
340
+ + 'частичный прогон под вывеской выполненного и есть ложная квитанция.');
341
+ }
342
+
343
+ const noAddress = [];
344
+ const ownOrigin = [];
345
+ for (const row of rows) {
346
+ const origin = originOf(row.evidence);
347
+ if (!origin) { noAddress.push(row.name); continue; }
348
+ if (origin.isFile || origin.origin === widget.origin) ownOrigin.push(row.name);
349
+ }
350
+ if (noAddress.length) {
351
+ proven('класс объявлен ПРОВЕРЕННЫМ, но доказательство не называет адрес', noAddress,
352
+ 'ровно тот же дефект, что подтверждение развёртывания обращением к localhost: проверка '
353
+ + 'обязана пользоваться адресом, который система ВЫДАЛА. Без адреса «проверено у клиента» '
354
+ + 'и «проверено у себя» пишутся одинаково.');
355
+ }
356
+ if (ownOrigin.length) {
357
+ proven('доказательство класса указывает на СВОЙ origin (или на file://)', ownOrigin,
358
+ 'origin виджета: ' + widget.origin + ' — на нём ни один из трёх классов отказа не '
359
+ + 'воспроизводится.');
360
+ }
361
+
362
+ say('✅ все ' + CLASSES.length + ' классов отказа проверены на чужом origin '
363
+ + '(' + host.origin + ' против ' + widget.origin + ')');
364
+ say(' Ограничение: это доказывает, что проверку ВЕЛИ на чужой странице и записали её адрес — '
365
+ + 'а не что виджет там выглядит правильно. Похожесть и целость вёрстки доказывает сравнение.');
366
+ process.exit(0);
367
+ }
368
+
369
+ try {
370
+ main();
371
+ } catch (err) {
372
+ // Even an unexpected failure must not read as "clean".
373
+ cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
374
+ }
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * check-external-deps.cjs — инвентарь чужих сервисов ЕСТЬ, и каждая строка несёт вердикт?
6
+ *
7
+ * NOT an event hook, and here that is a requirement rather than a habit: this package's hooks are
8
+ * NON-BLOCKING by contract (pinned by tests/unit/hooks-project-anchored.test.js, which requires exit
9
+ * 0), so a hook could only print — it could never refuse. Invoke it, in the form of its two
10
+ * siblings `check-growth-trace.cjs` and `check-look-trace.cjs`:
11
+ *
12
+ * node .claude/hooks/check-external-deps.cjs [path-to-project]
13
+ *
14
+ * WHAT IS ALREADY SHIPPED, AND IS NOT RE-CREATED HERE. The inventory itself (five columns), the
15
+ * three verdicts CONFIRMED / UNCONFIRMED / CONTRADICTED, the requirement that evidence carry a
16
+ * VERBATIM QUOTE rather than a bare link, the sixth validator lens and the Phase-2 output rows — all
17
+ * of it exists in `skills/sparc-prd-mini/SKILL.md` and is pinned by
18
+ * tests/unit/external-dependency-check.test.js. This file adds the one thing that was missing.
19
+ *
20
+ * THE MISSING THING: NOBODY ENFORCED IT.
21
+ *
22
+ * The template FORMULATES the rule in its own words — write literally *"No external dependencies —
23
+ * this product calls no third-party service"*, because «an empty section and an absent section are
24
+ * indistinguishable, and only one of them means anything». And then the only consumers of the
25
+ * inventory are a PROSE LENS read by a model and two rows of an output table. Layer 3-4, both of
26
+ * them, and a lens with nothing to read cannot tell «there are no dependencies» from «nobody wrote
27
+ * the section».
28
+ *
29
+ * THE VACUOUS TRUTH THAT MAKES IT WORSE. The Phase-2 green verdict reads «no external dependency
30
+ * UNCONFIRMED or CONTRADICTED». Over an EMPTY SET that sentence is true by itself — a green verdict
31
+ * obtained by having nothing to look at. That is why an absent section here is exit 2 and can never
32
+ * be exit 0: the one outcome this file exists to make unavailable is a clean answer produced by
33
+ * absence.
34
+ *
35
+ * MEASURED 2026-09-01 on a tree carrying the full set of Phase-1 documents whose `Architecture.md`
36
+ * has NO `## External Dependencies` section: `check-docs-complete` 0, `check-growth-trace` 0,
37
+ * `check-look-trace` 0, and the remaining four exit 2. Not one of them named the missing inventory.
38
+ * They are not broken — they answer their own questions correctly, and nothing was asking this one.
39
+ *
40
+ * WHAT THIS FILE CAN AND CANNOT DECIDE — read before trusting exit 0.
41
+ *
42
+ * It settles that the inventory EXISTS, that it was filled in rather than left as the shipped
43
+ * template, that every row carries a verdict from the closed three, that no row is CONTRADICTED, and
44
+ * that a row claiming CONFIRMED carries the three things the rule demands as proof: a link, a check
45
+ * date, and a verbatim quote. It CANNOT open the link, and it cannot tell a real quote from an
46
+ * invented one — a fabricated quotation passes here and is caught, if at all, by a human or a
47
+ * browser. That stays layer 3, and the exit-0 text says so rather than letting a green verdict imply
48
+ * otherwise.
49
+ *
50
+ * Exit codes — three, and the third is the point:
51
+ * 0 the inventory exists, is filled in, and every row carries a verdict; UNCONFIRMED rows are
52
+ * NAMED in the output because their requirements may not enter Phase 3 unresolved
53
+ * 1 a defect is PROVEN and named: a row with no verdict or an unrecognised one, a CONTRADICTED
54
+ * row, or a CONFIRMED row whose evidence lacks a link, a date, or a verbatim quote
55
+ * 2 THE CHECK DID NOT RUN — no Architecture.md, NO `## External Dependencies` SECTION AT ALL, a
56
+ * section holding only the shipped template row, duplicate rows, or the legitimate answer «no
57
+ * external dependencies», written in the words the rule prescribes
58
+ *
59
+ * The two exit-2 reasons that matter most are kept APART in the output, because they are opposite
60
+ * facts wearing the same code: an absent section means the question was never ASKED; the «no
61
+ * external dependencies» sentence means it was asked and answered. Printing them identically would
62
+ * rebuild the very confusion this file removes.
63
+ */
64
+
65
+ const fs = require('node:fs');
66
+ const path = require('node:path');
67
+
68
+ const ARCH = path.join('docs', 'Architecture.md');
69
+ const HEADING = /^#{2,6}\s+External Dependencies\s*$/i;
70
+
71
+ /** The closed three. Two would hide a difference that matters: «nobody could cite it» and «the
72
+ * provider's own docs say it cannot» need different repairs and have different consequences. */
73
+ const VERDICTS = { CONFIRMED: 'confirmed', UNCONFIRMED: 'unconfirmed', CONTRADICTED: 'contradicted' };
74
+
75
+ /** The sentence the rule prescribes for a product that genuinely calls nobody. Matched loosely on
76
+ * wording but strictly on MEANING: it must be an explicit statement, not an empty section. */
77
+ const NO_DEPS = /no external dependenc\w*|внешних зависимостей нет|не вызывает (?:ни одного )?(?:стороннего|чужого) сервис/i;
78
+
79
+ /** A verbatim quote: text inside quotation marks, long enough to be a sentence fragment rather than
80
+ * a word. The rule demands it precisely because a URL is the cheapest possible forgery. */
81
+ const QUOTE = /[«"“”']\s*[^«»"“”']{12,}\s*[»"“”']|`[^`]{12,}`/;
82
+
83
+ /** A link, and a date the evidence was checked. Both are demanded by the rule for CONFIRMED, and
84
+ * they are reported SEPARATELY because their repairs differ: one is «go find the page», the other
85
+ * is «say when you looked». */
86
+ const LINK = /\bhttps?:\/\/\S+/i;
87
+ const CHECKED = /\b(20\d{2})[-/.](0[1-9]|1[0-2])[-/.](0[1-9]|[12]\d|3[01])\b/;
88
+
89
+ function say(s) { process.stdout.write(s + '\n'); }
90
+
91
+ /** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
92
+ function cannotCheck(reason, hint) {
93
+ say('⚠️ проверка НЕ выполнена: ' + reason);
94
+ if (hint) say(' ' + hint);
95
+ process.exit(2);
96
+ }
97
+
98
+ /** Exit 1 with the defect NAMED. A violation that cannot be named is a 2, not a 1. */
99
+ function proven(title, lines, tail) {
100
+ say('❌ ' + title);
101
+ for (const line of lines) say(' • ' + line);
102
+ if (tail) say(' ' + tail);
103
+ process.exit(1);
104
+ }
105
+
106
+ /**
107
+ * The body of the `## External Dependencies` section, or null when the heading is absent.
108
+ *
109
+ * Read PER SECTION and not from the whole document, for the reason the whole file is about: the
110
+ * Technology Stack table sits directly above and has three columns of its own. A global scan would
111
+ * read its rows as inventory rows with no verdict and report a defect that is not there — an answer
112
+ * to a neighbouring question, delivered with confidence.
113
+ *
114
+ * Fenced blocks are skipped, so a `##` inside an example cannot invent a section.
115
+ */
116
+ function section(text) {
117
+ const lines = text.split('\n');
118
+ let fenced = false;
119
+ let start = -1;
120
+ let level = 0;
121
+ for (let i = 0; i < lines.length; i++) {
122
+ if (/^\s*(```|~~~)/.test(lines[i])) { fenced = !fenced; continue; }
123
+ if (fenced) continue;
124
+ if (start < 0) {
125
+ if (HEADING.test(lines[i].trim())) { start = i; level = /^(#+)/.exec(lines[i].trim())[1].length; }
126
+ continue;
127
+ }
128
+ const m = /^(#{1,6})\s+\S/.exec(lines[i].trim());
129
+ if (m && m[1].length <= level) return lines.slice(start + 1, i).join('\n');
130
+ }
131
+ return start < 0 ? null : lines.slice(start + 1).join('\n');
132
+ }
133
+
134
+ /**
135
+ * The inventory rows.
136
+ *
137
+ * Five columns: capability, provider, evidence, verdict, requirements. A row whose FIRST cell is a
138
+ * bracketed placeholder is the SHIPPED TEMPLATE row, not a real dependency — counting it would let
139
+ * an untouched template look like a filled-in inventory, which is the same substitution as an
140
+ * absent section, one level in.
141
+ */
142
+ function rows(body) {
143
+ const out = [];
144
+ let templates = 0;
145
+ let fenced = false;
146
+ for (const raw of body.split('\n')) {
147
+ const line = raw.trim();
148
+ if (/^(```|~~~)/.test(line)) { fenced = !fenced; continue; }
149
+ if (fenced) continue;
150
+ if (!line.startsWith('|')) continue;
151
+ const cells = line.split('|').map((c) => c.trim());
152
+ if (cells.length < 7) continue; // '' + 5 columns + ''
153
+ const capability = cells[1];
154
+ if (!capability || /^:?-+:?$/.test(capability)) continue;
155
+ if (/^capability/i.test(capability)) continue; // header row
156
+ if (/^\[.*\]$/.test(capability) || capability === '...') { templates += 1; continue; }
157
+ out.push({
158
+ capability,
159
+ provider: cells[2],
160
+ evidence: cells[3],
161
+ verdict: cells[4].toUpperCase().replace(/[^A-Z]/g, ''),
162
+ requirements: cells[5],
163
+ });
164
+ }
165
+ return { out, templates };
166
+ }
167
+
168
+ function main() {
169
+ const root = process.argv[2] || '.';
170
+ try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
171
+ catch { cannotCheck('путь не существует: ' + root); }
172
+
173
+ const abs = path.join(root, ARCH);
174
+ let text;
175
+ try {
176
+ if (!fs.statSync(abs).isFile()) cannotCheck(ARCH + ' существует, но это не файл');
177
+ text = fs.readFileSync(abs, 'utf-8');
178
+ } catch (e) {
179
+ if (e && e.code === 'ENOENT') {
180
+ cannotCheck('нет файла ' + ARCH,
181
+ 'Фаза 1 не дописана — это НЕ «внешних зависимостей нет»');
182
+ }
183
+ cannotCheck('не читается ' + ARCH + ': ' + ((e && e.message) || e));
184
+ }
185
+
186
+ // THE FIXTURE THE WHOLE FILE IS BUILT AROUND: an absent section is exit 2 with a NAMED reason, and
187
+ // it is never, under any circumstance, exit 0.
188
+ const body = section(text);
189
+ if (body === null) {
190
+ cannotCheck('в ' + ARCH + ' нет раздела `## External Dependencies`',
191
+ 'ОТСУТСТВУЮЩИЙ раздел — это НЕ «зависимостей нет»: пустое множество делает зелёный вердикт '
192
+ + '«ни одна внешняя зависимость не UNCONFIRMED и не CONTRADICTED» истинным САМО СОБОЙ. '
193
+ + 'Продукт, который действительно никого не зовёт, пишет это дословно: «No external '
194
+ + 'dependencies — this product calls no third-party service.»');
195
+ }
196
+
197
+ const { out, templates } = rows(body);
198
+
199
+ // The legitimate answer — and it is a DIFFERENT fact from the one above, so it prints differently.
200
+ if (out.length === 0 && NO_DEPS.test(body)) {
201
+ cannotCheck('раздел объявляет: внешних зависимостей нет',
202
+ 'это законный ответ, а не нарушение, и он отличается от отсутствующего раздела ровно тем, '
203
+ + 'что вопрос БЫЛ задан и на него ответили. Сверять нечего, поэтому не 0 и не 1.');
204
+ }
205
+ if (out.length === 0) {
206
+ cannotCheck(templates
207
+ ? 'в разделе только шаблонная строка (' + templates + ') — инвентарь не заполняли'
208
+ : 'раздел `## External Dependencies` есть, но он пуст',
209
+ 'пустой раздел и отсутствующий отличаются только на глаз, а значат одно и то же: вопрос не '
210
+ + 'закрыт. Либо перечислите способности, либо напишите дословно «No external dependencies — '
211
+ + 'this product calls no third-party service.»');
212
+ }
213
+
214
+ const keys = out.map((r) => (r.capability + '|' + r.provider).toLowerCase());
215
+ const dupes = [...new Set(keys.filter((k, i) => keys.indexOf(k) !== i))];
216
+ if (dupes.length) {
217
+ cannotCheck('в инвентаре повторяются строки: ' + dupes.join(', '),
218
+ 'одна способность у одного поставщика — одна строка; иначе один вердикт закрывает два '
219
+ + 'разных вопроса');
220
+ }
221
+
222
+ // A row with no verdict is a PROVEN defect, not an unknown: the inventory's entire job is to
223
+ // carry a verdict, and a row that carries none was written as if it had one.
224
+ const noVerdict = out.filter((r) => !Object.prototype.hasOwnProperty.call(VERDICTS, r.verdict));
225
+ if (noVerdict.length) {
226
+ proven('строка инвентаря без вердикта из закрытой тройки',
227
+ noVerdict.map((r) => r.capability + ' (' + (r.provider || 'поставщик не назван') + ') → '
228
+ + (r.verdict || '(пусто)')),
229
+ 'допустимы ровно: ' + Object.keys(VERDICTS).join(' | ') + '. Строка без вердикта выглядит '
230
+ + 'как проверенная и не проверена ничем — именно этот разрыв и делает зелёный вердикт Фазы 2 '
231
+ + 'бессодержательным.');
232
+ }
233
+
234
+ const contradicted = out.filter((r) => VERDICTS[r.verdict] === 'contradicted');
235
+ if (contradicted.length) {
236
+ proven('способность CONTRADICTED — документация поставщика говорит, что он так не умеет',
237
+ contradicted.map((r) => r.capability + ' (' + r.provider + ')'
238
+ + (r.requirements ? ' ← ' + r.requirements : '')),
239
+ 'требования из последней колонки опираются на то, чего нет. Это 🔴, а не примечание: '
240
+ + 'переписать требование на подтверждаемую способность либо сменить поставщика.');
241
+ }
242
+
243
+ // CONFIRMED carries the burden the rule puts on it: a link, a date, and a VERBATIM QUOTE. Reported
244
+ // apart because the repairs differ — «find the page», «say when you looked», «quote the sentence».
245
+ const confirmed = out.filter((r) => VERDICTS[r.verdict] === 'confirmed');
246
+ const noQuote = confirmed.filter((r) => !QUOTE.test(r.evidence));
247
+ const noLink = confirmed.filter((r) => !LINK.test(r.evidence));
248
+ const noDate = confirmed.filter((r) => !CHECKED.test(r.evidence));
249
+ if (noQuote.length) {
250
+ proven('CONFIRMED без ДОСЛОВНОЙ ЦИТАТЫ',
251
+ noQuote.map((r) => r.capability + ' (' + r.provider + ')'),
252
+ 'правдоподобная ссылка — самая дешёвая подделка из возможных, и ровно поэтому правило '
253
+ + 'требует цитату, а не ссылку: цитата называет СПОСОБНОСТЬ, ссылка называет только страницу. '
254
+ + 'Приведите короткую дословную фразу со страницы поставщика в кавычках.');
255
+ }
256
+ if (noLink.length) {
257
+ proven('CONFIRMED без ссылки на документацию поставщика',
258
+ noLink.map((r) => r.capability + ' (' + r.provider + ')'),
259
+ 'цитата без адреса непроверяема: её нельзя открыть и сверить. Нужны оба.');
260
+ }
261
+ if (noDate.length) {
262
+ proven('CONFIRMED без даты проверки',
263
+ noDate.map((r) => r.capability + ' (' + r.provider + ')'),
264
+ 'то, что API умеет, ДРЕЙФУЕТ. Подтверждение без даты не даёт понять, устарело оно или нет, '
265
+ + 'а устаревший факт, записанный как доказательство, хуже его отсутствия.');
266
+ }
267
+
268
+ const unconfirmed = out.filter((r) => VERDICTS[r.verdict] === 'unconfirmed');
269
+ say('✅ инвентарь на месте: ' + out.length + ' способност(ей), у каждой вердикт из закрытой тройки '
270
+ + '(' + confirmed.length + ' CONFIRMED, ' + unconfirmed.length + ' UNCONFIRMED)');
271
+ if (unconfirmed.length) {
272
+ say(' UNCONFIRMED — не отказ, но и не бесплатный пропуск. Требования этих строк НЕ входят в '
273
+ + 'Фазу 3, пока их не отложат, не уберут или не перепишут:');
274
+ for (const r of unconfirmed) {
275
+ say(' • ' + r.capability + ' (' + r.provider + ')' + (r.requirements ? ' ← ' + r.requirements : ''));
276
+ }
277
+ }
278
+ say(' Ограничение: проверка НЕ ОТКРЫВАЕТ ссылку и не отличает настоящую цитату от выдуманной. '
279
+ + 'Доказано, что доказательство ПРЕДЪЯВЛЕНО в требуемой форме, — не что оно истинно (слой 3).');
280
+ process.exit(0);
281
+ }
282
+
283
+ try {
284
+ main();
285
+ } catch (err) {
286
+ // Even an unexpected failure must not read as "clean".
287
+ cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
288
+ }