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,41 @@
|
|
|
1
|
+
import { readFile, realpath, mkdir, rename, writeFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { score } from './core/score.mjs';
|
|
6
|
+
import { compare } from './core/compare.mjs';
|
|
7
|
+
import { writeReport } from './core/report.mjs';
|
|
8
|
+
|
|
9
|
+
/** Local calculation CLI. */
|
|
10
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
11
|
+
const usage = 'usage: cli.mjs score --input <file> --output <file> | compare --before <file> --after <file> --output <file> | report --input <packet.json> --output-dir <directory>';
|
|
12
|
+
let result, destination;
|
|
13
|
+
if (argv.length === 5 && argv[0] === 'report' && argv[1] === '--input' && argv[3] === '--output-dir' && argv[2] && argv[4]) {
|
|
14
|
+
const input = path.resolve(argv[2]);
|
|
15
|
+
const packet = JSON.parse(await readFile(input, 'utf8'));
|
|
16
|
+
packet.evidence_root = path.resolve(path.dirname(input), packet.evidence_root ?? '.');
|
|
17
|
+
const relative = path.relative(await realpath(path.dirname(input)), await realpath(packet.evidence_root));
|
|
18
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`)) throw new Error('evidence root must stay under packet run directory');
|
|
19
|
+
return writeReport(packet, path.resolve(argv[4]));
|
|
20
|
+
} else if (argv.length === 5 && argv[0] === 'score' && argv[1] === '--input' && argv[3] === '--output' && argv[2] && argv[4]) {
|
|
21
|
+
result = score(JSON.parse(await readFile(argv[2], 'utf8')));
|
|
22
|
+
destination = path.resolve(argv[4]);
|
|
23
|
+
} else if (argv.length === 7 && argv[0] === 'compare' && argv[1] === '--before' && argv[3] === '--after' &&
|
|
24
|
+
argv[5] === '--output' && argv[2] && argv[4] && argv[6]) {
|
|
25
|
+
const before = JSON.parse(await readFile(argv[2], 'utf8'));
|
|
26
|
+
const after = JSON.parse(await readFile(argv[4], 'utf8'));
|
|
27
|
+
result = compare(before, after);
|
|
28
|
+
destination = path.resolve(argv[6]);
|
|
29
|
+
} else throw new Error(usage);
|
|
30
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
31
|
+
const temp = `${destination}.${process.pid}.tmp`;
|
|
32
|
+
try {
|
|
33
|
+
await writeFile(temp, `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' });
|
|
34
|
+
await rename(temp, destination);
|
|
35
|
+
} finally { await rm(temp, { force: true }); }
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (process.argv[1] && realpathSync(path.resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
40
|
+
runCli().catch(error => { process.stderr.write(`${error.name}: ${error.message}\n`); process.exitCode = 1; });
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Resample whole question clusters, keeping repetitions dependent. */
|
|
2
|
+
export function clusterBootstrap({ questionIds, valuesByQuestion, seed, iterations = 2000, confidence = 0.95 }) {
|
|
3
|
+
if (!Array.isArray(questionIds) || !questionIds.length || new Set(questionIds).size !== questionIds.length ||
|
|
4
|
+
!Number.isInteger(seed) || !Number.isInteger(iterations) || iterations < 1 || !(confidence > 0 && confidence < 1))
|
|
5
|
+
throw new TypeError('invalid cluster bootstrap parameters');
|
|
6
|
+
const clusters = questionIds.map(id => {
|
|
7
|
+
const values = valuesByQuestion?.[id];
|
|
8
|
+
if (!Array.isArray(values) || !values.length || values.some(x => !Number.isFinite(x)))
|
|
9
|
+
throw new TypeError(`invalid values for question ${id}`);
|
|
10
|
+
return values;
|
|
11
|
+
});
|
|
12
|
+
let state = seed >>> 0;
|
|
13
|
+
const random = () => ((state = (1664525 * state + 1013904223) >>> 0) / 2 ** 32);
|
|
14
|
+
const draws = [];
|
|
15
|
+
for (let iteration = 0; iteration < iterations; iteration++) {
|
|
16
|
+
let sum = 0, count = 0;
|
|
17
|
+
for (let i = 0; i < clusters.length; i++) {
|
|
18
|
+
const cluster = clusters[Math.floor(random() * clusters.length)];
|
|
19
|
+
for (const value of cluster) { sum += value; count++; }
|
|
20
|
+
}
|
|
21
|
+
draws.push(sum / count);
|
|
22
|
+
}
|
|
23
|
+
draws.sort((a, b) => a - b);
|
|
24
|
+
const tail = (1 - confidence) / 2;
|
|
25
|
+
const quantile = p => {
|
|
26
|
+
const x = p * (draws.length - 1), lo = Math.floor(x), hi = Math.ceil(x);
|
|
27
|
+
return draws[lo] + (draws[hi] - draws[lo]) * (x - lo);
|
|
28
|
+
};
|
|
29
|
+
return { interval: [quantile(tail), quantile(1 - tail)], method: 'question_cluster_percentile',
|
|
30
|
+
seed, iterations, unique_questions: questionIds.length };
|
|
31
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
6
|
+
const manifestName = 'bundle-manifest.json';
|
|
7
|
+
export const evaluationSourcePaths = ['references/check-registry.json', 'references/profiles.json',
|
|
8
|
+
'references/scoring.md', 'references/measurement.md', 'scripts/core/score.mjs', 'scripts/core/weights.mjs',
|
|
9
|
+
'scripts/core/contracts.mjs', 'scripts/core/compare.mjs', 'scripts/core/metrics.mjs', 'scripts/core/bootstrap.mjs'];
|
|
10
|
+
export const protocolSourcePaths = ['references/schemas/contract.schema.json', 'scripts/core/protocol.mjs'];
|
|
11
|
+
|
|
12
|
+
/** Match the package.json skill exclusions for generated Python cache files. */
|
|
13
|
+
export function isDistributedBundlePath(relative) {
|
|
14
|
+
const parts = relative.split(/[\\/]/);
|
|
15
|
+
return !parts.some(part => ['__pycache__', 'tests', 'evals'].includes(part) || part.startsWith('_백업_원본_')) &&
|
|
16
|
+
!relative.endsWith('.pyc') && !/^test_.*\.py$/.test(parts.at(-1));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function listFiles(root, relative = '') {
|
|
20
|
+
const result = [];
|
|
21
|
+
for (const entry of await readdir(path.join(root, relative), { withFileTypes: true })) {
|
|
22
|
+
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
|
23
|
+
if (name === manifestName) continue;
|
|
24
|
+
if (!isDistributedBundlePath(name)) continue;
|
|
25
|
+
if (entry.isDirectory()) result.push(...await listFiles(root, name));
|
|
26
|
+
else if (entry.isFile()) result.push(name);
|
|
27
|
+
else throw new Error(`bundle contains link or special file: ${name}`);
|
|
28
|
+
}
|
|
29
|
+
return result.sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Hash every distributed file; the manifest never hashes itself. */
|
|
33
|
+
export async function createBundleManifest({ bundleRoot, releaseVersion }) {
|
|
34
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(releaseVersion)) throw new Error('invalid release version');
|
|
35
|
+
const files = [];
|
|
36
|
+
for (const name of await listFiles(bundleRoot)) {
|
|
37
|
+
files.push({ path: name, sha256: digest(await readFile(path.join(bundleRoot, name))) });
|
|
38
|
+
}
|
|
39
|
+
// Synthetic install fixtures contain only SKILL.md; production release checks completeness.
|
|
40
|
+
const selected = paths => paths.every(name => files.some(file => file.path === name))
|
|
41
|
+
? paths.map(name => files.find(file => file.path === name)) : [];
|
|
42
|
+
const evaluation_sources = selected(evaluationSourcePaths);
|
|
43
|
+
const protocol_sources = selected(protocolSourcePaths);
|
|
44
|
+
const identity = evaluation_sources.length && protocol_sources.length ? {
|
|
45
|
+
evaluation_version: '0.2.0', protocol_version: '0.1.0',
|
|
46
|
+
evaluation_sources, evaluation_hash: digest(JSON.stringify(evaluation_sources)),
|
|
47
|
+
protocol_sources, protocol_hash: digest(JSON.stringify(protocol_sources)) } : {};
|
|
48
|
+
return { schema_version: '1.0.0', release_version: releaseVersion,
|
|
49
|
+
...identity, files, bundle_hash: digest(JSON.stringify(files)) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Verify the exact allowlist, byte hashes, and aggregate hash. */
|
|
53
|
+
export async function verifyBundle({ bundleRoot, manifest }) {
|
|
54
|
+
const errors = [];
|
|
55
|
+
let actual;
|
|
56
|
+
try { actual = await createBundleManifest({ bundleRoot, releaseVersion: manifest?.release_version }); }
|
|
57
|
+
catch (error) { return { status: 'invalid', checked_files: 0, errors: [{ path: '', code: error.message }], bundle_hash: null }; }
|
|
58
|
+
if (!Array.isArray(manifest?.files) || manifest.schema_version !== '1.0.0') errors.push({ path: manifestName, code: 'invalid_manifest' });
|
|
59
|
+
const expected = new Map(Array.isArray(manifest?.files) ? manifest.files.map(f => [f.path, f.sha256]) : []);
|
|
60
|
+
const found = new Map(actual.files.map(f => [f.path, f.sha256]));
|
|
61
|
+
if (expected.size !== manifest?.files?.length) errors.push({ path: manifestName, code: 'duplicate_path' });
|
|
62
|
+
for (const [name, sha] of found) {
|
|
63
|
+
if (!expected.has(name)) errors.push({ path: name, code: 'unexpected_file' });
|
|
64
|
+
else if (expected.get(name) !== sha) errors.push({ path: name, code: 'hash_mismatch' });
|
|
65
|
+
}
|
|
66
|
+
for (const name of expected.keys()) if (!found.has(name)) errors.push({ path: name, code: 'missing_file' });
|
|
67
|
+
if (manifest?.bundle_hash !== actual.bundle_hash ||
|
|
68
|
+
JSON.stringify(manifest?.files) !== JSON.stringify(actual.files)) errors.push({ path: manifestName, code: 'bundle_hash_mismatch' });
|
|
69
|
+
for (const field of ['evaluation_version', 'protocol_version', 'evaluation_sources', 'protocol_sources', 'evaluation_hash', 'protocol_hash'])
|
|
70
|
+
if (JSON.stringify(manifest?.[field]) !== JSON.stringify(actual[field]))
|
|
71
|
+
errors.push({ path: manifestName, code: field.endsWith('_version') ? 'migration_required' : `${field}_mismatch` });
|
|
72
|
+
return { status: errors.length ? 'invalid' : 'valid', checked_files: actual.files.length, errors, bundle_hash: actual.bundle_hash };
|
|
73
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Match actual evidence surfaces to requested checks without inventing missing capability. */
|
|
2
|
+
export function selectCapabilities(available, requirements) {
|
|
3
|
+
const selected = []; const missing = []; const limitations = [];
|
|
4
|
+
for (const requirement of requirements) {
|
|
5
|
+
const names = requirement === 'structured_data' ? ['raw_html'] : [requirement];
|
|
6
|
+
const candidate = available.find(item => names.includes(item.capability) && item.bounded === true &&
|
|
7
|
+
['code', 'host_policy', 'none'].includes(item.cost_enforcement));
|
|
8
|
+
if (!candidate || (requirement === 'bulk_crawl' && candidate.cost_enforcement !== 'code')) {
|
|
9
|
+
missing.push(requirement);
|
|
10
|
+
if (requirement === 'bulk_crawl' && available.some(item => item.capability === 'bulk_crawl'))
|
|
11
|
+
limitations.push('bulk_crawl: boundary or cost enforcement is host_policy/unverified');
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
selected.push({ capability: candidate.capability, provider: candidate.provider, tool_ref: candidate.tool_ref,
|
|
15
|
+
cost_enforcement: candidate.cost_enforcement });
|
|
16
|
+
if (candidate.cost_enforcement === 'host_policy') limitations.push(`${candidate.provider}: host_policy cannot enforce paid cost in code`);
|
|
17
|
+
}
|
|
18
|
+
return { selected, missing, limitations };
|
|
19
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import { isIP } from 'node:net';
|
|
6
|
+
import { performance } from 'node:perf_hooks';
|
|
7
|
+
import { assertInScope, assertUrlInScope } from './scope.mjs';
|
|
8
|
+
import { anchorLinks, canonicalLink } from './html.mjs';
|
|
9
|
+
|
|
10
|
+
const HEADER_NAMES = ['content-type', 'content-length', 'content-range', 'content-encoding', 'location', 'x-robots-tag', 'last-modified', 'etag'];
|
|
11
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
12
|
+
const text = value => Buffer.isBuffer(value) ? value : Buffer.from(value ?? '');
|
|
13
|
+
|
|
14
|
+
function auxiliaryDocument(url) {
|
|
15
|
+
const pathname = decodeURIComponent(new URL(url).pathname).toLowerCase();
|
|
16
|
+
return /\.(?:txt|xml)(?:\.gz)?$/.test(pathname) ||
|
|
17
|
+
/(?:^|\/)(?:sitemap|(?:[^/]*[-_])?polic(?:y|ies))(?:[-_.\/]|$)/.test(pathname);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function undeclaredAuxiliary(url, allowed) {
|
|
21
|
+
if (!auxiliaryDocument(url.href) || allowed.has(url.href)) return;
|
|
22
|
+
const error = new Error('auxiliary document was not declared');
|
|
23
|
+
error.code = 'auxiliary_undeclared';
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeHeaders(headers) {
|
|
28
|
+
const source = headers instanceof Headers ? Object.fromEntries(headers) : headers ?? {};
|
|
29
|
+
return Object.fromEntries(Object.entries(source).filter(([name]) => HEADER_NAMES.includes(name.toLowerCase()))
|
|
30
|
+
.map(([name, value]) => [name.toLowerCase(), String(value)]));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Response header lines in wire order; repeated lines (for example two X-Robots-Tag lines) stay separate. */
|
|
34
|
+
export function headerLines(response) {
|
|
35
|
+
const lines = [];
|
|
36
|
+
if (Array.isArray(response.rawHeaders)) {
|
|
37
|
+
for (let i = 0; i + 1 < response.rawHeaders.length; i += 2) {
|
|
38
|
+
const name = String(response.rawHeaders[i]).toLowerCase();
|
|
39
|
+
if (HEADER_NAMES.includes(name)) lines.push({ name, value: String(response.rawHeaders[i + 1]) });
|
|
40
|
+
}
|
|
41
|
+
return lines;
|
|
42
|
+
}
|
|
43
|
+
const source = response.headers instanceof Headers ? Object.fromEntries(response.headers) : response.headers ?? {};
|
|
44
|
+
for (const [name, value] of Object.entries(source))
|
|
45
|
+
if (HEADER_NAMES.includes(name.toLowerCase())) for (const item of Array.isArray(value) ? value : [value])
|
|
46
|
+
lines.push({ name: name.toLowerCase(), value: String(item) });
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Node's lookup callback pins the already-vetted address while TLS still verifies the URL hostname. */
|
|
51
|
+
async function nodeTransport({ url, address, timeoutMs, maxBytes, headers = {}, signal }) {
|
|
52
|
+
const client = url.protocol === 'https:' ? https : http;
|
|
53
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '');
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const req = client.request(url, {
|
|
56
|
+
method: 'GET', agent: false, signal, headers: { 'User-Agent': 'optbrew/0.1 (bounded public audit)', Accept: '*/*', 'Accept-Encoding': 'identity', ...headers },
|
|
57
|
+
lookup: (_hostname, options, callback) => options.all ?
|
|
58
|
+
callback(null, [{ address: address.address, family: address.family }]) : callback(null, address.address, address.family),
|
|
59
|
+
...(url.protocol === 'https:' ? { rejectUnauthorized: true,
|
|
60
|
+
...(!isIP(hostname) ? { servername: hostname } : {}) } : {}),
|
|
61
|
+
}, res => {
|
|
62
|
+
const chunks = []; let size = 0;
|
|
63
|
+
if (Number(res.headers['content-length']) > maxBytes) {
|
|
64
|
+
req.destroy(new Error('response byte limit exceeded')); return;
|
|
65
|
+
}
|
|
66
|
+
res.on('data', chunk => {
|
|
67
|
+
size += chunk.length;
|
|
68
|
+
if (size > maxBytes) { req.destroy(new Error('response byte limit exceeded')); return; }
|
|
69
|
+
chunks.push(chunk);
|
|
70
|
+
});
|
|
71
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, rawHeaders: res.rawHeaders, body: Buffer.concat(chunks) }));
|
|
72
|
+
res.on('error', reject);
|
|
73
|
+
});
|
|
74
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error('request timeout')));
|
|
75
|
+
req.on('error', reject);
|
|
76
|
+
req.end();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function addressesFor(url, resolve) {
|
|
81
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
82
|
+
if (isIP(host)) return [{ address: host, family: isIP(host) }];
|
|
83
|
+
const values = await resolve(host);
|
|
84
|
+
return (Array.isArray(values) ? values : [values]).map(x => {
|
|
85
|
+
const address = typeof x === 'string' ? x : x?.address;
|
|
86
|
+
return { address, family: isIP(address) };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fetch one URL with fresh DNS validation at every redirect and a pinned connection. */
|
|
91
|
+
export async function fetchScoped(input, { context, transport = nodeTransport, resolve = host => lookup(host, { all: true, verbatim: true }), timeoutMs = 20000, maxBytes = 5242880, maxRedirects = 10, headers = {}, allowedAuxiliary = null, reserveRequest = null }) {
|
|
92
|
+
if (![timeoutMs, maxBytes, maxRedirects].every(Number.isInteger) || timeoutMs < 1 || timeoutMs > 20000 || maxBytes < 1 || maxBytes > 5242880 || maxRedirects < 0 || maxRedirects > 10)
|
|
93
|
+
throw new Error('fetch limits exceed context ceiling');
|
|
94
|
+
let url = assertUrlInScope(input, context.scope);
|
|
95
|
+
if (allowedAuxiliary) undeclaredAuxiliary(url, allowedAuxiliary);
|
|
96
|
+
const requested_url = String(input);
|
|
97
|
+
const redirect_chain = [];
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const deadline = performance.now() + timeoutMs;
|
|
100
|
+
let rejectTimeout;
|
|
101
|
+
const timeout = new Promise((_, reject) => { rejectTimeout = reject; });
|
|
102
|
+
const timer = setTimeout(() => { controller.abort(); rejectTimeout(new Error('request timeout')); }, timeoutMs);
|
|
103
|
+
const withinDeadline = promise => Promise.race([promise, timeout]);
|
|
104
|
+
try {
|
|
105
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
106
|
+
const addresses = await withinDeadline(addressesFor(url, resolve));
|
|
107
|
+
try { assertInScope(url.href, context.scope, addresses); }
|
|
108
|
+
catch (error) { error.code = 'scope_rejected'; error.redirect_chain = redirect_chain; throw error; }
|
|
109
|
+
// Pick only a vetted address. The transport must use this address, not resolve again.
|
|
110
|
+
const remainingMs = Math.ceil(deadline - performance.now());
|
|
111
|
+
if (remainingMs <= 0 || controller.signal.aborted) throw new Error('request timeout');
|
|
112
|
+
// Reserve the shared budget immediately before the real request; redirect hops are requests too.
|
|
113
|
+
if (reserveRequest && !reserveRequest()) {
|
|
114
|
+
const error = new Error('request budget exhausted');
|
|
115
|
+
error.code = 'request_limit';
|
|
116
|
+
error.redirect_chain = redirect_chain;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
const response = await withinDeadline(transport({ url, address: addresses[0], timeoutMs: remainingMs, maxBytes, headers, signal: controller.signal }));
|
|
120
|
+
if (!Number.isInteger(response?.status) || response.status < 100 || response.status > 599) throw new Error('invalid transport status');
|
|
121
|
+
const body = text(response.body);
|
|
122
|
+
if (body.length > maxBytes) throw new Error('response byte limit exceeded');
|
|
123
|
+
const savedHeaders = safeHeaders(response.headers);
|
|
124
|
+
if (REDIRECTS.has(response.status) && savedHeaders.location) {
|
|
125
|
+
if (hop === maxRedirects) throw new Error('redirect limit exceeded');
|
|
126
|
+
let next;
|
|
127
|
+
try { next = assertUrlInScope(new URL(savedHeaders.location, url).href, context.scope); }
|
|
128
|
+
catch {
|
|
129
|
+
const error = new Error('redirect outside scope');
|
|
130
|
+
error.code = 'redirect_outside_scope';
|
|
131
|
+
error.redirect_chain = [...redirect_chain, { url: url.href, status: response.status, location: '[blocked]' }];
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
if (allowedAuxiliary) {
|
|
135
|
+
try { undeclaredAuxiliary(next, allowedAuxiliary); }
|
|
136
|
+
catch (error) { error.redirect_chain = [...redirect_chain, { url: url.href, status: response.status, location: '[blocked]' }]; throw error; }
|
|
137
|
+
}
|
|
138
|
+
redirect_chain.push({ url: url.href, status: response.status, location: next.href });
|
|
139
|
+
url = next;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
requested_url, normalized_url: assertUrlInScope(requested_url, context.scope).href, final_url: url.href,
|
|
144
|
+
status: response.status, headers: savedHeaders, header_lines: headerLines(response), redirect_chain, raw_html_ref: null,
|
|
145
|
+
content_hash: createHash('sha256').update(body).digest('hex'),
|
|
146
|
+
capability: savedHeaders['content-type']?.toLowerCase().includes('html') &&
|
|
147
|
+
(!savedHeaders['content-encoding'] || savedHeaders['content-encoding'].toLowerCase() === 'identity') ? 'raw_html' : 'raw_response',
|
|
148
|
+
source_ref: 'optbrew:collector', evidence_ref: null, error: null, body,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
throw new Error('redirect limit exceeded');
|
|
152
|
+
} catch (error) { error.redirect_chain ??= [...redirect_chain]; throw error; }
|
|
153
|
+
finally { clearTimeout(timer); }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Index of the declared robots.txt artifact on the same origin as the page, as the audit and the report both select it. */
|
|
157
|
+
export function declaredRobotsIndex(artifacts, declaredRobots, pageFinalUrl) {
|
|
158
|
+
return artifacts.findIndex(x => {
|
|
159
|
+
try { const u = new URL(x.final_url); return declaredRobots.has(x.normalized_url) && u.origin === new URL(pageFinalUrl).origin && u.pathname === '/robots.txt'; }
|
|
160
|
+
catch { return false; }
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Anchors plus the canonical target, which the report needs in order to judge canonical consistency. */
|
|
165
|
+
export function links(html, base) {
|
|
166
|
+
const out = anchorLinks(html, base);
|
|
167
|
+
const canonical = canonicalLink(html);
|
|
168
|
+
if (canonical) { try { out.push(new URL(canonical, base).href); } catch { /* malformed canonical */ } }
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Bounded crawl of fixed protocol URLs and their in-scope links. */
|
|
173
|
+
export async function collect(context, { transport, resolve } = {}) {
|
|
174
|
+
const scope = context?.scope;
|
|
175
|
+
const limit = Math.min(scope?.max_urls ?? 50, 50);
|
|
176
|
+
const depthLimit = Math.min(scope?.max_depth ?? 3, 3);
|
|
177
|
+
const concurrency = Math.min(scope?.concurrency ?? 2, 2);
|
|
178
|
+
if (![limit, depthLimit, concurrency].every(Number.isInteger) || limit < 1 || depthLimit < 0 || concurrency < 1)
|
|
179
|
+
throw new Error('invalid collection limits');
|
|
180
|
+
const initial = context.protocol.page_types.flatMap(type => type.urls.map(ref => ref.normalized));
|
|
181
|
+
const auxiliary = context.protocol.auxiliary_resources ?? [];
|
|
182
|
+
const declaredAuxiliary = new Set(auxiliary.map(resource => resource.normalized));
|
|
183
|
+
const allowedDocuments = new Set([...initial, ...declaredAuxiliary]);
|
|
184
|
+
if (initial.some(url => /\/(?:robots\.txt|sitemap[^/]*\.xml)$/i.test(decodeURIComponent(new URL(url).pathname))))
|
|
185
|
+
throw new Error('robots and sitemap must be auxiliary resources');
|
|
186
|
+
if (auxiliary.length && initial.length + auxiliary.length > limit) throw new Error('selected pages and auxiliary resources exceed request scope');
|
|
187
|
+
const queue = []; const seen = new Set(); const skipped = [];
|
|
188
|
+
// Every queued or rejected URL leaves one ledger entry, so skipped URLs and the request count can be checked later.
|
|
189
|
+
const ledger = [];
|
|
190
|
+
const skip = (url, reason) => { skipped.push({ url, reason }); ledger.push({ url, requests: 0, outcome: 'skipped', skipped_reason: reason, error: null }); };
|
|
191
|
+
const enqueue = (url, depth, auxiliaryResource = false) => {
|
|
192
|
+
let normalized;
|
|
193
|
+
try { normalized = assertUrlInScope(url, scope).href; }
|
|
194
|
+
catch { skip(url, 'outside_scope'); return; }
|
|
195
|
+
if (depth > 0 && auxiliaryDocument(normalized) && !allowedDocuments.has(normalized)) {
|
|
196
|
+
skip(normalized, 'auxiliary_undeclared'); return;
|
|
197
|
+
}
|
|
198
|
+
if (seen.has(normalized)) return;
|
|
199
|
+
seen.add(normalized);
|
|
200
|
+
if (depth > depthLimit) { skip(normalized, 'depth_limit'); return; }
|
|
201
|
+
queue.push({ url: normalized, depth, auxiliaryResource });
|
|
202
|
+
};
|
|
203
|
+
initial.forEach(url => enqueue(url, 0));
|
|
204
|
+
for (const resource of auxiliary) {
|
|
205
|
+
if (seen.has(resource.normalized)) throw new Error('auxiliary resource duplicates selected URL');
|
|
206
|
+
const normalized = assertUrlInScope(resource.normalized, scope).href;
|
|
207
|
+
if (normalized !== resource.normalized || assertUrlInScope(resource.original, scope).href !== normalized)
|
|
208
|
+
throw new Error('auxiliary resource URL mismatch');
|
|
209
|
+
enqueue(normalized, 0, true);
|
|
210
|
+
}
|
|
211
|
+
const artifacts = []; let cursor = 0; let requestCount = 0;
|
|
212
|
+
async function worker() {
|
|
213
|
+
while (cursor < queue.length) {
|
|
214
|
+
const item = queue[cursor++];
|
|
215
|
+
if (requestCount >= limit) { skip(item.url, 'url_limit'); continue; }
|
|
216
|
+
let used = 0;
|
|
217
|
+
// The budget decides whether a fetch may start. A redirect chain that has started is allowed to finish (at most
|
|
218
|
+
// maxRedirects further requests), so a chain is never cut in the middle by the limit.
|
|
219
|
+
const reserveRequest = () => {
|
|
220
|
+
if (!used && requestCount >= limit) return false;
|
|
221
|
+
requestCount++; used++;
|
|
222
|
+
return true;
|
|
223
|
+
};
|
|
224
|
+
let artifact;
|
|
225
|
+
// A chain is at most min(10, limit) redirects long, so the requests a started chain adds beyond the limit scale with
|
|
226
|
+
// the limit: at most concurrency x min(10, limit) in all.
|
|
227
|
+
try { artifact = await fetchScoped(item.url, { context, transport, resolve, allowedAuxiliary: allowedDocuments, reserveRequest, maxRedirects: Math.min(10, limit) }); }
|
|
228
|
+
catch (error) {
|
|
229
|
+
const reason = ['scope_rejected', 'redirect_outside_scope', 'auxiliary_undeclared', 'request_limit'].includes(error.code) ? error.code : 'fetch_failed';
|
|
230
|
+
if (reason === 'request_limit' && !used) { skip(item.url, 'url_limit'); continue; }
|
|
231
|
+
if (reason !== 'fetch_failed') skipped.push({ url: item.url, reason });
|
|
232
|
+
// A failed fetch has no hashed response record, so the requests it made cannot be proven later: it counts as one.
|
|
233
|
+
ledger.push({ url: item.url, requests: 1, outcome: 'error', skipped_reason: reason !== 'fetch_failed' ? reason : null, error: reason });
|
|
234
|
+
artifact = { requested_url: item.url, normalized_url: item.url, final_url: null, status: null,
|
|
235
|
+
headers: {}, header_lines: [], redirect_chain: error.redirect_chain ?? [], raw_html_ref: null, content_hash: null, capability: 'none',
|
|
236
|
+
source_ref: 'optbrew:collector', evidence_ref: null, error: reason, body: Buffer.alloc(0) };
|
|
237
|
+
}
|
|
238
|
+
if (!artifact.error) ledger.push({ url: item.url, requests: used, outcome: 'response', skipped_reason: null, error: null });
|
|
239
|
+
artifacts.push(artifact);
|
|
240
|
+
if (!item.auxiliaryResource && artifact.status === 200 && artifact.capability === 'raw_html')
|
|
241
|
+
for (const next of links(artifact.body.toString('utf8'), artifact.final_url)) enqueue(next, item.depth + 1);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
245
|
+
// Counts follow the ledger (failed fetches count once), the same rule the report replay applies.
|
|
246
|
+
const evaluated = ledger.filter(x => x.requests > 0).length;
|
|
247
|
+
const counted = ledger.reduce((sum, x) => sum + x.requests, 0);
|
|
248
|
+
return { artifacts, ledger, discovered_count: seen.size, evaluated_count: evaluated, request_count: counted, skipped,
|
|
249
|
+
limit_reached: skipped.some(x => x.reason === 'url_limit' || x.reason === 'request_limit') };
|
|
250
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { validateEntity } from './contracts.mjs';
|
|
2
|
+
import { freezeProtocol } from './protocol.mjs';
|
|
3
|
+
import { compileUnits } from './weights.mjs';
|
|
4
|
+
import { score } from './score.mjs';
|
|
5
|
+
|
|
6
|
+
const close = (a, b) => Math.abs(a - b) <= 1e-9;
|
|
7
|
+
const rawScore = result => result.M === 0 ? null : 100 * result.N / result.M;
|
|
8
|
+
const rawCoverage = result => result.A === 0 ? null : 100 * result.M / result.A;
|
|
9
|
+
const difference = (before, after) => before === null || after === null ? null : after - before;
|
|
10
|
+
const relative = (before, after) => before === null || after === null || close(before, 0) ? null : (after - before) / before;
|
|
11
|
+
|
|
12
|
+
/** Compare two ScoreInputs only when their frozen conditions and applicable units match. */
|
|
13
|
+
export function compare(before, after) {
|
|
14
|
+
validateEntity('ScoreInput', before);
|
|
15
|
+
const baseline = freezeProtocol(before.run_context);
|
|
16
|
+
if (after === null || after === undefined) return { status: 'no_after', score_deltas: [], reasons: ['after_missing'] };
|
|
17
|
+
validateEntity('ScoreInput', after);
|
|
18
|
+
const followup = freezeProtocol(after.run_context);
|
|
19
|
+
const reasons = [];
|
|
20
|
+
if (baseline.protocol_hash !== followup.protocol_hash) {
|
|
21
|
+
for (const field of ['target', 'environment', 'evaluation_version', 'protocol_version'])
|
|
22
|
+
if (JSON.stringify(baseline.protocol[field]) !== JSON.stringify(followup.protocol[field])) reasons.push(`${field}_changed`);
|
|
23
|
+
for (const field of ['page_types', 'engines', 'families', 'locale', 'region', 'browser', 'device',
|
|
24
|
+
'question_panel', 'surfaces', 'models', 'search_enabled', 'target_identity'])
|
|
25
|
+
if (JSON.stringify(baseline.protocol[field]) !== JSON.stringify(followup.protocol[field])) reasons.push(`${field}_changed`);
|
|
26
|
+
if (!reasons.length) reasons.push('protocol_changed');
|
|
27
|
+
}
|
|
28
|
+
if (reasons.length) return { status: 'not_comparable', score_deltas: [], reasons: [...new Set(reasons)] };
|
|
29
|
+
// The protocol fixes planned units. Evidence-backed N/A can still change the actual denominator.
|
|
30
|
+
const unitSignature = input => compileUnits(input, 'SEO').map(unit => [unit.key, unit.weight]).sort((a, b) => a[0].localeCompare(b[0]));
|
|
31
|
+
const leftUnits = unitSignature(before), rightUnits = unitSignature(after);
|
|
32
|
+
if (leftUnits.length !== rightUnits.length || leftUnits.some(([key, weight], i) =>
|
|
33
|
+
key !== rightUnits[i][0] || !close(weight, rightUnits[i][1]))) reasons.push('applicable_units_changed');
|
|
34
|
+
if (reasons.length) return { status: 'not_comparable', score_deltas: [], reasons: [...new Set(reasons)] };
|
|
35
|
+
const left = score(before), right = score(after);
|
|
36
|
+
const score_deltas = left.map((item, i) => {
|
|
37
|
+
const next = right[i];
|
|
38
|
+
const oldScore = rawScore(item), newScore = rawScore(next);
|
|
39
|
+
return { profile: item.profile, before: oldScore, after: newScore,
|
|
40
|
+
raw_difference: difference(oldScore, newScore),
|
|
41
|
+
percentage_point_delta: difference(oldScore, newScore),
|
|
42
|
+
relative_change: relative(oldScore, newScore),
|
|
43
|
+
coverage_percentage_point_delta: difference(rawCoverage(item), rawCoverage(next)),
|
|
44
|
+
A_delta: next.A - item.A, M_delta: next.M - item.M, N_delta: next.N - item.N };
|
|
45
|
+
});
|
|
46
|
+
return { status: 'comparable', score_deltas, reasons: [] };
|
|
47
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
const schema = JSON.parse(readFileSync(new URL('../../references/schemas/contract.schema.json', import.meta.url)));
|
|
4
|
+
const registry = JSON.parse(readFileSync(new URL('../../references/check-registry.json', import.meta.url)));
|
|
5
|
+
const families = new Map(registry.families.map(family => [family.family_id, family]));
|
|
6
|
+
const tolerance = 1e-9;
|
|
7
|
+
const cwv = { 'lcp-field': [2500, 4000, 'ms'], 'inp-field': [200, 500, 'ms'], 'cls-field': [0.1, 0.25, 'unitless'] };
|
|
8
|
+
|
|
9
|
+
export class ContractError extends Error {
|
|
10
|
+
constructor(message) { super(message); this.name = 'ContractError'; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fail(path, message) { throw new ContractError(`${path}: ${message}`); }
|
|
14
|
+
function plainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) &&
|
|
15
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); }
|
|
16
|
+
|
|
17
|
+
function jsonSafe(value, path = '$', seen = new Set()) {
|
|
18
|
+
if (value === undefined || typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') fail(path, 'not JSON data');
|
|
19
|
+
if (typeof value === 'number' && !Number.isFinite(value)) fail(path, 'nonfinite number');
|
|
20
|
+
if (value && typeof value === 'object') {
|
|
21
|
+
if (seen.has(value)) fail(path, 'cycle');
|
|
22
|
+
seen.add(value);
|
|
23
|
+
if (!Array.isArray(value) && !plainObject(value)) fail(path, 'not a plain JSON object');
|
|
24
|
+
for (const [key, child] of Object.entries(value)) jsonSafe(child, `${path}.${key}`, seen);
|
|
25
|
+
seen.delete(value);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checkRule(rule, value, path) {
|
|
30
|
+
if (rule.$ref) return checkRule(schema.$defs[rule.$ref.split('/').at(-1)], value, path);
|
|
31
|
+
if ('const' in rule && value !== rule.const) fail(path, `expected ${JSON.stringify(rule.const)}`);
|
|
32
|
+
if (rule.enum && !rule.enum.includes(value)) fail(path, 'unrecognized value');
|
|
33
|
+
if (rule.type) {
|
|
34
|
+
const types = Array.isArray(rule.type) ? rule.type : [rule.type];
|
|
35
|
+
const valid = types.some(type => type === 'null' ? value === null : type === 'array' ? Array.isArray(value) : type === 'object' ? plainObject(value) : typeof value === type);
|
|
36
|
+
if (!valid) fail(path, `expected ${types.join(' or ')}`);
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === 'number') {
|
|
39
|
+
if (rule.minimum !== undefined && value < rule.minimum) fail(path, 'below minimum');
|
|
40
|
+
if (rule.maximum !== undefined && value > rule.maximum) fail(path, 'above maximum');
|
|
41
|
+
}
|
|
42
|
+
if (plainObject(value)) {
|
|
43
|
+
for (const key of rule.required ?? []) if (!Object.hasOwn(value, key)) fail(`${path}.${key}`, 'required');
|
|
44
|
+
if (rule.additionalProperties === false) for (const key of Object.keys(value))
|
|
45
|
+
if (!Object.hasOwn(rule.properties ?? {}, key)) fail(`${path}.${key}`, 'unexpected field');
|
|
46
|
+
for (const [key, child] of Object.entries(rule.properties ?? {})) if (Object.hasOwn(value, key)) checkRule(child, value[key], `${path}.${key}`);
|
|
47
|
+
}
|
|
48
|
+
if (Array.isArray(value) && rule.items) value.forEach((item, i) => checkRule(rule.items, item, `${path}[${i}]`));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A decision is traceable evidence for a condition, never evidence of a measured score. */
|
|
52
|
+
export function validateApplicabilityDecision(decision, allowedConditions, path = 'applicability_decision') {
|
|
53
|
+
if (!decision) fail(path, 'structured condition decision required');
|
|
54
|
+
checkRule(schema.$defs.ApplicabilityDecision, decision, path);
|
|
55
|
+
if (!allowedConditions.includes(decision.condition)) fail(path, `condition not allowed: ${decision.condition}`);
|
|
56
|
+
if (!decision.reason.trim() || !decision.evidence_ref.trim()) fail(path, 'reason and evidence_ref required');
|
|
57
|
+
return decision;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Official p75 thresholds; optbrew assigns the middle interval q=0.5. */
|
|
61
|
+
export function cwvVerdict(familyId, value, { percentile = 75, source_type = 'field', unit } = {}) {
|
|
62
|
+
const limits = cwv[familyId];
|
|
63
|
+
if (!limits) throw new ContractError(`unknown CWV family: ${familyId}`);
|
|
64
|
+
if (!Number.isFinite(value) || value < 0 || percentile !== 75 || source_type !== 'field' || unit !== limits[2])
|
|
65
|
+
throw new ContractError(`invalid p75 field measurement for ${familyId}`);
|
|
66
|
+
if (value <= limits[0] + tolerance) return { state: 'pass', q: 1, partial_basis: null };
|
|
67
|
+
if (value > limits[1] + tolerance) return { state: 'fail', q: 0, partial_basis: null };
|
|
68
|
+
return { state: 'partial', q: 0.5, partial_basis: { kind: 'threshold_half' } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function checkObservation(value) {
|
|
72
|
+
const { state, q, partial_basis: basis } = value;
|
|
73
|
+
const definition = families.get(value.family_id);
|
|
74
|
+
if (!definition) fail('CheckObservation.family_id', 'unregistered family');
|
|
75
|
+
if (state === 'unknown' || state === 'not_applicable') {
|
|
76
|
+
if (q !== null || basis !== null) fail('CheckObservation', `${state} requires null q and basis`);
|
|
77
|
+
if (state === 'not_applicable') {
|
|
78
|
+
if (definition.core_policy) fail('CheckObservation', 'core policy unit cannot be not_applicable');
|
|
79
|
+
validateApplicabilityDecision(value.applicability_decision, definition.na_conditions, 'CheckObservation.applicability_decision');
|
|
80
|
+
}
|
|
81
|
+
} else if (state === 'pass' || state === 'fail') {
|
|
82
|
+
if (typeof q !== 'number' || Math.abs(q - (state === 'pass' ? 1 : 0)) > tolerance || basis !== null) fail('CheckObservation', `${state} requires fixed q and null basis`);
|
|
83
|
+
} else if (state === 'partial') {
|
|
84
|
+
if (!plainObject(basis) || !(q > 0 && q < 1)) fail('CheckObservation', 'partial requires a supported basis and interior q');
|
|
85
|
+
if (definition.verdict_mode === 'binary') fail('CheckObservation', `${value.family_id} is binary`);
|
|
86
|
+
if (basis.kind === 'threshold_half') {
|
|
87
|
+
if (definition.verdict_mode !== 'threshold_half') fail('CheckObservation', `${value.family_id} does not allow threshold_half`);
|
|
88
|
+
if (Math.abs(q - 0.5) > tolerance) fail('CheckObservation', 'threshold_half requires q=0.5');
|
|
89
|
+
} else if (basis.kind === 'ratio') {
|
|
90
|
+
if (definition.verdict_mode !== 'ratio') fail('CheckObservation', `${value.family_id} does not allow ratio`);
|
|
91
|
+
if (!Number.isInteger(basis.numerator) || !Number.isInteger(basis.denominator) || basis.denominator <= 0 ||
|
|
92
|
+
basis.numerator <= 0 || basis.numerator >= basis.denominator || Math.abs(q - basis.numerator / basis.denominator) > tolerance)
|
|
93
|
+
fail('CheckObservation', 'ratio requires matching integer numerator and positive denominator');
|
|
94
|
+
} else fail('CheckObservation', 'unsupported partial basis');
|
|
95
|
+
}
|
|
96
|
+
if (state !== 'not_applicable' && value.applicability_decision) fail('CheckObservation', 'decision only allowed for not_applicable');
|
|
97
|
+
if (value.measurement && cwv[value.family_id]) {
|
|
98
|
+
const expected = cwvVerdict(value.family_id, value.measurement.value, value.measurement);
|
|
99
|
+
if (expected.state !== state || Math.abs(expected.q - q) > tolerance) fail('CheckObservation', 'CWV state/q differs from field p75');
|
|
100
|
+
} else if (cwv[value.family_id] && !['unknown', 'not_applicable'].includes(state)) {
|
|
101
|
+
fail('CheckObservation', 'CWV measurement requires field p75 source and unit');
|
|
102
|
+
}
|
|
103
|
+
if (value.blocker === true && state !== 'fail') fail('CheckObservation', 'blocker requires fail');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function checkMetric(value) {
|
|
107
|
+
const path = 'MetricObservation';
|
|
108
|
+
if (!value.metric_id.trim() || !value.definition.trim() || !value.provider_definition.trim() ||
|
|
109
|
+
!value.source_ref.trim() || !value.evidence_ref.trim()) fail(path, 'nonempty identity, definition and evidence required');
|
|
110
|
+
if (value.value_state === 'measured' ? !Number.isFinite(value.value) : value.value !== null)
|
|
111
|
+
fail(path, 'value_state and value disagree');
|
|
112
|
+
if (!value.sample_selection || !['all', 'positive_only', 'unknown'].includes(value.sample_selection.kind))
|
|
113
|
+
fail(path, 'sample_selection.kind required');
|
|
114
|
+
if (value.value_state === 'not_applicable' &&
|
|
115
|
+
(typeof value.sample_selection.exclusion_reason !== 'string' || !value.sample_selection.exclusion_reason.trim()))
|
|
116
|
+
fail(path, 'not_applicable requires an exclusion reason');
|
|
117
|
+
const { start, end, timezone } = value.window;
|
|
118
|
+
for (const [name, date] of [['start', start], ['end', end]]) {
|
|
119
|
+
const timestamp = Date.parse(`${date}T00:00:00Z`);
|
|
120
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(timestamp) ||
|
|
121
|
+
new Date(timestamp).toISOString().slice(0, 10) !== date)
|
|
122
|
+
fail(`${path}.window.${name}`, 'invalid local date');
|
|
123
|
+
}
|
|
124
|
+
if (start > end || !timezone) fail(`${path}.window`, 'ordered dates and timezone required');
|
|
125
|
+
try { new Intl.DateTimeFormat('en', { timeZone: timezone }); }
|
|
126
|
+
catch { fail(`${path}.window.timezone`, 'invalid timezone'); }
|
|
127
|
+
if ((value.numerator === null) !== (value.denominator === null) ||
|
|
128
|
+
(value.numerator !== null && (!Number.isFinite(value.numerator) || !Number.isFinite(value.denominator) || value.denominator <= 0)))
|
|
129
|
+
fail(path, 'numerator and positive denominator must occur together');
|
|
130
|
+
if (value.sample_selection.kind === 'positive_only' &&
|
|
131
|
+
/(?:^|:)citation_response_rate$/.test(value.metric_id)) fail(path, 'positive-only sample cannot estimate all-question citation rate');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Validate a persisted entity and return its original value. */
|
|
135
|
+
export function validateEntity(typeName, value) {
|
|
136
|
+
const rule = schema.$defs[typeName];
|
|
137
|
+
if (!rule) throw new ContractError(`unknown entity type: ${typeName}`);
|
|
138
|
+
jsonSafe(value);
|
|
139
|
+
checkRule(rule, value, typeName);
|
|
140
|
+
if (typeName === 'CheckObservation') checkObservation(value);
|
|
141
|
+
if (typeName === 'ScoreInput') value.observations.forEach(checkObservation);
|
|
142
|
+
if (typeName === 'MetricObservation') checkMetric(value);
|
|
143
|
+
return value;
|
|
144
|
+
}
|