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