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,52 @@
|
|
|
1
|
+
import { BlockList, isIP } from 'node:net';
|
|
2
|
+
|
|
3
|
+
const blocked4 = new BlockList();
|
|
4
|
+
const blocked6 = new BlockList();
|
|
5
|
+
for (const [base, prefix] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
|
6
|
+
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
|
7
|
+
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
|
8
|
+
['224.0.0.0', 4], ['240.0.0.0', 4]]) blocked4.addSubnet(base, prefix, 'ipv4');
|
|
9
|
+
for (const [base, prefix] of [['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['fc00::', 7], ['fe80::', 10],
|
|
10
|
+
['ff00::', 8], ['2001:db8::', 32]]) blocked6.addSubnet(base, prefix, 'ipv6');
|
|
11
|
+
|
|
12
|
+
function addressText(value) { return typeof value === 'string' ? value : value?.address; }
|
|
13
|
+
function privateAddress(value) {
|
|
14
|
+
const address = addressText(value);
|
|
15
|
+
const kind = isIP(address);
|
|
16
|
+
if (!kind) throw new Error('unresolved or invalid IP address');
|
|
17
|
+
if (kind === 4) return blocked4.check(address, 'ipv4');
|
|
18
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
|
|
19
|
+
return mapped ? blocked4.check(mapped[1], 'ipv4') : blocked6.check(address, 'ipv6');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Check the URL boundary before DNS; every request additionally checks resolved IPs. */
|
|
23
|
+
export function assertUrlInScope(input, scope) {
|
|
24
|
+
let url;
|
|
25
|
+
try { url = new URL(input); } catch { throw new Error('invalid URL'); }
|
|
26
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('URL scheme or userinfo outside scope');
|
|
27
|
+
if (/%(?:2f|5c|25)/i.test(url.pathname)) throw new Error('encoded path delimiter outside scope');
|
|
28
|
+
if (!Array.isArray(scope?.allowed) || !scope.allowed.length) throw new Error('scope.allowed required');
|
|
29
|
+
const path = decodeURIComponent(url.pathname);
|
|
30
|
+
const matching = scope.allowed.some(rule => {
|
|
31
|
+
let origin;
|
|
32
|
+
try { origin = new URL(rule.origin); } catch { return false; }
|
|
33
|
+
if (origin.origin !== url.origin || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) return false;
|
|
34
|
+
const prefix = rule.path_prefix ?? '/';
|
|
35
|
+
if (typeof prefix !== 'string' || !prefix.startsWith('/')) return false;
|
|
36
|
+
return prefix === '/' || path === prefix || path.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`);
|
|
37
|
+
});
|
|
38
|
+
if (!matching) throw new Error('URL outside allowed origin or path');
|
|
39
|
+
url.hash = '';
|
|
40
|
+
return url;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Check origin, path segment, and every resolved address before a request. */
|
|
44
|
+
export function assertInScope(input, scope, resolvedAddresses) {
|
|
45
|
+
const url = assertUrlInScope(input, scope);
|
|
46
|
+
if (!Array.isArray(resolvedAddresses) || !resolvedAddresses.length) throw new Error('resolved addresses required');
|
|
47
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
48
|
+
const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
|
|
49
|
+
const local = (scope.local_targets ?? []).some(x => x.host === host && Number(x.port) === port);
|
|
50
|
+
for (const address of resolvedAddresses) if (privateAddress(address) && !local) throw new Error('private or reserved address outside local target');
|
|
51
|
+
return url;
|
|
52
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { validateEntity, cwvVerdict, ContractError } from './contracts.mjs';
|
|
4
|
+
import { compileUnits, scoreWeighted } from './weights.mjs';
|
|
5
|
+
|
|
6
|
+
const registry = JSON.parse(readFileSync(new URL('../../references/check-registry.json', import.meta.url)));
|
|
7
|
+
const profiles = JSON.parse(readFileSync(new URL('../../references/profiles.json', import.meta.url)));
|
|
8
|
+
const bundleRoot = new URL('../../', import.meta.url);
|
|
9
|
+
|
|
10
|
+
function assertEvaluationIdentity(context) {
|
|
11
|
+
const manifest = JSON.parse(readFileSync(new URL('bundle-manifest.json', bundleRoot), 'utf8'));
|
|
12
|
+
if (!manifest.evaluation_hash || !manifest.protocol_hash ||
|
|
13
|
+
context.evaluation_version !== manifest.evaluation_version ||
|
|
14
|
+
context.protocol_version !== manifest.protocol_version)
|
|
15
|
+
throw new ContractError('evaluation version or protocol identity differs from installed bundle');
|
|
16
|
+
for (const [field, hashField] of [['evaluation_sources', 'evaluation_hash'], ['protocol_sources', 'protocol_hash']]) {
|
|
17
|
+
const sources = manifest[field];
|
|
18
|
+
if (!Array.isArray(sources) || !sources.length ||
|
|
19
|
+
createHash('sha256').update(JSON.stringify(sources)).digest('hex') !== manifest[hashField] ||
|
|
20
|
+
sources.some(source => createHash('sha256').update(readFileSync(new URL(source.path, bundleRoot))).digest('hex') !== source.sha256))
|
|
21
|
+
throw new ContractError(`${field} identity differs from installed bundle`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { cwvVerdict };
|
|
26
|
+
|
|
27
|
+
function band(score) {
|
|
28
|
+
if (score === null) return null;
|
|
29
|
+
if (score < 60) return 'focus_improvement';
|
|
30
|
+
if (score < 80) return 'major_improvement';
|
|
31
|
+
if (score < 95) return 'additional_improvement';
|
|
32
|
+
return 'remaining_review';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Readiness scores are local audit status, never predicted search or AI outcomes. */
|
|
36
|
+
export function score(input) {
|
|
37
|
+
validateEntity('ScoreInput', input);
|
|
38
|
+
assertEvaluationIdentity(input.run_context);
|
|
39
|
+
const units = compileUnits(input, 'SEO');
|
|
40
|
+
const coreIds = new Set(registry.families.filter(x => x.core_policy).map(x => x.family_id));
|
|
41
|
+
const blockingIds = new Set(registry.families.filter(x => x.blocker_on_fail).map(x => x.family_id));
|
|
42
|
+
const canonicalObservations = [...input.observations].sort((a, b) => {
|
|
43
|
+
const left = [a.unit_key, a.observation_id, a.source_ref, a.evidence_ref, a.reason].join('\0');
|
|
44
|
+
const right = [b.unit_key, b.observation_id, b.source_ref, b.evidence_ref, b.reason].join('\0');
|
|
45
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
46
|
+
});
|
|
47
|
+
const observations = new Map();
|
|
48
|
+
for (const obs of canonicalObservations) if (!observations.has(obs.unit_key)) observations.set(obs.unit_key, obs);
|
|
49
|
+
const blockers = [], unknown_core = [];
|
|
50
|
+
for (const unit of units) {
|
|
51
|
+
const obs = observations.get(unit.key);
|
|
52
|
+
if ((blockingIds.has(unit.family_id) || obs?.blocker) && unit.state === 'fail')
|
|
53
|
+
blockers.push({ family_id: unit.family_id, unit_key: unit.key, reason: obs?.reason ?? 'confirmed failure', evidence_ref: obs?.evidence_ref ?? null });
|
|
54
|
+
if (coreIds.has(unit.family_id) && unit.state === 'unknown')
|
|
55
|
+
unknown_core.push({ family_id: unit.family_id, unit_key: unit.key });
|
|
56
|
+
}
|
|
57
|
+
for (const obs of canonicalObservations) if (obs.blocker && obs.state === 'fail' &&
|
|
58
|
+
!blockers.some(x => x.unit_key === obs.unit_key))
|
|
59
|
+
blockers.push({ family_id: obs.family_id, unit_key: obs.unit_key, reason: obs.reason, evidence_ref: obs.evidence_ref });
|
|
60
|
+
blockers.sort((a, b) => a.unit_key < b.unit_key ? -1 : a.unit_key > b.unit_key ? 1 : 0);
|
|
61
|
+
const selectedProfiles = Object.keys(profiles.profiles).filter(name => name !== 'NEO' || input.run_context.protocol.engines.includes('naver'));
|
|
62
|
+
return selectedProfiles.map(profile => {
|
|
63
|
+
const weighted = scoreWeighted(compileUnits(input, profile));
|
|
64
|
+
const readiness_status = weighted.A === 0 ? 'not_applicable' : blockers.length ? 'blocked' :
|
|
65
|
+
unknown_core.length ? 'unverified' : 100 * weighted.M / weighted.A < 80 - 1e-9 ? 'provisional' : 'measured';
|
|
66
|
+
const result = { schema_version: '1.0.0', evaluation_version: input.run_context.evaluation_version,
|
|
67
|
+
profile, ...weighted, band: band(weighted.M === 0 ? null : 100 * weighted.N / weighted.M), readiness_status,
|
|
68
|
+
blockers, unknown_core, source_ref: 'optbrew:scoring:0.1.0' };
|
|
69
|
+
return validateEntity('ScoreResult', result);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { ContractError, validateEntity } from './contracts.mjs';
|
|
3
|
+
import { freezeProtocol } from './protocol.mjs';
|
|
4
|
+
|
|
5
|
+
const registry = JSON.parse(readFileSync(new URL('../../references/check-registry.json', import.meta.url)));
|
|
6
|
+
const familyById = new Map(registry.families.map(family => [family.family_id, family]));
|
|
7
|
+
const profiles = JSON.parse(readFileSync(new URL('../../references/profiles.json', import.meta.url)));
|
|
8
|
+
const tolerance = 1e-9;
|
|
9
|
+
|
|
10
|
+
function candidateUnits(definition, plan, protocol) {
|
|
11
|
+
const publicOnly = ['index-policy', 'search-crawl-policy', 'discovery'].includes(definition.family_id);
|
|
12
|
+
const pageTypes = protocol.page_types.map(type => ({ ...type,
|
|
13
|
+
urls: type.urls.filter(url => (!publicOnly || url.public_indexing) &&
|
|
14
|
+
(!plan.urls || plan.urls.includes(url.normalized))) })).filter(type => type.urls.length);
|
|
15
|
+
const pages = pageTypes.flatMap(type => type.urls.map(url => ({ url: url.normalized,
|
|
16
|
+
share: 1 / pageTypes.length / type.urls.length })));
|
|
17
|
+
const scope = definition.unit_scope;
|
|
18
|
+
if (scope === 'site') return [{ key: `${definition.family_id}|site`, engine: null, url: null, share: 1 }];
|
|
19
|
+
if (scope === 'origin') {
|
|
20
|
+
const origins = [...new Set(pages.map(page => new URL(page.url).origin))];
|
|
21
|
+
return origins.map(origin => ({ key: `${definition.family_id}|${origin}`, engine: null, url: origin, share: 1 / origins.length }));
|
|
22
|
+
}
|
|
23
|
+
if (scope === 'url') return pages.map(page => ({ key: `${definition.family_id}|${page.url}`,
|
|
24
|
+
engine: null, url: page.url, share: page.share }));
|
|
25
|
+
if (scope === 'engine_url') {
|
|
26
|
+
const engines = (plan.engines ?? protocol.engines).filter(engine => !definition.engines || definition.engines.includes(engine));
|
|
27
|
+
return engines.flatMap(engine => pages.map(page => ({ key: `${definition.family_id}|${engine}|${page.url}`,
|
|
28
|
+
engine, url: page.url, share: page.share / engines.length })));
|
|
29
|
+
}
|
|
30
|
+
throw new ContractError(`unsupported unit scope: ${scope}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function observationsByUnit(input) {
|
|
34
|
+
const result = new Map();
|
|
35
|
+
for (const obs of input.observations) {
|
|
36
|
+
if (obs.check_id !== obs.family_id) throw new ContractError(`unregistered check_id: ${obs.check_id}`);
|
|
37
|
+
const previous = result.get(obs.unit_key);
|
|
38
|
+
if (previous && (previous.family_id !== obs.family_id || previous.state !== obs.state ||
|
|
39
|
+
previous.q !== obs.q || JSON.stringify(previous.partial_basis) !== JSON.stringify(obs.partial_basis) ||
|
|
40
|
+
previous.applicability_decision?.condition !== obs.applicability_decision?.condition ||
|
|
41
|
+
Boolean(previous.blocker) !== Boolean(obs.blocker)))
|
|
42
|
+
throw new ContractError(`conflicting observation for ${obs.unit_key}`);
|
|
43
|
+
result.set(obs.unit_key, previous ?? obs);
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Compile fixed protocol units in axis → family → engine → page type/URL order. */
|
|
49
|
+
export function compileUnits(input, profile) {
|
|
50
|
+
validateEntity('ScoreInput', input);
|
|
51
|
+
const profileWeights = profiles.profiles[profile];
|
|
52
|
+
if (!profileWeights) throw new ContractError(`unknown profile: ${profile}`);
|
|
53
|
+
const { protocol } = freezeProtocol(input.run_context);
|
|
54
|
+
const plans = new Map(protocol.families.map(plan => [plan.family_id, plan]));
|
|
55
|
+
if (plans.size !== registry.families.length || registry.families.some(def => !plans.has(def.family_id)))
|
|
56
|
+
throw new ContractError('protocol must state applicability for all 22 families');
|
|
57
|
+
const observed = observationsByUnit(input);
|
|
58
|
+
for (const obs of input.observations) if (obs.state === 'not_applicable') {
|
|
59
|
+
const definition = familyById.get(obs.family_id);
|
|
60
|
+
if (definition.unit_scope === 'site' || obs.family_id === 'discovery' ||
|
|
61
|
+
(obs.family_id === 'naver-markup' && obs.applicability_decision.condition === 'naver_not_target'))
|
|
62
|
+
throw new ContractError(`${obs.family_id}: unit N/A contradicts fixed protocol conditions`);
|
|
63
|
+
}
|
|
64
|
+
const questionCoverage = observed.get('question-coverage|site');
|
|
65
|
+
if (questionCoverage?.partial_basis?.kind === 'ratio' &&
|
|
66
|
+
questionCoverage.partial_basis.denominator !== protocol.question_panel.length)
|
|
67
|
+
throw new ContractError('question-coverage denominator differs from fixed panel');
|
|
68
|
+
const expected = new Set();
|
|
69
|
+
const families = [];
|
|
70
|
+
for (const definition of registry.families) {
|
|
71
|
+
const plan = plans.get(definition.family_id);
|
|
72
|
+
if (!plan.applicable) continue;
|
|
73
|
+
const candidates = candidateUnits(definition, plan, protocol);
|
|
74
|
+
if (!candidates.length) throw new ContractError(`applicable family has no fixed units: ${definition.family_id}`);
|
|
75
|
+
for (const candidate of candidates) expected.add(candidate.key);
|
|
76
|
+
const active = candidates.map(candidate => ({ ...candidate, observation: observed.get(candidate.key) })).filter(x => x.observation?.state !== 'not_applicable');
|
|
77
|
+
if (active.length) families.push({ definition, active, total: active.reduce((sum, x) => sum + x.share, 0) });
|
|
78
|
+
}
|
|
79
|
+
for (const obs of input.observations) {
|
|
80
|
+
if (!expected.has(obs.unit_key) || !obs.unit_key.startsWith(`${obs.family_id}|`))
|
|
81
|
+
throw new ContractError(`unregistered observation unit: ${obs.unit_key}`);
|
|
82
|
+
}
|
|
83
|
+
const axes = profiles.axes.filter(axis => profileWeights[axis] > 0 && families.some(f => f.definition.axis === axis));
|
|
84
|
+
const availableAxisWeight = axes.reduce((sum, axis) => sum + profileWeights[axis], 0);
|
|
85
|
+
const units = [];
|
|
86
|
+
for (const axis of axes) {
|
|
87
|
+
const axisFamilies = families.filter(f => f.definition.axis === axis);
|
|
88
|
+
const axisWeight = 100 * profileWeights[axis] / availableAxisWeight;
|
|
89
|
+
for (const family of axisFamilies) {
|
|
90
|
+
for (const candidate of family.active) {
|
|
91
|
+
const obs = candidate.observation;
|
|
92
|
+
units.push({ key: candidate.key, family_id: family.definition.family_id,
|
|
93
|
+
engine: candidate.engine, url: candidate.url,
|
|
94
|
+
weight: axisWeight / axisFamilies.length * candidate.share / family.total,
|
|
95
|
+
state: obs?.state ?? 'unknown', q: obs?.q ?? null });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return units;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const display = value => Math.round((value + Number.EPSILON) * 10) / 10;
|
|
103
|
+
const tidy = value => Math.abs(value) < tolerance ? 0 : Math.abs(value - 100) < tolerance ? 100 : value;
|
|
104
|
+
|
|
105
|
+
/** A/M/N remain raw weighted totals; only displayed percentages are rounded. */
|
|
106
|
+
export function scoreWeighted(units) {
|
|
107
|
+
if (!Array.isArray(units)) throw new ContractError('units must be an array');
|
|
108
|
+
const keys = new Set();
|
|
109
|
+
let A = 0, M = 0, N = 0;
|
|
110
|
+
for (const unit of units) {
|
|
111
|
+
if (!unit || typeof unit.key !== 'string' || !unit.key || keys.has(unit.key) ||
|
|
112
|
+
!Number.isFinite(unit.weight) || unit.weight < 0) throw new ContractError('invalid or duplicate weighted unit');
|
|
113
|
+
keys.add(unit.key);
|
|
114
|
+
const { state, q, weight } = unit;
|
|
115
|
+
const definition = familyById.get(unit.family_id);
|
|
116
|
+
if (state === 'not_applicable') {
|
|
117
|
+
if (q !== null) throw new ContractError('not_applicable q must be null');
|
|
118
|
+
if (definition?.core_policy) throw new ContractError(`${unit.family_id}: core policy unit cannot be not_applicable`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
A += weight;
|
|
122
|
+
if (state === 'unknown') {
|
|
123
|
+
if (q !== null) throw new ContractError('unknown q must be null');
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!['pass', 'partial', 'fail'].includes(state) || !Number.isFinite(q) || q < 0 || q > 1 ||
|
|
127
|
+
(state === 'pass' && Math.abs(q - 1) > tolerance) ||
|
|
128
|
+
(state === 'fail' && Math.abs(q) > tolerance) ||
|
|
129
|
+
(state === 'partial' && (!(q > 0 && q < 1) || definition?.verdict_mode === 'binary' ||
|
|
130
|
+
(definition?.verdict_mode === 'threshold_half' && Math.abs(q - 0.5) > tolerance))))
|
|
131
|
+
throw new ContractError(`invalid weighted verdict: ${unit.key}`);
|
|
132
|
+
M += weight;
|
|
133
|
+
N += weight * q;
|
|
134
|
+
}
|
|
135
|
+
A = tidy(A); M = tidy(M); N = tidy(N);
|
|
136
|
+
return { A, M, N, score: M === 0 ? null : display(100 * N / M),
|
|
137
|
+
coverage: A === 0 ? null : display(100 * M / A),
|
|
138
|
+
missing_bounds: A === 0 ? null : [display(100 * N / A), display(100 * (N + A - M) / A)] };
|
|
139
|
+
}
|