optbrew 0.0.0-stage → 0.1.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 (172) hide show
  1. package/.claude-plugin/marketplace.json +6 -0
  2. package/.claude-plugin/plugin.json +6 -0
  3. package/.codex-plugin/plugin.json +13 -0
  4. package/CHANGELOG.md +20 -0
  5. package/LICENSE +21 -0
  6. package/README.ko.md +73 -0
  7. package/README.md +72 -2
  8. package/THIRD_PARTY_NOTICES.md +10 -0
  9. package/bin/optbrew.mjs +4 -0
  10. package/docs/platforms.md +31 -0
  11. package/docs/release.md +14 -0
  12. package/lib/cli.mjs +103 -0
  13. package/lib/doctor.mjs +44 -0
  14. package/lib/install.mjs +158 -0
  15. package/lib/paths.mjs +30 -0
  16. package/lib/records.mjs +44 -0
  17. package/lib/transaction.mjs +205 -0
  18. package/lib/update.mjs +181 -0
  19. package/package.json +39 -4
  20. package/plugin.json +13 -0
  21. package/skills/optbrew/LICENSE +21 -0
  22. package/skills/optbrew/SKILL.md +17 -0
  23. package/skills/optbrew/THIRD_PARTY_NOTICES.md +10 -0
  24. package/skills/optbrew/agents/openai.yaml +4 -0
  25. package/skills/optbrew/bundle-manifest.json +663 -0
  26. package/skills/optbrew/licenses/firecrawl-seo-audit.txt +15 -0
  27. package/skills/optbrew/licenses/magnus-seo.txt +21 -0
  28. package/skills/optbrew/licenses/marketing-search.txt +21 -0
  29. package/skills/optbrew/licenses/mblode-seo.txt +21 -0
  30. package/skills/optbrew/licenses/naver-searchadvisor.txt +29 -0
  31. package/skills/optbrew/licenses/seranking-seo.txt +21 -0
  32. package/skills/optbrew/modules/firecrawl-seo-audit/GUIDE.md +64 -0
  33. package/skills/optbrew/modules/magnus-seo/GUIDE.md +79 -0
  34. package/skills/optbrew/modules/magnus-seo/README.md +45 -0
  35. package/skills/optbrew/modules/magnus-seo/assets/audit-report-template.md +76 -0
  36. package/skills/optbrew/modules/magnus-seo/references/aeo-methodology.md +105 -0
  37. package/skills/optbrew/modules/magnus-seo/references/agent-readable-content.md +28 -0
  38. package/skills/optbrew/modules/magnus-seo/references/content-and-entity-architecture.md +100 -0
  39. package/skills/optbrew/modules/magnus-seo/references/content-strategy-seo.md +142 -0
  40. package/skills/optbrew/modules/magnus-seo/references/discovery-and-freshness.md +47 -0
  41. package/skills/optbrew/modules/magnus-seo/references/evidence-boundaries.md +75 -0
  42. package/skills/optbrew/modules/magnus-seo/references/ghost-metadata.md +204 -0
  43. package/skills/optbrew/modules/magnus-seo/references/implementation-playbook.md +114 -0
  44. package/skills/optbrew/modules/magnus-seo/references/measurement-and-experimentation.md +47 -0
  45. package/skills/optbrew/modules/magnus-seo/references/onpage-seo.md +154 -0
  46. package/skills/optbrew/modules/magnus-seo/references/platform-guidance.md +30 -0
  47. package/skills/optbrew/modules/magnus-seo/references/schema-markup.md +244 -0
  48. package/skills/optbrew/modules/magnus-seo/references/source-index.md +36 -0
  49. package/skills/optbrew/modules/magnus-seo/references/structured-data.md +56 -0
  50. package/skills/optbrew/modules/magnus-seo/references/technical-seo.md +99 -0
  51. package/skills/optbrew/modules/magnus-seo/scripts/aeo_audit.py +158 -0
  52. package/skills/optbrew/modules/magnus-seo/scripts/build_prompt_matrix.py +52 -0
  53. package/skills/optbrew/modules/magnus-seo/templates/aeo-implementation-plan.md +53 -0
  54. package/skills/optbrew/modules/magnus-seo/templates/citation-observation-log.md +16 -0
  55. package/skills/optbrew/modules/magnus-seo/templates/llms.txt.template +15 -0
  56. package/skills/optbrew/modules/magnus-seo/templates/question-cluster.md +27 -0
  57. package/skills/optbrew/modules/magnus-seo/templates/robots-ai-crawlers.txt +23 -0
  58. package/skills/optbrew/modules/marketing-search/skills/ai-seo/GUIDE.md +483 -0
  59. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/agent-readiness.md +57 -0
  60. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/citations-vs-recommendations.md +85 -0
  61. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/content-patterns.md +287 -0
  62. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/content-types.md +71 -0
  63. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/format-volatility.md +98 -0
  64. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/okf.md +104 -0
  65. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/platform-ranking-factors.md +154 -0
  66. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/youtube-ai-citations.md +60 -0
  67. package/skills/optbrew/modules/marketing-search/skills/analytics/GUIDE.md +303 -0
  68. package/skills/optbrew/modules/marketing-search/skills/analytics/references/event-library.md +260 -0
  69. package/skills/optbrew/modules/marketing-search/skills/analytics/references/ga4-implementation.md +300 -0
  70. package/skills/optbrew/modules/marketing-search/skills/analytics/references/gtm-implementation.md +390 -0
  71. package/skills/optbrew/modules/marketing-search/skills/content-strategy/GUIDE.md +432 -0
  72. package/skills/optbrew/modules/marketing-search/skills/content-strategy/references/content-distribution.md +83 -0
  73. package/skills/optbrew/modules/marketing-search/skills/content-strategy/references/headless-cms.md +194 -0
  74. package/skills/optbrew/modules/marketing-search/skills/programmatic-seo/GUIDE.md +231 -0
  75. package/skills/optbrew/modules/marketing-search/skills/programmatic-seo/references/playbooks.md +308 -0
  76. package/skills/optbrew/modules/marketing-search/skills/schema/GUIDE.md +172 -0
  77. package/skills/optbrew/modules/marketing-search/skills/schema/references/schema-examples.md +398 -0
  78. package/skills/optbrew/modules/marketing-search/skills/seo-audit/GUIDE.md +492 -0
  79. package/skills/optbrew/modules/marketing-search/skills/seo-audit/references/ai-writing-detection.md +200 -0
  80. package/skills/optbrew/modules/marketing-search/skills/seo-audit/references/international-seo.md +230 -0
  81. package/skills/optbrew/modules/marketing-search/skills/site-architecture/GUIDE.md +350 -0
  82. package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/mermaid-templates.md +216 -0
  83. package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/navigation-patterns.md +305 -0
  84. package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/site-type-templates.md +293 -0
  85. package/skills/optbrew/modules/mblode-seo/GUIDE.md +74 -0
  86. package/skills/optbrew/modules/mblode-seo/references/answer-engines.md +58 -0
  87. package/skills/optbrew/modules/mblode-seo/references/audit.md +78 -0
  88. package/skills/optbrew/modules/mblode-seo/references/brief-template.md +47 -0
  89. package/skills/optbrew/modules/mblode-seo/references/monitoring.md +56 -0
  90. package/skills/optbrew/modules/mblode-seo/references/nextjs-implementation.md +65 -0
  91. package/skills/optbrew/modules/mblode-seo/references/research-protocol.md +37 -0
  92. package/skills/optbrew/modules/mblode-seo/references/sources.md +47 -0
  93. package/skills/optbrew/modules/mblode-seo/references/validation-evidence.md +21 -0
  94. package/skills/optbrew/modules/naver-searchadvisor/GUIDE.md +75 -0
  95. package/skills/optbrew/modules/naver-searchadvisor/NOTICE.md +59 -0
  96. package/skills/optbrew/modules/naver-searchadvisor/references/INDEX.md +97 -0
  97. package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +714 -0
  98. package/skills/optbrew/modules/naver-searchadvisor/scripts/robots.mjs +77 -0
  99. package/skills/optbrew/modules/seranking-seo/skills/seo-ai-search-share-of-voice/GUIDE.md +94 -0
  100. package/skills/optbrew/modules/seranking-seo/skills/seo-api/GUIDE.md +186 -0
  101. package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/api-surface-map.md +207 -0
  102. package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/auth-and-keys.md +182 -0
  103. package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/integration-patterns.md +283 -0
  104. package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/rate-limits-and-credits.md +235 -0
  105. package/skills/optbrew/modules/seranking-seo/skills/seo-competitor-gap-analysis/GUIDE.md +117 -0
  106. package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/GUIDE.md +155 -0
  107. package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/references/cite.md +84 -0
  108. package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/references/core-eeat.md +147 -0
  109. package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/templates/verdict.md +63 -0
  110. package/skills/optbrew/modules/seranking-seo/skills/seo-firecrawl/GUIDE.md +141 -0
  111. package/skills/optbrew/modules/seranking-seo/skills/seo-firecrawl/references/preflight.md +73 -0
  112. package/skills/optbrew/modules/seranking-seo/skills/seo-geo/GUIDE.md +159 -0
  113. package/skills/optbrew/modules/seranking-seo/skills/seo-google/GUIDE.md +393 -0
  114. package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/cwv-audit-report.md +48 -0
  115. package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/gsc-performance-report.md +44 -0
  116. package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/indexation-status-report.md +43 -0
  117. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/auth-setup.md +160 -0
  118. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/cross-skill-integration.md +187 -0
  119. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/ga4-data-api.md +184 -0
  120. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/indexing-api.md +109 -0
  121. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/keyword-planner-api.md +66 -0
  122. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/nlp-api.md +55 -0
  123. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/pagespeed-crux-api.md +204 -0
  124. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/rate-limits-quotas.md +75 -0
  125. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/search-console-api.md +156 -0
  126. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/supplementary-apis.md +99 -0
  127. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/youtube-api.md +49 -0
  128. package/skills/optbrew/modules/seranking-seo/skills/seo-keyword-niche/GUIDE.md +174 -0
  129. package/skills/optbrew/modules/seranking-seo/skills/seo-page/GUIDE.md +174 -0
  130. package/skills/optbrew/modules/seranking-seo/skills/seo-plan/GUIDE.md +220 -0
  131. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/GUIDE.md +135 -0
  132. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/references/google-rich-results.md +89 -0
  133. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/article.json +23 -0
  134. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/breadcrumb-list.json +18 -0
  135. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/faq-page.json +22 -0
  136. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/local-business.json +32 -0
  137. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/product.json +25 -0
  138. package/skills/optbrew/modules/seranking-seo/skills/seo-sitemap/GUIDE.md +128 -0
  139. package/skills/optbrew/modules/seranking-seo/skills/seo-technical-audit/GUIDE.md +235 -0
  140. package/skills/optbrew/modules/seranking-seo/skills/seo-technical-audit/references/severity-mapping.md +133 -0
  141. package/skills/optbrew/references/api.md +45 -0
  142. package/skills/optbrew/references/check-registry.json +28 -0
  143. package/skills/optbrew/references/evidence.md +9 -0
  144. package/skills/optbrew/references/intake.md +9 -0
  145. package/skills/optbrew/references/measurement.md +29 -0
  146. package/skills/optbrew/references/policies.md +7 -0
  147. package/skills/optbrew/references/profiles.json +13 -0
  148. package/skills/optbrew/references/remediations.json +12 -0
  149. package/skills/optbrew/references/report.md +25 -0
  150. package/skills/optbrew/references/routing.md +15 -0
  151. package/skills/optbrew/references/schemas/contract.schema.json +51 -0
  152. package/skills/optbrew/references/scoring.md +62 -0
  153. package/skills/optbrew/scripts/adapters/anthropic.mjs +116 -0
  154. package/skills/optbrew/scripts/adapters/imports.mjs +81 -0
  155. package/skills/optbrew/scripts/audit.mjs +145 -0
  156. package/skills/optbrew/scripts/cli.mjs +41 -0
  157. package/skills/optbrew/scripts/core/bootstrap.mjs +31 -0
  158. package/skills/optbrew/scripts/core/bundle.mjs +73 -0
  159. package/skills/optbrew/scripts/core/capabilities.mjs +19 -0
  160. package/skills/optbrew/scripts/core/collector.mjs +236 -0
  161. package/skills/optbrew/scripts/core/compare.mjs +47 -0
  162. package/skills/optbrew/scripts/core/contracts.mjs +144 -0
  163. package/skills/optbrew/scripts/core/cost-ledger.mjs +95 -0
  164. package/skills/optbrew/scripts/core/findings.mjs +54 -0
  165. package/skills/optbrew/scripts/core/metrics.mjs +97 -0
  166. package/skills/optbrew/scripts/core/protocol.mjs +125 -0
  167. package/skills/optbrew/scripts/core/report.mjs +652 -0
  168. package/skills/optbrew/scripts/core/scope.mjs +52 -0
  169. package/skills/optbrew/scripts/core/score.mjs +71 -0
  170. package/skills/optbrew/scripts/core/weights.mjs +139 -0
  171. package/skills/optbrew/source-lock.json +2686 -0
  172. package/source-lock.json +2686 -0
@@ -0,0 +1,652 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import { access, mkdir, rename, rm, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { ContractError, validateEntity } from './contracts.mjs';
6
+ import { score } from './score.mjs';
7
+ import { compare } from './compare.mjs';
8
+ import { freezeProtocol } from './protocol.mjs';
9
+ import { measurePanel } from './metrics.mjs';
10
+ import { normalizeImport } from '../adapters/imports.mjs';
11
+ import { mergeFindings, selectRemediations } from './findings.mjs';
12
+ import { auditCollected, indexingDirectives } from '../../modules/naver-searchadvisor/scripts/audit.mjs';
13
+ import { declaredRobotsIndex } from './collector.mjs';
14
+ import { parseRobots, robotsVerdict } from '../../modules/naver-searchadvisor/scripts/robots.mjs';
15
+
16
+ const catalog = JSON.parse(readFileSync(new URL('../../references/remediations.json', import.meta.url))).items;
17
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
18
+ const named = value => typeof value === 'string' && value.trim().length > 0;
19
+ const sensitive = /(?:sk-ant-api03-[A-Za-z0-9_-]{32,}|sk-proj-[A-Za-z0-9_-]{32,}|ghp_[A-Za-z0-9]{36,}|AKIA[A-Z0-9]{16}|\b\d{6}-[1-8]\d{6}\b)/;
20
+ const html = value => String(value ?? 'unknown').replace(/\\/g, '\\\\').replace(/&/g, '&amp;').replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
22
+ const table = (columns, rows) => `| ${columns.join(' | ')} |\n| ${columns.map(() => '---').join(' | ')} |\n${rows.map(row => `| ${row.map(html).join(' | ')} |`).join('\n') || `| ${columns.map(() => '—').join(' | ')} |`}`;
23
+ const days = window => (Date.parse(`${window.end}T00:00:00Z`) - Date.parse(`${window.start}T00:00:00Z`)) / 86400000 + 1;
24
+ const dayBefore = date => new Date(Date.parse(`${date}T00:00:00Z`) - 86400000).toISOString().slice(0, 10);
25
+ function completeWindow(metric) {
26
+ const collectedAt = metric.filters.collected_at, completeDate = metric.filters.last_complete_date;
27
+ if (!collectedAt || !/^\d{4}-\d{2}-\d{2}$/.test(completeDate ?? '') ||
28
+ !/[zZ]|[+-]\d\d:\d\d$/.test(collectedAt) || !Number.isFinite(Date.parse(collectedAt)) ||
29
+ !Number.isFinite(Date.parse(`${completeDate}T00:00:00Z`)) ||
30
+ new Date(`${completeDate}T00:00:00Z`).toISOString().slice(0, 10) !== completeDate) return false;
31
+ const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: metric.window.timezone,
32
+ year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(collectedAt)).map(x => [x.type, x.value]));
33
+ const localCollectionDate = `${parts.year}-${parts.month}-${parts.day}`;
34
+ return completeDate <= dayBefore(localCollectionDate) && metric.window.end <= completeDate;
35
+ }
36
+
37
+ function metricRows(metrics) {
38
+ const groups = new Map();
39
+ for (const metric of metrics) {
40
+ const filters = { ...metric.filters }; delete filters.collected_at; delete filters.last_complete_date; delete filters.phase;
41
+ const key = JSON.stringify([metric.metric_id, metric.definition, filters, metric.panel_hash,
42
+ metric.provider_definition, metric.sample_selection]);
43
+ if (!groups.has(key)) groups.set(key, []);
44
+ groups.get(key).push(metric);
45
+ }
46
+ return [...groups.values()].map(group => {
47
+ group.sort((a, b) => a.window.start.localeCompare(b.window.start));
48
+ const [before, after] = group;
49
+ const paired = group.length === 2 && completeWindow(before) && completeWindow(after) &&
50
+ days(before.window) === 28 && days(after.window) === 28 &&
51
+ before.window.timezone === after.window.timezone &&
52
+ Date.parse(`${after.window.start}T00:00:00Z`) - Date.parse(`${before.window.end}T00:00:00Z`) === 86400000;
53
+ const measured = paired && before.value_state === 'measured' && after.value_state === 'measured';
54
+ const completion = group.map(m => m.metric_id.endsWith('observation_completion_rate') && m.value_state === 'measured' ? m.value :
55
+ m.sample_selection.valid_responses != null && m.sample_selection.planned_responses > 0 ?
56
+ m.sample_selection.valid_responses / m.sample_selection.planned_responses : null);
57
+ // One measured window keeps its raw value and state; the difference stays unknown without a matched pair.
58
+ const single = !paired && group.length === 1 && before.value_state === 'measured' ? before.value ?? 'unknown' : 'unknown';
59
+ return [Object.entries(before.filters).filter(([k]) => ['provider', 'engine', 'property', 'surface'].includes(k)).map(([k, v]) => `${k}:${v}`).join('; ') || 'unknown',
60
+ before.metric_id, group.map(m => `${m.window.start}–${m.window.end} (${m.sample_selection.kind})`).join(' → '),
61
+ paired ? before.value ?? 'unknown' : single, paired ? after.value ?? 'unknown' : 'unknown',
62
+ measured ? after.value - before.value : 'unknown', completion.map(x => x ?? 'unknown').join(' → '),
63
+ `${paired ? 'comparable_windows' : 'not_comparable_or_missing_window'}; ${group.map(m => m.value_state).join(' → ')}; ` +
64
+ group.map(m => m.sample_selection.missing_reason ?? m.sample_selection.inference ?? '').filter(Boolean).join('; ')];
65
+ });
66
+ }
67
+
68
+ function indexedEvidence(packet) {
69
+ if (!packet.evidence_root || !Array.isArray(packet.evidence_index)) throw new ContractError('evidence_root and evidence_index required');
70
+ const root = realpathSync(packet.evidence_root);
71
+ const index = new Map();
72
+ for (const entry of packet.evidence_index) {
73
+ if (!entry || typeof entry.ref !== 'string' || !entry.ref || !/^[a-f0-9]{64}$/.test(entry.sha256) ||
74
+ typeof entry.kind !== 'string' || !entry.kind || entry.run_id !== packet.run_context.run_id || index.has(entry.ref))
75
+ throw new ContractError('invalid or duplicate run evidence entry');
76
+ const file = path.resolve(root, entry.ref);
77
+ const actual = realpathSync(file);
78
+ if (actual !== root && !actual.startsWith(`${root}${path.sep}`)) throw new ContractError(`evidence path outside run directory: ${entry.ref}`);
79
+ const bytes = readFileSync(actual);
80
+ if (sensitive.test(bytes.toString('utf8'))) throw new ContractError('private identifier or secret in report evidence');
81
+ if (createHash('sha256').update(bytes).digest('hex') !== entry.sha256)
82
+ throw new ContractError(`evidence hash mismatch: ${entry.ref}`);
83
+ index.set(entry.ref, { ...entry, text: bytes.toString('utf8') });
84
+ }
85
+ const requireRef = (ref, sourceRef) => {
86
+ const entry = index.get(ref);
87
+ if (!entry || sourceRef && entry.source_ref !== sourceRef) throw new ContractError(`evidence/source link missing: ${ref}`);
88
+ return entry;
89
+ };
90
+ const context = requireRef(packet.run_context.evidence_ref);
91
+ if (context.kind !== 'context' || !same(JSON.parse(context.text), packet.run_context))
92
+ throw new ContractError('run context evidence differs from report context');
93
+ return { requireRef, index };
94
+ }
95
+
96
+ /** Collected responses by raw evidence reference across the packet's audit snapshots (lazy, memoized). */
97
+ function collectedResponses(snapshots, requireRef) {
98
+ let byRef = null;
99
+ return ref => {
100
+ if (!byRef) {
101
+ byRef = new Map();
102
+ for (const snapshot of snapshots) {
103
+ try {
104
+ const audit = JSON.parse(requireRef(snapshot.audit_ref).text);
105
+ const collection = JSON.parse(requireRef(audit.collection_ref).text);
106
+ for (const entry of collection.artifacts ?? []) {
107
+ let artifact = entry;
108
+ try { artifact = boundArtifact(entry, audit, requireRef); } catch { /* strict binding runs for collector observations */ }
109
+ byRef.set(artifact.evidence_ref, [...(byRef.get(artifact.evidence_ref) ?? []), artifact]);
110
+ }
111
+ } catch { /* an unreadable snapshot supplies no response headers; collector observations are replayed separately */ }
112
+ }
113
+ }
114
+ return byRef.get(ref) ?? [];
115
+ };
116
+ }
117
+ const unitUrl = unitKey => String(unitKey).split('|').at(-1);
118
+
119
+ /**
120
+ * Replay the index-policy verdict from preserved raw evidence. HTML meta and the collected X-Robots-Tag header
121
+ * go through the same extractor as the audit; a collected response must belong to the named unit URL.
122
+ */
123
+ function evidenceBlocksIndexing(evidence, engines, collectedFor, unit) {
124
+ const responses = collectedFor(evidence.ref);
125
+ const scoped = unit ? responses.filter(x => x.normalized_url === unit || x.final_url === unit) : responses;
126
+ if (responses.length && !scoped.length) return false;
127
+ if (evidence.kind === 'raw_html') {
128
+ const headers = scoped.length ? scoped.map(xRobotsLines) : [[]];
129
+ return headers.some(xRobotsTag => indexingDirectives({ html: evidence.text, xRobotsTag, engines }).blocked);
130
+ }
131
+ if (evidence.kind === 'http_response')
132
+ return [...evidence.text.matchAll(/^x-robots-tag:[ \t]*(.*)$/gim)].some(match =>
133
+ indexingDirectives({ html: '', xRobotsTag: match[1], engines }).blocked);
134
+ return false;
135
+ }
136
+ /** Normalized pathname of an export path; absolute URLs count only on the mapping host, "//" forms never. */
137
+ function exportPath(value, host) {
138
+ const text = String(value);
139
+ if (/^[a-z][a-z0-9+.-]*:/i.test(text)) {
140
+ try {
141
+ const url = new URL(text);
142
+ return ['http:', 'https:'].includes(url.protocol) && host && url.hostname.toLowerCase() === String(host).toLowerCase() ? url.pathname : null;
143
+ } catch { return null; }
144
+ }
145
+ if (!text.startsWith('/') || /^\/[/\\]/.test(text)) return null;
146
+ try {
147
+ const url = new URL(text, 'http://path.invalid');
148
+ return url.host === 'path.invalid' ? url.pathname : null;
149
+ } catch { return null; }
150
+ }
151
+ const pathWithin = (value, prefix, host) => {
152
+ const actual = exportPath(value, host), wanted = exportPath(prefix, host);
153
+ if (actual === null || wanted === null) return false;
154
+ return wanted === '/' || actual === wanted || actual.startsWith(wanted.endsWith('/') ? wanted : `${wanted}/`);
155
+ };
156
+
157
+ /**
158
+ * Replay a collected response from its hashed record: final URL, redirect chain and header lines come from the
159
+ * record, and the collection entry must agree with it. Bodyless failures have no response to bind.
160
+ */
161
+ function boundArtifact(artifact, audit, requireRef) {
162
+ if (!Number.isInteger(artifact.status)) return artifact;
163
+ const entry = (audit.responses ?? []).find(x => x.normalized_url === artifact.normalized_url);
164
+ if (!artifact.response_ref || !entry || entry.evidence_ref !== artifact.response_ref)
165
+ throw new ContractError('response record missing for collected artifact');
166
+ const raw = requireRef(artifact.response_ref, 'optbrew:collector');
167
+ if (raw.sha256 !== entry.content_hash) throw new ContractError('response record hash differs from the audit index');
168
+ const record = JSON.parse(raw.text);
169
+ const chain = Array.isArray(record.redirect_chain) ? record.redirect_chain : [];
170
+ if (record.final_url !== (chain.length ? chain.at(-1).location : record.normalized_url) ||
171
+ (chain.length && chain[0].url !== record.normalized_url) ||
172
+ chain.some((hop, index) => index + 1 < chain.length && hop.location !== chain[index + 1].url))
173
+ throw new ContractError('final URL differs from the redirect chain');
174
+ for (const key of ['requested_url', 'normalized_url', 'final_url', 'status', 'redirect_chain', 'header_lines', 'content_hash', 'capability'])
175
+ if (!same(record[key], artifact[key])) throw new ContractError(`response record differs from collection record: ${key}`);
176
+ return { ...artifact, ...record };
177
+ }
178
+ const xRobotsLines = response => Array.isArray(response.header_lines) ?
179
+ response.header_lines.filter(line => line.name === 'x-robots-tag').map(line => line.value) :
180
+ [response.headers?.['x-robots-tag'] ?? response.headers?.['X-Robots-Tag']].filter(value => value != null);
181
+
182
+ function checkDecisions(input, requireRef, collectedFor) {
183
+ const protocolHash = freezeProtocol(input.run_context).protocol_hash;
184
+ const engines = input.run_context.protocol.engines;
185
+ const decisionRecord = (decision, scope, identity) => {
186
+ const record = JSON.parse(requireRef(decision.evidence_ref).text);
187
+ if (record.run_id !== input.run_context.run_id || !same(record.target, input.run_context.target) ||
188
+ record.protocol_hash !== protocolHash || !named(record.approved_by) || !Array.isArray(record.decisions) ||
189
+ !record.decisions.some(item => item.scope === scope && item[identity.key] === identity.value &&
190
+ item.condition === decision.condition && item.reason === decision.reason))
191
+ throw new ContractError(`applicability decision record does not match ${scope} ${identity.value}`);
192
+ };
193
+ for (const type of input.run_context.protocol.page_types) for (const url of type.urls)
194
+ if (url.indexing_decision) decisionRecord(url.indexing_decision, 'url', { key: 'normalized_url', value: url.normalized });
195
+ for (const family of input.run_context.protocol.families)
196
+ if (family.applicability_decision) decisionRecord(family.applicability_decision, 'family', { key: 'family_id', value: family.family_id });
197
+ for (const observation of input.observations) {
198
+ const evidence = requireRef(observation.evidence_ref, observation.source_ref);
199
+ if (observation.applicability_decision) decisionRecord(observation.applicability_decision, 'unit', { key: 'unit_key', value: observation.unit_key });
200
+ if (observation.check_id === 'index-policy' && observation.state === 'fail') {
201
+ if (evidence.kind !== 'raw_html' && evidence.kind !== 'http_response')
202
+ throw new ContractError('index-policy failure requires raw HTML or response-header evidence');
203
+ if (!evidenceBlocksIndexing(evidence, engines, collectedFor, unitUrl(observation.unit_key)))
204
+ throw new ContractError('index-policy noindex verdict is absent from raw response evidence');
205
+ }
206
+ if (observation.check_id === 'structured-data-truth' && observation.state !== 'unknown' && evidence.kind === 'markdown')
207
+ throw new ContractError('Markdown cannot establish raw HTML structured-data truth');
208
+ }
209
+ }
210
+
211
+ function checkMetricEvidence(metric, packet, requireRef) {
212
+ validateEntity('MetricObservation', metric);
213
+ const evidence = requireRef(metric.evidence_ref, metric.source_ref);
214
+ let source;
215
+ try { source = JSON.parse(evidence.text); }
216
+ catch { throw new ContractError('metric evidence must be replayable JSON'); }
217
+ if (source.run_id !== packet.run_context.run_id) throw new ContractError('metric evidence run mismatch');
218
+ const identity = packet.run_context.protocol.target_identity;
219
+ if (!identity || !same(identity.target, packet.run_context.target))
220
+ throw new ContractError('target identity mapping missing from frozen run protocol');
221
+ let observed;
222
+ if (source.kind === 'native_export') {
223
+ const property = source.metadata.property;
224
+ const mapping = identity.native_properties?.find(x => x.provider === source.provider && x.property === property);
225
+ if (!mapping || !same(mapping.target, packet.run_context.target) || !mapping.evidence_ref || !property || property === 'unknown')
226
+ throw new ContractError('native property target mapping missing');
227
+ if (metric.filters.hostname !== 'unknown' && metric.filters.hostname !== mapping.hostname ||
228
+ metric.filters.path !== 'unknown' && (!mapping.path_prefix || !pathWithin(metric.filters.path, mapping.path_prefix, mapping.hostname)))
229
+ throw new ContractError('native hostname or URL scope differs from approved target mapping');
230
+ const decision = JSON.parse(requireRef(mapping.evidence_ref).text);
231
+ if (decision.run_id !== packet.run_context.run_id || !same(decision.target, packet.run_context.target) ||
232
+ decision.provider !== source.provider || decision.property !== property ||
233
+ (mapping.hostname && decision.hostname !== mapping.hostname) ||
234
+ (mapping.path_prefix && decision.path_prefix !== mapping.path_prefix) || !named(decision.approved_by))
235
+ throw new ContractError('native property target mapping record differs from run');
236
+ observed = normalizeImport(source.provider, source.rows, source.metadata);
237
+ }
238
+ else if (source.kind === 'panel') {
239
+ const expectedHash = freezeProtocol(packet.run_context).protocol_hash;
240
+ if (source.panel.panel_hash !== expectedHash || metric.panel_hash !== expectedHash)
241
+ throw new ContractError('panel hash differs from run protocol');
242
+ const protocol = packet.run_context.protocol;
243
+ if (!same(source.panel.questions, protocol.question_panel) || !protocol.engines.includes(source.panel.engine) ||
244
+ !protocol.surfaces.includes(source.panel.surface) || !protocol.models.includes(source.panel.model) ||
245
+ source.panel.search_enabled !== protocol.search_enabled || source.panel.locale !== protocol.locale ||
246
+ source.panel.region !== protocol.region)
247
+ throw new ContractError('panel inputs differ from run protocol');
248
+ const target = identity.panel;
249
+ if (!target || !same([source.panel.target_entity, source.panel.target_domains, source.panel.competitor_entities ?? []],
250
+ [target.target_entity, target.target_domains, target.competitor_entities ?? []]) || !target.evidence_ref)
251
+ throw new ContractError('panel target identity differs from frozen run');
252
+ const decision = JSON.parse(requireRef(target.evidence_ref).text);
253
+ if (decision.run_id !== packet.run_context.run_id || !same(decision.target, packet.run_context.target) ||
254
+ !same(decision.panel, target) || !named(decision.approved_by))
255
+ throw new ContractError('panel target identity approval record differs from run');
256
+ const evaluableClaims = source.evaluations.flatMap(x => x.claims ?? []).filter(x => x.evaluable === true);
257
+ const evaluableCitations = source.evaluations.flatMap(x => x.citations ?? []).filter(x => x.evaluable === true);
258
+ let facts = null;
259
+ if (source.panel.fact_list_ref) {
260
+ facts = JSON.parse(requireRef(source.panel.fact_list_ref).text);
261
+ if (facts.run_id !== packet.run_context.run_id || facts.target_entity !== target.target_entity ||
262
+ !named(facts.approved_by) || !Array.isArray(facts.facts)) throw new ContractError('approved fact list differs from panel target');
263
+ }
264
+ if (evaluableClaims.length && !facts) throw new ContractError('evaluable facts require approved fact list');
265
+ for (const response of source.responses) {
266
+ if (!response.response_ref || !response.extraction_ref) throw new ContractError('provider response evidence missing');
267
+ const raw = requireRef(response.response_ref);
268
+ const receipt = JSON.parse(requireRef(response.extraction_ref).text);
269
+ const normalized = { ...response }; delete normalized.response_ref; delete normalized.extraction_ref;
270
+ if (receipt.run_id !== packet.run_context.run_id || receipt.response_id !== response.response_id ||
271
+ receipt.raw_ref !== response.response_ref || receipt.raw_sha256 !== raw.sha256 ||
272
+ !same(receipt.normalized_response, normalized))
273
+ throw new ContractError('provider response extraction receipt differs from raw evidence');
274
+ }
275
+ for (const evaluation of source.evaluations) {
276
+ if (!evaluation.evidence_ref) throw new ContractError('evaluation evidence receipt missing');
277
+ const receipt = JSON.parse(requireRef(evaluation.evidence_ref).text);
278
+ const normalized = { ...evaluation }; delete normalized.evidence_ref;
279
+ if (receipt.run_id !== packet.run_context.run_id || receipt.response_id !== evaluation.response_id ||
280
+ !named(receipt.approved_by) || !same(receipt.evaluation, normalized))
281
+ throw new ContractError('evaluation evidence receipt differs from panel verdict');
282
+ for (const citation of (evaluation.citations ?? []).filter(x => x.evaluable === true)) {
283
+ if (!citation.source_evidence_ref || requireRef(citation.source_evidence_ref).source_url !== citation.citation_url)
284
+ throw new ContractError('citation source evidence does not match evaluated URL');
285
+ }
286
+ for (const claim of (evaluation.claims ?? []).filter(x => x.evaluable === true)) {
287
+ if (!claim.fact_id || !facts.facts.some(x => x.fact_id === claim.fact_id) || !claim.source_evidence_ref)
288
+ throw new ContractError('evaluated claim lacks fact ID or source evidence');
289
+ requireRef(claim.source_evidence_ref);
290
+ }
291
+ }
292
+ observed = measurePanel(source.panel, source.responses, source.evaluations);
293
+ } else throw new ContractError('unsupported metric evidence kind');
294
+ if (!observed.some(item => same(item, metric))) throw new ContractError('metric differs from bound export or panel evidence');
295
+ return source.kind === 'native_export' ? 'owner export origin and provider completeness are not independently authenticated' :
296
+ 'provider response origin and semantic evaluations are attributed to indexed receipts, not independently authenticated';
297
+ }
298
+
299
+ function statusFor(finding, changes, afterInput, target, requireRef) {
300
+ const linked = changes.filter(change => change.finding_ids.some(id => finding.finding_ids.includes(id)));
301
+ if (!linked.length) return finding.remediation_ids.length ? 'proposed_only' :
302
+ finding.status === 'unknown' ? 'unknown' : 'confirmed';
303
+ if (linked.some(change => change.verification.status === 'failed')) return 'verification_failed';
304
+ if (linked.some(change => change.verification.status !== 'passed')) return 'modified_unverified';
305
+ const afterResolved = same(afterInput?.run_context.target, target) &&
306
+ afterInput?.run_context.environment === 'production' && linked.every(change => afterInput.observations.some(obs => {
307
+ if (obs.check_id !== finding.check_id || obs.unit_key !== finding.unit_key ||
308
+ !['pass', 'partial'].includes(obs.state) || obs.observation_id !== change.verification.observation_id ||
309
+ obs.evidence_ref !== change.verification.observation_evidence_ref) return false;
310
+ const source = requireRef(obs.evidence_ref, obs.source_ref);
311
+ return source.phase === 'after' && source.environment === 'production' && same(source.target, target);
312
+ }));
313
+ if (linked.every(change => change.environment === 'production' && change.verification.deployed === true))
314
+ return afterResolved ? 'verified_production' : 'modified_unverified';
315
+ // A byte-hash receipt proves which file changed, not that the finding is resolved. Local verification uses the
316
+ // production binding: the receipt names an after observation of this check and unit that the collector produced
317
+ // (so the audit replay covers it), and its raw evidence is labelled after, local and for this target.
318
+ const localResolved = same(afterInput?.run_context.target, target) && afterInput?.run_context.environment === 'local' &&
319
+ linked.every(change => change.environment === 'local' && change.verification.observation_id &&
320
+ change.verification.observation_evidence_ref && afterInput.observations.some(obs => {
321
+ if (obs.check_id !== finding.check_id || obs.unit_key !== finding.unit_key || !['pass', 'partial'].includes(obs.state) ||
322
+ obs.source_ref !== 'optbrew:collector' || obs.observation_id !== change.verification.observation_id ||
323
+ obs.evidence_ref !== change.verification.observation_evidence_ref) return false;
324
+ const source = requireRef(obs.evidence_ref, obs.source_ref);
325
+ return source.phase === 'after' && source.environment === 'local' && same(source.target, target);
326
+ }));
327
+ return linked.every(change => change.environment === 'local') && localResolved ? 'verified_local' : 'modified_unverified';
328
+ }
329
+
330
+ function checkAuditPhase(input, phase, snapshot, requireRef) {
331
+ if (!input) return;
332
+ const observations = input.observations.filter(x => x.source_ref === 'optbrew:collector');
333
+ if (!snapshot && !observations.length) return;
334
+ if (!snapshot || snapshot.phase !== phase || snapshot.context_ref !== input.run_context.evidence_ref)
335
+ throw new ContractError(`${phase} collector observations require their own audit snapshot`);
336
+ const audit = JSON.parse(requireRef(snapshot.audit_ref).text);
337
+ if (audit.run_id !== input.run_context.run_id || audit.context_ref !== snapshot.context_ref)
338
+ throw new ContractError('audit index run/context mismatch');
339
+ const collection = JSON.parse(requireRef(audit.collection_ref).text);
340
+ const moduleOutputs = JSON.parse(requireRef(audit.module_output_ref).text);
341
+ const savedObservations = JSON.parse(requireRef(audit.observations_ref).text);
342
+ if (!Array.isArray(collection.artifacts) || !Array.isArray(audit.raw) || !Array.isArray(savedObservations))
343
+ throw new ContractError('audit snapshot files incomplete');
344
+ const rawRefs = new Set();
345
+ for (const raw of audit.raw) {
346
+ if (raw.content_hash === null) {
347
+ if (raw.evidence_ref !== audit.collection_ref || !collection.artifacts.some(x =>
348
+ x.normalized_url === raw.normalized_url && x.evidence_ref === raw.evidence_ref &&
349
+ x.content_hash === null && x.error))
350
+ throw new ContractError('bodyless audit failure lacks matching collection record');
351
+ requireRef(raw.evidence_ref);
352
+ } else if (requireRef(raw.evidence_ref).sha256 !== raw.content_hash)
353
+ throw new ContractError('audit raw content hash mismatch');
354
+ rawRefs.add(raw.evidence_ref);
355
+ }
356
+ // Final URL, redirect chain and header lines come from the hashed response records, not from collection.json alone.
357
+ const artifacts = collection.artifacts.map(x => boundArtifact(x, audit, requireRef));
358
+ for (const obs of observations) {
359
+ if (!savedObservations.some(x => same(x, obs)) || !rawRefs.has(obs.evidence_ref))
360
+ throw new ContractError('collector observation is not bound to audit index and raw evidence');
361
+ const url = unitUrl(obs.unit_key);
362
+ const artifact = artifacts.find(x => x.normalized_url === url && x.evidence_ref === obs.evidence_ref);
363
+ // Bind the requested unit to its collected artifact first; the module must describe that artifact's final URL.
364
+ const pageArtifact = artifact ?? artifacts.find(x => x.normalized_url === url && x.status === 200 && x.capability === 'raw_html');
365
+ const modules = pageArtifact?.final_url ? (moduleOutputs['naver-searchadvisor'] ?? []).filter(x =>
366
+ x.url === pageArtifact.final_url && (x.requested_url ?? url) === url) : [];
367
+ const module = modules.length === 1 ? modules[0] : null;
368
+ const engines = input.run_context.protocol.engines;
369
+ if (obs.check_id === 'fetch-response' && (!artifact ||
370
+ (obs.state === 'pass' && !(artifact.status >= 200 && artifact.status < 300)) ||
371
+ (obs.state === 'fail' && artifact.status < 400) ||
372
+ (artifact.content_hash === null && obs.state !== 'unknown')))
373
+ throw new ContractError('fetch response verdict differs from collected artifact');
374
+ if (['index-policy', 'document-title'].includes(obs.check_id) &&
375
+ (['pass', 'fail'].includes(obs.state) || obs.check_id === 'document-title' && obs.state === 'unknown')) {
376
+ const raw = artifact && requireRef(obs.evidence_ref);
377
+ if (!module || !artifact || artifact.status !== 200 || artifact.capability !== 'raw_html' ||
378
+ artifact.raw_html_ref !== obs.evidence_ref || artifact.content_hash !== raw.sha256 || raw.kind !== 'raw_html')
379
+ throw new ContractError('title/index verdict lacks bound raw response');
380
+ const replay = auditCollected({ artifact, html: raw.text, engines });
381
+ if (!same([module.title, module.robots_meta, module.x_robots_tag],
382
+ [replay.title, replay.robots_meta, replay.x_robots_tag]) ||
383
+ (module.requested_url !== undefined && module.requested_url !== artifact.normalized_url) ||
384
+ (module.redirect_chain !== undefined && !same(module.redirect_chain, artifact.redirect_chain ?? [])))
385
+ throw new ContractError('title/index module differs from raw response and headers');
386
+ // A present title proves existence only; uniqueness and descriptiveness need separate evidence.
387
+ const expected = obs.check_id === 'document-title' ? replay.title ? 'unknown' : 'fail' : replay.index_blocked ? 'fail' : 'pass';
388
+ if (obs.state !== expected)
389
+ throw new ContractError('title/index verdict differs from raw response and headers');
390
+ }
391
+ if (obs.check_id === 'search-crawl-policy' && ['pass', 'fail'].includes(obs.state)) {
392
+ const [, engine] = obs.unit_key.split('|');
393
+ const robotsArtifact = artifacts.find(x => x.evidence_ref === obs.evidence_ref && x.status === 200);
394
+ const declared = robotsArtifact && (input.run_context.protocol.auxiliary_resources ?? []).some(resource =>
395
+ resource.kind === 'robots' && resource.normalized === robotsArtifact.normalized_url);
396
+ const robotsRaw = robotsArtifact && requireRef(obs.evidence_ref);
397
+ let sameOriginRobots = false;
398
+ try {
399
+ const robotsUrl = new URL(robotsArtifact.final_url), pageUrl = new URL(pageArtifact.final_url);
400
+ sameOriginRobots = robotsUrl.origin === pageUrl.origin && robotsUrl.pathname === '/robots.txt';
401
+ } catch { /* missing or invalid URLs leave the binding false */ }
402
+ if (engine !== 'naver' || !engines.includes(engine) || !declared || !sameOriginRobots || !module ||
403
+ robotsRaw.sha256 !== robotsArtifact.content_hash || module.robots_evidence_ref !== obs.evidence_ref || !module.robots)
404
+ throw new ContractError('crawl verdict differs from bound robots evidence and module output');
405
+ const target = new URL(pageArtifact.final_url);
406
+ const verdict = robotsVerdict(parseRobots(robotsRaw.text), 'yeti', target.pathname + target.search);
407
+ if (module.robots.yeti_allowed !== verdict.allowed || module.robots.reason !== verdict.reason ||
408
+ obs.state !== (verdict.allowed ? 'pass' : 'fail'))
409
+ throw new ContractError('crawl verdict differs from bound robots evidence and module output');
410
+ }
411
+ }
412
+ // Every observation the collector plans for this phase must be in the packet and in the saved observation file;
413
+ // deleting a failed unit from both would otherwise silently remove its blocker.
414
+ if (!Array.isArray(moduleOutputs['naver-searchadvisor'])) return;
415
+ const planned = new Set(input.run_context.protocol.families.filter(x => x.applicable).map(x => x.family_id));
416
+ const pages = input.run_context.protocol.page_types.flatMap(type => type.urls);
417
+ const declaredRobots = new Set((input.run_context.protocol.auxiliary_resources ?? [])
418
+ .filter(resource => resource.kind === 'robots').map(resource => resource.normalized));
419
+ const expect = (family, engine, url) => {
420
+ const unitKey = `${family}|${engine ? `${engine}|` : ''}${url}`;
421
+ if (!observations.some(x => x.unit_key === unitKey) || !savedObservations.some(x => x.unit_key === unitKey))
422
+ throw new ContractError(`planned collector observation missing: ${unitKey}`);
423
+ };
424
+ for (const { normalized: url, public_indexing: publicIndexing } of pages) {
425
+ const artifact = artifacts.find(x => x.normalized_url === url);
426
+ if (!artifact) {
427
+ // A selected page is either collected or recorded as skipped; vanishing from both hides its observations.
428
+ if (!(collection.skipped ?? []).some(x => x.url === url))
429
+ throw new ContractError(`selected page missing from the collection and its skipped list: ${url}`);
430
+ continue;
431
+ }
432
+ if (planned.has('fetch-response')) expect('fetch-response', null, url);
433
+ if (artifact.status !== 200 || artifact.capability !== 'raw_html') continue;
434
+ if (planned.has('document-title')) expect('document-title', null, url);
435
+ if (publicIndexing === false) continue;
436
+ if (planned.has('index-policy')) expect('index-policy', null, url);
437
+ if (planned.has('search-crawl-policy') && input.run_context.protocol.engines.includes('naver')) {
438
+ const robots = artifacts[declaredRobotsIndex(artifacts, declaredRobots, artifact.final_url)];
439
+ if (robots?.status === 200) {
440
+ const target = new URL(artifact.final_url);
441
+ if (!robotsVerdict(parseRobots(requireRef(robots.evidence_ref).text), 'yeti', target.pathname + target.search).allowed)
442
+ expect('search-crawl-policy', 'naver', url);
443
+ }
444
+ }
445
+ }
446
+ }
447
+
448
+ /** Recompute scores and metrics from contained run evidence; render four design tables. */
449
+ export function buildReport(packet) {
450
+ validateEntity('ReportPacket', packet);
451
+ if (sensitive.test(JSON.stringify(packet))) throw new ContractError('private identifier or secret in report packet');
452
+ if (packet.baseline.input.run_context.run_id !== packet.run_context.run_id ||
453
+ !same(packet.baseline.input.run_context, packet.run_context) || packet.costs.run_id !== packet.run_context.run_id)
454
+ throw new ContractError('report run context or cost ledger mismatch');
455
+ const { requireRef } = indexedEvidence(packet);
456
+ const snapshots = packet.audit_snapshots ?? (packet.audit_ref ? [{ phase: 'baseline',
457
+ audit_ref: packet.audit_ref, context_ref: packet.run_context.evidence_ref }] : []);
458
+ if (new Set(snapshots.map(x => x.phase)).size !== snapshots.length ||
459
+ snapshots.some(x => !['baseline', 'after'].includes(x.phase) || !x.audit_ref || !x.context_ref))
460
+ throw new ContractError('audit snapshot phase or reference invalid');
461
+ const snapshotFor = phase => snapshots.find(x => x.phase === phase);
462
+ // Dropping the audit reference must not downgrade collector evidence to an unaudited packet.
463
+ if (!snapshotFor('baseline') && packet.evidence_index.some(x => x.source_ref === 'optbrew:collector'))
464
+ throw new ContractError('collector evidence is indexed but the baseline audit snapshot is missing');
465
+ const baselineScores = score(packet.baseline.input);
466
+ if (!same(packet.baseline.scores, baselineScores)) throw new ContractError('baseline score differs from raw observations');
467
+ const collectedIn = phase => collectedResponses(snapshotFor(phase) ? [snapshotFor(phase)] : [], requireRef);
468
+ checkDecisions(packet.baseline.input, requireRef, collectedIn('baseline'));
469
+ let afterScores = null;
470
+ if (packet.after) {
471
+ if (packet.after.input.run_context.run_id !== packet.run_context.run_id) throw new ContractError('after run ID mismatch');
472
+ if (!same(JSON.parse(requireRef(packet.after.input.run_context.evidence_ref).text), packet.after.input.run_context))
473
+ throw new ContractError('after context evidence differs from input');
474
+ afterScores = score(packet.after.input);
475
+ if (!same(packet.after.scores, afterScores)) throw new ContractError('after score differs from raw observations');
476
+ checkDecisions(packet.after.input, requireRef, collectedIn('after'));
477
+ }
478
+ const comparison = compare(packet.baseline.input, packet.after?.input ?? null);
479
+ if (!same(packet.comparison, comparison)) throw new ContractError('comparison differs from raw inputs');
480
+ const metricOriginLimitations = [...new Set(packet.metrics.map(metric => checkMetricEvidence(metric, packet, requireRef)))];
481
+ if (!packet.costs.evidence_ref || !same(JSON.parse(requireRef(packet.costs.evidence_ref).text), packet.costs))
482
+ throw new ContractError('cost ledger snapshot evidence differs from report');
483
+ checkAuditPhase(packet.baseline.input, 'baseline', snapshotFor('baseline'), requireRef);
484
+ checkAuditPhase(packet.after?.input, 'after', snapshotFor('after'), requireRef);
485
+ const merged = mergeFindings(packet.findings);
486
+ const observations = [...packet.baseline.input.observations, ...(packet.after?.input.observations ?? [])];
487
+ const collectedAny = collectedResponses(snapshots, requireRef);
488
+ const findings = merged.map(finding => {
489
+ if (!observations.some(obs => obs.check_id === finding.check_id && obs.unit_key === finding.unit_key &&
490
+ (obs.state === 'fail' || obs.state === 'unknown')))
491
+ throw new ContractError(`finding has no matching check observation: ${finding.finding_id}`);
492
+ if (finding.blocker && !observations.some(obs => obs.check_id === finding.check_id &&
493
+ obs.unit_key === finding.unit_key && obs.state === 'fail'))
494
+ throw new ContractError('blocker requires confirmed failed check observation');
495
+ for (const source of finding.sources) {
496
+ const evidence = requireRef(source.evidence_ref, source.source_ref);
497
+ // The same HTML plus response-header replay as the observation, bound to the finding's own unit.
498
+ if (finding.check_id === 'index-policy' && finding.blocker &&
499
+ !evidenceBlocksIndexing(evidence, packet.run_context.protocol.engines, collectedAny, unitUrl(finding.unit_key)))
500
+ throw new ContractError('finding noindex evidence does not contain directive');
501
+ }
502
+ const options = selectRemediations(finding, { context: packet.run_context,
503
+ capabilities: packet.capabilities ?? { selected: [], missing: [], limitations: [] }, catalog });
504
+ const selected = options.find(option => option.selected);
505
+ if (finding.remediation_ids.length && !finding.remediation_ids.every(id => id === selected?.remediation_id))
506
+ throw new ContractError(`finding remediation link invalid: ${finding.finding_id}`);
507
+ const status = statusFor(finding, packet.changes, packet.after?.input, packet.run_context.target, requireRef);
508
+ // Show who attested a production resolution, so model-written fixes stay distinct from collector-verified ones.
509
+ const resolving = status === 'verified_production' ? packet.changes
510
+ .filter(change => change.finding_ids.some(id => finding.finding_ids.includes(id)))
511
+ .map(change => packet.after.input.observations.find(obs => obs.observation_id === change.verification.observation_id &&
512
+ obs.evidence_ref === change.verification.observation_evidence_ref)).filter(Boolean) : [];
513
+ const resolution = resolving.length ? { observation_id: resolving[0].observation_id,
514
+ evaluator_type: [...new Set(resolving.map(obs => obs.evaluator_type))].join(', '),
515
+ source_ref: [...new Set(resolving.map(obs => obs.source_ref))].join(', ') } : null;
516
+ return { ...finding, remediation: selected ?? null, status, resolution };
517
+ });
518
+ for (const change of packet.changes) {
519
+ validateEntity('ChangeRecord', change);
520
+ if (!change.finding_ids.length || change.finding_ids.some(id => !findings.some(f => f.finding_ids.includes(id))))
521
+ throw new ContractError('change has no linked finding');
522
+ if (requireRef(change.evidence_ref).sha256 !== change.after_hash)
523
+ throw new ContractError('change after_hash differs from evidence');
524
+ if (change.backup_ref && requireRef(change.backup_ref).sha256 !== change.before_hash)
525
+ throw new ContractError('change before_hash differs from backup evidence');
526
+ if (change.verification?.status === 'passed' && !change.verification.evidence_ref)
527
+ throw new ContractError('passed verification requires evidence');
528
+ if (change.verification?.evidence_ref) {
529
+ const receipt = JSON.parse(requireRef(change.verification.evidence_ref).text);
530
+ if (receipt.run_id !== packet.run_context.run_id || receipt.change_id !== change.change_id ||
531
+ receipt.status !== change.verification.status || receipt.path !== change.path ||
532
+ receipt.after_hash !== change.after_hash || receipt.environment !== change.environment ||
533
+ !same(receipt.target, packet.run_context.target) ||
534
+ (change.verification.deployed === true && receipt.deployed !== true))
535
+ throw new ContractError('verification receipt differs from change record');
536
+ if (receipt.status === 'passed' && (!Array.isArray(receipt.checks) || !receipt.checks.length ||
537
+ receipt.checks.some(check => check.status !== 'passed' || !check.id || !check.observed ||
538
+ !check.evidence_ref || !check.after_hash || check.after_hash !== change.after_hash ||
539
+ requireRef(check.evidence_ref).sha256 !== change.after_hash)))
540
+ throw new ContractError('verification check lacks actual changed bytes and passing observation');
541
+ if (change.environment === 'local' && change.verification.observation_id &&
542
+ (receipt.observation_id !== change.verification.observation_id ||
543
+ receipt.observation_evidence_ref !== change.verification.observation_evidence_ref))
544
+ throw new ContractError('local verification receipt is not bound to its after observation');
545
+ if (change.environment === 'production' && change.verification.deployed === true) {
546
+ const deployment = receipt.deployment;
547
+ const evidence = deployment?.evidence_ref ? JSON.parse(requireRef(deployment.evidence_ref).text) : null;
548
+ if (!deployment || !same(deployment.target, packet.run_context.target) ||
549
+ deployment.path !== change.path || deployment.after_hash !== change.after_hash ||
550
+ !evidence || evidence.run_id !== packet.run_context.run_id || evidence.environment !== 'production' ||
551
+ !same(evidence.target, packet.run_context.target) || evidence.path !== change.path ||
552
+ evidence.after_hash !== change.after_hash || !named(evidence.confirmed_by))
553
+ throw new ContractError('deployment receipt does not bind target path and bytes');
554
+ if (receipt.status === 'passed' && (receipt.observation_id !== change.verification.observation_id ||
555
+ receipt.observation_evidence_ref !== change.verification.observation_evidence_ref ||
556
+ !receipt.observation_id || !receipt.observation_evidence_ref ||
557
+ !packet.after?.input.observations.some(obs => obs.observation_id === receipt.observation_id &&
558
+ obs.evidence_ref === receipt.observation_evidence_ref && ['pass', 'partial'].includes(obs.state))))
559
+ throw new ContractError('production verification is not bound to after observation evidence');
560
+ }
561
+ }
562
+ }
563
+ for (const item of packet.remaining) if (!item.problem || !item.reason || !item.required_input_or_authority ||
564
+ !item.next_action || !item.reobserve_when) throw new ContractError('remaining work row incomplete');
565
+ const remaining = [...packet.remaining];
566
+ for (const finding of findings.filter(x => x.status !== 'verified_production' &&
567
+ !packet.remaining.some(row => row.finding_id && finding.finding_ids.includes(row.finding_id)))) {
568
+ if (finding.status === 'verified_local') {
569
+ remaining.push({ finding_id: finding.finding_id,
570
+ problem: `Production application and observation pending for ${finding.unit_key}`,
571
+ reason: 'production_deployment_pending', required_input_or_authority: 'site owner deployment authority and production target',
572
+ next_action: 'Deploy the locally verified change, then fetch the production response',
573
+ reobserve_when: 'after authorized production deployment and a new raw response' });
574
+ } else remaining.push({ finding_id: finding.finding_id, problem: `${finding.message} ${finding.unit_key}`,
575
+ reason: finding.status, required_input_or_authority: finding.remediation?.required_authority ?? 'source evidence and owner review',
576
+ next_action: finding.remediation?.mechanism ?? 'Confirm the issue from raw evidence',
577
+ reobserve_when: finding.remediation?.validation ?? 'after new evidence is collected' });
578
+ }
579
+ const costIssues = [
580
+ ...packet.costs.reservations.filter(x => ['unknown', 'overrun'].includes(x.status)).map(x => {
581
+ const settlement = packet.costs.settlements.find(item => item.reservation_id === x.reservation_id);
582
+ const format = amounts => amounts?.map(item => `${item.amount} ${item.currency ?? item.unit}`).join(', ') ?? 'unknown';
583
+ return `${x.provider}: ${x.status} (reserved ${format(x.upperBounds)}; actual ${format(settlement?.actual)})`;
584
+ }),
585
+ ...(packet.capabilities?.selected ?? []).filter(x => x.cost_enforcement === 'host_policy').map(x => `${x.provider}: host_policy`),
586
+ ...(packet.capabilities?.limitations ?? []),
587
+ ];
588
+ if (packet.costs.reservations.some(x => x.provider === 'anthropic') ||
589
+ (packet.capabilities?.selected ?? []).some(x => x.provider === 'anthropic'))
590
+ costIssues.push('Anthropic direct v0.1.0: text-only; claude-fable-5-1, claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5; max output 128000; one token reserve >=1000000+max_tokens per attempt; tools/search and currency-only or mixed bounds unsupported; reservation is capacity, not actual billing');
591
+ const result = { schema_version: '1.0.0', run_context: packet.run_context,
592
+ data_class: packet.data_class ?? 'unknown', baseline: { input: packet.baseline.input, scores: baselineScores },
593
+ after: packet.after ? { input: packet.after.input, scores: afterScores } : null, comparison,
594
+ metrics: packet.metrics, findings, changes: packet.changes, costs: packet.costs,
595
+ capabilities: packet.capabilities ?? null, cost_issues: costIssues, metric_origin_limitations: metricOriginLimitations, remaining,
596
+ evidence_root: packet.evidence_root, evidence_index: packet.evidence_index,
597
+ audit_ref: packet.audit_ref ?? null, audit_snapshots: snapshots, source_ref: packet.source_ref };
598
+ const byProfile = new Map(afterScores?.map(x => [x.profile, x]) ?? []);
599
+ const scoreRows = baselineScores.map(before => {
600
+ const after = byProfile.get(before.profile);
601
+ const delta = comparison.score_deltas.find(x => x.profile === before.profile);
602
+ return [before.profile, before.score, after?.score ?? 'unknown', delta?.percentage_point_delta ?? 'unknown',
603
+ `${before.coverage ?? 'unknown'} → ${after?.coverage ?? 'unknown'}`,
604
+ `${before.missing_bounds?.join('–') ?? 'unknown'} → ${after?.missing_bounds?.join('–') ?? 'unknown'}`,
605
+ `${before.readiness_status} → ${after?.readiness_status ?? 'unknown'}`];
606
+ });
607
+ const remediationRows = findings.map(f => [f.priority, `${f.message} ${f.unit_key}`, f.sources.map(x => `${x.source_ref} ${x.evidence_ref}`).join('; '),
608
+ f.remediation ? `${f.remediation.mechanism} (${f.remediation.source_url}, checked ${f.remediation.checked_date})` : 'unknown',
609
+ packet.changes.filter(x => x.finding_ids.some(id => f.finding_ids.includes(id))).map(x => x.path).join('; ') || f.remediation?.location_hint || 'owner implementation location required',
610
+ f.remediation ? `${f.remediation.cost}; ${f.remediation.effort}` : 'unknown', f.remediation?.validation ?? 'unknown',
611
+ f.resolution ? `${f.status} (resolved by ${f.resolution.evaluator_type} observation from ${f.resolution.source_ref})` : f.status]);
612
+ const outcomeRows = metricRows(packet.metrics);
613
+ const remainingRows = remaining.map(x => [x.problem, x.reason, x.required_input_or_authority, x.next_action, x.reobserve_when]);
614
+ const markdown = `# optbrew report\n\n${packet.data_class === 'synthetic' ? '**Synthetic example — no real site or provider outcome.**\n\n' : ''}` +
615
+ `Target: ${html(packet.run_context.target)} \nEnvironment: ${html(packet.run_context.environment)} \nGoals: ${html(packet.run_context.goals.join(', '))} \n` +
616
+ `Comparison: ${html(comparison.status)} (${html(comparison.reasons.join(', ') || 'none')}) \n` +
617
+ `Baseline blockers: ${html(baselineScores[0]?.blockers.length ?? 0)} (${html(baselineScores[0]?.blockers.map(x => `${x.family_id} ${x.unit_key}`).join('; ') || 'none')}) → ` +
618
+ `After blockers: ${afterScores ? html(afterScores[0]?.blockers.length ?? 0) : 'unknown'} (${afterScores ? html(afterScores[0]?.blockers.map(x => `${x.family_id} ${x.unit_key}`).join('; ') || 'none') : 'after not observed'}); ` +
619
+ `after core unknown: ${afterScores ? html(afterScores[0]?.unknown_core.length ?? 0) : 'unknown'}; actual outcomes: ${packet.metrics.length ? 'see measured and missing states below' : 'not_connected / unknown'} \n` +
620
+ `Cost and capability limitations: ${html(costIssues.join('; ') || 'none recorded')} \n` +
621
+ `Metric source limitations: ${html(metricOriginLimitations.join('; ') || 'no measured provider input')}\n\n` +
622
+ `## Score\n\n${table(['Area', 'Before', 'After', 'Difference', 'Coverage before → after', 'Missing bounds', 'Status'], scoreRows)}\n\n` +
623
+ `## Remediation\n\n${table(['Priority', 'Problem / URL', 'Evidence', 'Selected remedy', 'Change path or owner location', 'Cost / effort', 'Verification', 'Status'], remediationRows)}\n\n` +
624
+ `## Actual outcomes\n\n${table(['Engine / property', 'Metric', 'Window / sample', 'Before raw', 'After raw', 'Difference', 'Completion', 'Limitations'], outcomeRows)}\n\n` +
625
+ `## Remaining work\n\n${table(['Problem', 'Reason', 'Needed input / authority', 'Next action', 'Reobserve when'], remainingRows)}\n\n` +
626
+ `Evidence root: ${html(packet.evidence_root)}; run ID: ${html(packet.run_context.run_id)}. Read report.json to replay raw score inputs.\n`;
627
+ result.tables = { score: scoreRows, remediation: remediationRows, actual_outcomes: outcomeRows, remaining_work: remainingRows };
628
+ return { json: result, markdown };
629
+ }
630
+
631
+ /** Write both outputs after all validation; temporary files stay in the destination. */
632
+ export async function writeReport(packet, outputDir) {
633
+ const built = buildReport(packet);
634
+ await mkdir(outputDir, { recursive: true });
635
+ const jsonPath = path.resolve(outputDir, 'report.json'), markdownPath = path.resolve(outputDir, 'report.md');
636
+ for (const file of [jsonPath, markdownPath]) {
637
+ try { await access(file); throw new ContractError(`report output already exists: ${file}`); }
638
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
639
+ }
640
+ const temporary = [`${jsonPath}.${process.pid}.tmp`, `${markdownPath}.${process.pid}.tmp`];
641
+ let jsonWritten = false;
642
+ try {
643
+ await writeFile(temporary[0], `${JSON.stringify(built.json, null, 2)}\n`, { flag: 'wx' });
644
+ await writeFile(temporary[1], built.markdown, { flag: 'wx' });
645
+ await rename(temporary[0], jsonPath); jsonWritten = true;
646
+ await rename(temporary[1], markdownPath);
647
+ } catch (error) {
648
+ if (jsonWritten) await rm(jsonPath, { force: true });
649
+ throw error;
650
+ } finally { await Promise.all(temporary.map(file => rm(file, { force: true }))); }
651
+ return { jsonPath, markdownPath };
652
+ }