optbrew 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/.claude-plugin/marketplace.json +6 -0
  2. package/.claude-plugin/plugin.json +6 -0
  3. package/.codex-plugin/plugin.json +13 -0
  4. package/CHANGELOG.md +20 -0
  5. package/LICENSE +21 -0
  6. package/README.ko.md +73 -0
  7. package/README.md +72 -2
  8. package/THIRD_PARTY_NOTICES.md +10 -0
  9. package/bin/optbrew.mjs +4 -0
  10. package/docs/platforms.md +31 -0
  11. package/docs/release.md +14 -0
  12. package/lib/cli.mjs +103 -0
  13. package/lib/doctor.mjs +44 -0
  14. package/lib/install.mjs +158 -0
  15. package/lib/paths.mjs +30 -0
  16. package/lib/records.mjs +44 -0
  17. package/lib/transaction.mjs +205 -0
  18. package/lib/update.mjs +181 -0
  19. package/package.json +39 -4
  20. package/plugin.json +13 -0
  21. package/skills/optbrew/LICENSE +21 -0
  22. package/skills/optbrew/SKILL.md +17 -0
  23. package/skills/optbrew/THIRD_PARTY_NOTICES.md +10 -0
  24. package/skills/optbrew/agents/openai.yaml +4 -0
  25. package/skills/optbrew/bundle-manifest.json +663 -0
  26. package/skills/optbrew/licenses/firecrawl-seo-audit.txt +15 -0
  27. package/skills/optbrew/licenses/magnus-seo.txt +21 -0
  28. package/skills/optbrew/licenses/marketing-search.txt +21 -0
  29. package/skills/optbrew/licenses/mblode-seo.txt +21 -0
  30. package/skills/optbrew/licenses/naver-searchadvisor.txt +29 -0
  31. package/skills/optbrew/licenses/seranking-seo.txt +21 -0
  32. package/skills/optbrew/modules/firecrawl-seo-audit/GUIDE.md +64 -0
  33. package/skills/optbrew/modules/magnus-seo/GUIDE.md +79 -0
  34. package/skills/optbrew/modules/magnus-seo/README.md +45 -0
  35. package/skills/optbrew/modules/magnus-seo/assets/audit-report-template.md +76 -0
  36. package/skills/optbrew/modules/magnus-seo/references/aeo-methodology.md +105 -0
  37. package/skills/optbrew/modules/magnus-seo/references/agent-readable-content.md +28 -0
  38. package/skills/optbrew/modules/magnus-seo/references/content-and-entity-architecture.md +100 -0
  39. package/skills/optbrew/modules/magnus-seo/references/content-strategy-seo.md +142 -0
  40. package/skills/optbrew/modules/magnus-seo/references/discovery-and-freshness.md +47 -0
  41. package/skills/optbrew/modules/magnus-seo/references/evidence-boundaries.md +75 -0
  42. package/skills/optbrew/modules/magnus-seo/references/ghost-metadata.md +204 -0
  43. package/skills/optbrew/modules/magnus-seo/references/implementation-playbook.md +114 -0
  44. package/skills/optbrew/modules/magnus-seo/references/measurement-and-experimentation.md +47 -0
  45. package/skills/optbrew/modules/magnus-seo/references/onpage-seo.md +154 -0
  46. package/skills/optbrew/modules/magnus-seo/references/platform-guidance.md +30 -0
  47. package/skills/optbrew/modules/magnus-seo/references/schema-markup.md +244 -0
  48. package/skills/optbrew/modules/magnus-seo/references/source-index.md +36 -0
  49. package/skills/optbrew/modules/magnus-seo/references/structured-data.md +56 -0
  50. package/skills/optbrew/modules/magnus-seo/references/technical-seo.md +99 -0
  51. package/skills/optbrew/modules/magnus-seo/scripts/aeo_audit.py +158 -0
  52. package/skills/optbrew/modules/magnus-seo/scripts/build_prompt_matrix.py +52 -0
  53. package/skills/optbrew/modules/magnus-seo/templates/aeo-implementation-plan.md +53 -0
  54. package/skills/optbrew/modules/magnus-seo/templates/citation-observation-log.md +16 -0
  55. package/skills/optbrew/modules/magnus-seo/templates/llms.txt.template +15 -0
  56. package/skills/optbrew/modules/magnus-seo/templates/question-cluster.md +27 -0
  57. package/skills/optbrew/modules/magnus-seo/templates/robots-ai-crawlers.txt +23 -0
  58. package/skills/optbrew/modules/marketing-search/skills/ai-seo/GUIDE.md +483 -0
  59. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/agent-readiness.md +57 -0
  60. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/citations-vs-recommendations.md +85 -0
  61. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/content-patterns.md +287 -0
  62. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/content-types.md +71 -0
  63. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/format-volatility.md +98 -0
  64. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/okf.md +104 -0
  65. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/platform-ranking-factors.md +154 -0
  66. package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/youtube-ai-citations.md +60 -0
  67. package/skills/optbrew/modules/marketing-search/skills/analytics/GUIDE.md +303 -0
  68. package/skills/optbrew/modules/marketing-search/skills/analytics/references/event-library.md +260 -0
  69. package/skills/optbrew/modules/marketing-search/skills/analytics/references/ga4-implementation.md +300 -0
  70. package/skills/optbrew/modules/marketing-search/skills/analytics/references/gtm-implementation.md +390 -0
  71. package/skills/optbrew/modules/marketing-search/skills/content-strategy/GUIDE.md +432 -0
  72. package/skills/optbrew/modules/marketing-search/skills/content-strategy/references/content-distribution.md +83 -0
  73. package/skills/optbrew/modules/marketing-search/skills/content-strategy/references/headless-cms.md +194 -0
  74. package/skills/optbrew/modules/marketing-search/skills/programmatic-seo/GUIDE.md +231 -0
  75. package/skills/optbrew/modules/marketing-search/skills/programmatic-seo/references/playbooks.md +308 -0
  76. package/skills/optbrew/modules/marketing-search/skills/schema/GUIDE.md +172 -0
  77. package/skills/optbrew/modules/marketing-search/skills/schema/references/schema-examples.md +398 -0
  78. package/skills/optbrew/modules/marketing-search/skills/seo-audit/GUIDE.md +492 -0
  79. package/skills/optbrew/modules/marketing-search/skills/seo-audit/references/ai-writing-detection.md +200 -0
  80. package/skills/optbrew/modules/marketing-search/skills/seo-audit/references/international-seo.md +230 -0
  81. package/skills/optbrew/modules/marketing-search/skills/site-architecture/GUIDE.md +350 -0
  82. package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/mermaid-templates.md +216 -0
  83. package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/navigation-patterns.md +305 -0
  84. package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/site-type-templates.md +293 -0
  85. package/skills/optbrew/modules/mblode-seo/GUIDE.md +74 -0
  86. package/skills/optbrew/modules/mblode-seo/references/answer-engines.md +58 -0
  87. package/skills/optbrew/modules/mblode-seo/references/audit.md +78 -0
  88. package/skills/optbrew/modules/mblode-seo/references/brief-template.md +47 -0
  89. package/skills/optbrew/modules/mblode-seo/references/monitoring.md +56 -0
  90. package/skills/optbrew/modules/mblode-seo/references/nextjs-implementation.md +65 -0
  91. package/skills/optbrew/modules/mblode-seo/references/research-protocol.md +37 -0
  92. package/skills/optbrew/modules/mblode-seo/references/sources.md +47 -0
  93. package/skills/optbrew/modules/mblode-seo/references/validation-evidence.md +21 -0
  94. package/skills/optbrew/modules/naver-searchadvisor/GUIDE.md +75 -0
  95. package/skills/optbrew/modules/naver-searchadvisor/NOTICE.md +59 -0
  96. package/skills/optbrew/modules/naver-searchadvisor/references/INDEX.md +97 -0
  97. package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +714 -0
  98. package/skills/optbrew/modules/naver-searchadvisor/scripts/robots.mjs +77 -0
  99. package/skills/optbrew/modules/seranking-seo/skills/seo-ai-search-share-of-voice/GUIDE.md +94 -0
  100. package/skills/optbrew/modules/seranking-seo/skills/seo-api/GUIDE.md +186 -0
  101. package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/api-surface-map.md +207 -0
  102. package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/auth-and-keys.md +182 -0
  103. package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/integration-patterns.md +283 -0
  104. package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/rate-limits-and-credits.md +235 -0
  105. package/skills/optbrew/modules/seranking-seo/skills/seo-competitor-gap-analysis/GUIDE.md +117 -0
  106. package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/GUIDE.md +155 -0
  107. package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/references/cite.md +84 -0
  108. package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/references/core-eeat.md +147 -0
  109. package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/templates/verdict.md +63 -0
  110. package/skills/optbrew/modules/seranking-seo/skills/seo-firecrawl/GUIDE.md +141 -0
  111. package/skills/optbrew/modules/seranking-seo/skills/seo-firecrawl/references/preflight.md +73 -0
  112. package/skills/optbrew/modules/seranking-seo/skills/seo-geo/GUIDE.md +159 -0
  113. package/skills/optbrew/modules/seranking-seo/skills/seo-google/GUIDE.md +393 -0
  114. package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/cwv-audit-report.md +48 -0
  115. package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/gsc-performance-report.md +44 -0
  116. package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/indexation-status-report.md +43 -0
  117. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/auth-setup.md +160 -0
  118. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/cross-skill-integration.md +187 -0
  119. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/ga4-data-api.md +184 -0
  120. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/indexing-api.md +109 -0
  121. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/keyword-planner-api.md +66 -0
  122. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/nlp-api.md +55 -0
  123. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/pagespeed-crux-api.md +204 -0
  124. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/rate-limits-quotas.md +75 -0
  125. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/search-console-api.md +156 -0
  126. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/supplementary-apis.md +99 -0
  127. package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/youtube-api.md +49 -0
  128. package/skills/optbrew/modules/seranking-seo/skills/seo-keyword-niche/GUIDE.md +174 -0
  129. package/skills/optbrew/modules/seranking-seo/skills/seo-page/GUIDE.md +174 -0
  130. package/skills/optbrew/modules/seranking-seo/skills/seo-plan/GUIDE.md +220 -0
  131. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/GUIDE.md +135 -0
  132. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/references/google-rich-results.md +89 -0
  133. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/article.json +23 -0
  134. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/breadcrumb-list.json +18 -0
  135. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/faq-page.json +22 -0
  136. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/local-business.json +32 -0
  137. package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/product.json +25 -0
  138. package/skills/optbrew/modules/seranking-seo/skills/seo-sitemap/GUIDE.md +128 -0
  139. package/skills/optbrew/modules/seranking-seo/skills/seo-technical-audit/GUIDE.md +235 -0
  140. package/skills/optbrew/modules/seranking-seo/skills/seo-technical-audit/references/severity-mapping.md +133 -0
  141. package/skills/optbrew/references/api.md +45 -0
  142. package/skills/optbrew/references/check-registry.json +28 -0
  143. package/skills/optbrew/references/evidence.md +9 -0
  144. package/skills/optbrew/references/intake.md +9 -0
  145. package/skills/optbrew/references/measurement.md +29 -0
  146. package/skills/optbrew/references/policies.md +7 -0
  147. package/skills/optbrew/references/profiles.json +13 -0
  148. package/skills/optbrew/references/remediations.json +12 -0
  149. package/skills/optbrew/references/report.md +25 -0
  150. package/skills/optbrew/references/routing.md +15 -0
  151. package/skills/optbrew/references/schemas/contract.schema.json +51 -0
  152. package/skills/optbrew/references/scoring.md +62 -0
  153. package/skills/optbrew/scripts/adapters/anthropic.mjs +116 -0
  154. package/skills/optbrew/scripts/adapters/imports.mjs +81 -0
  155. package/skills/optbrew/scripts/audit.mjs +145 -0
  156. package/skills/optbrew/scripts/cli.mjs +41 -0
  157. package/skills/optbrew/scripts/core/bootstrap.mjs +31 -0
  158. package/skills/optbrew/scripts/core/bundle.mjs +73 -0
  159. package/skills/optbrew/scripts/core/capabilities.mjs +19 -0
  160. package/skills/optbrew/scripts/core/collector.mjs +236 -0
  161. package/skills/optbrew/scripts/core/compare.mjs +47 -0
  162. package/skills/optbrew/scripts/core/contracts.mjs +144 -0
  163. package/skills/optbrew/scripts/core/cost-ledger.mjs +95 -0
  164. package/skills/optbrew/scripts/core/findings.mjs +54 -0
  165. package/skills/optbrew/scripts/core/metrics.mjs +97 -0
  166. package/skills/optbrew/scripts/core/protocol.mjs +125 -0
  167. package/skills/optbrew/scripts/core/report.mjs +652 -0
  168. package/skills/optbrew/scripts/core/scope.mjs +52 -0
  169. package/skills/optbrew/scripts/core/score.mjs +71 -0
  170. package/skills/optbrew/scripts/core/weights.mjs +139 -0
  171. package/skills/optbrew/source-lock.json +2686 -0
  172. package/source-lock.json +2686 -0
@@ -0,0 +1,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.1.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,236 @@
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
+
9
+ const HEADER_NAMES = ['content-type', 'content-length', 'content-range', 'content-encoding', 'location', 'x-robots-tag', 'last-modified', 'etag'];
10
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
11
+ const text = value => Buffer.isBuffer(value) ? value : Buffer.from(value ?? '');
12
+
13
+ function auxiliaryDocument(url) {
14
+ const pathname = decodeURIComponent(new URL(url).pathname).toLowerCase();
15
+ return /\.(?:txt|xml)(?:\.gz)?$/.test(pathname) ||
16
+ /(?:^|\/)(?:sitemap|(?:[^/]*[-_])?polic(?:y|ies))(?:[-_.\/]|$)/.test(pathname);
17
+ }
18
+
19
+ function undeclaredAuxiliary(url, allowed) {
20
+ if (!auxiliaryDocument(url.href) || allowed.has(url.href)) return;
21
+ const error = new Error('auxiliary document was not declared');
22
+ error.code = 'auxiliary_undeclared';
23
+ throw error;
24
+ }
25
+
26
+ function safeHeaders(headers) {
27
+ const source = headers instanceof Headers ? Object.fromEntries(headers) : headers ?? {};
28
+ return Object.fromEntries(Object.entries(source).filter(([name]) => HEADER_NAMES.includes(name.toLowerCase()))
29
+ .map(([name, value]) => [name.toLowerCase(), String(value)]));
30
+ }
31
+
32
+ /** Response header lines in wire order; repeated lines (for example two X-Robots-Tag lines) stay separate. */
33
+ export function headerLines(response) {
34
+ const lines = [];
35
+ if (Array.isArray(response.rawHeaders)) {
36
+ for (let i = 0; i + 1 < response.rawHeaders.length; i += 2) {
37
+ const name = String(response.rawHeaders[i]).toLowerCase();
38
+ if (HEADER_NAMES.includes(name)) lines.push({ name, value: String(response.rawHeaders[i + 1]) });
39
+ }
40
+ return lines;
41
+ }
42
+ const source = response.headers instanceof Headers ? Object.fromEntries(response.headers) : response.headers ?? {};
43
+ for (const [name, value] of Object.entries(source))
44
+ if (HEADER_NAMES.includes(name.toLowerCase())) for (const item of Array.isArray(value) ? value : [value])
45
+ lines.push({ name: name.toLowerCase(), value: String(item) });
46
+ return lines;
47
+ }
48
+
49
+ /** Node's lookup callback pins the already-vetted address while TLS still verifies the URL hostname. */
50
+ async function nodeTransport({ url, address, timeoutMs, maxBytes, headers = {}, signal }) {
51
+ const client = url.protocol === 'https:' ? https : http;
52
+ const hostname = url.hostname.replace(/^\[|\]$/g, '');
53
+ return new Promise((resolve, reject) => {
54
+ const req = client.request(url, {
55
+ method: 'GET', agent: false, signal, headers: { 'User-Agent': 'optbrew/0.1 (bounded public audit)', Accept: '*/*', 'Accept-Encoding': 'identity', ...headers },
56
+ lookup: (_hostname, options, callback) => options.all ?
57
+ callback(null, [{ address: address.address, family: address.family }]) : callback(null, address.address, address.family),
58
+ ...(url.protocol === 'https:' ? { rejectUnauthorized: true,
59
+ ...(!isIP(hostname) ? { servername: hostname } : {}) } : {}),
60
+ }, res => {
61
+ const chunks = []; let size = 0;
62
+ if (Number(res.headers['content-length']) > maxBytes) {
63
+ req.destroy(new Error('response byte limit exceeded')); return;
64
+ }
65
+ res.on('data', chunk => {
66
+ size += chunk.length;
67
+ if (size > maxBytes) { req.destroy(new Error('response byte limit exceeded')); return; }
68
+ chunks.push(chunk);
69
+ });
70
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, rawHeaders: res.rawHeaders, body: Buffer.concat(chunks) }));
71
+ res.on('error', reject);
72
+ });
73
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('request timeout')));
74
+ req.on('error', reject);
75
+ req.end();
76
+ });
77
+ }
78
+
79
+ async function addressesFor(url, resolve) {
80
+ const host = url.hostname.replace(/^\[|\]$/g, '');
81
+ if (isIP(host)) return [{ address: host, family: isIP(host) }];
82
+ const values = await resolve(host);
83
+ return (Array.isArray(values) ? values : [values]).map(x => {
84
+ const address = typeof x === 'string' ? x : x?.address;
85
+ return { address, family: isIP(address) };
86
+ });
87
+ }
88
+
89
+ /** Fetch one URL with fresh DNS validation at every redirect and a pinned connection. */
90
+ 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 }) {
91
+ if (![timeoutMs, maxBytes, maxRedirects].every(Number.isInteger) || timeoutMs < 1 || timeoutMs > 20000 || maxBytes < 1 || maxBytes > 5242880 || maxRedirects < 0 || maxRedirects > 10)
92
+ throw new Error('fetch limits exceed context ceiling');
93
+ let url = assertUrlInScope(input, context.scope);
94
+ if (allowedAuxiliary) undeclaredAuxiliary(url, allowedAuxiliary);
95
+ const requested_url = String(input);
96
+ const redirect_chain = [];
97
+ const controller = new AbortController();
98
+ const deadline = performance.now() + timeoutMs;
99
+ let rejectTimeout;
100
+ const timeout = new Promise((_, reject) => { rejectTimeout = reject; });
101
+ const timer = setTimeout(() => { controller.abort(); rejectTimeout(new Error('request timeout')); }, timeoutMs);
102
+ const withinDeadline = promise => Promise.race([promise, timeout]);
103
+ try {
104
+ for (let hop = 0; hop <= maxRedirects; hop++) {
105
+ const addresses = await withinDeadline(addressesFor(url, resolve));
106
+ try { assertInScope(url.href, context.scope, addresses); }
107
+ catch (error) { error.code = 'scope_rejected'; error.redirect_chain = redirect_chain; throw error; }
108
+ // Pick only a vetted address. The transport must use this address, not resolve again.
109
+ const remainingMs = Math.ceil(deadline - performance.now());
110
+ if (remainingMs <= 0 || controller.signal.aborted) throw new Error('request timeout');
111
+ // Reserve the shared budget immediately before the real request; redirect hops are requests too.
112
+ if (reserveRequest && !reserveRequest()) {
113
+ const error = new Error('request budget exhausted');
114
+ error.code = 'request_limit';
115
+ error.redirect_chain = redirect_chain;
116
+ throw error;
117
+ }
118
+ const response = await withinDeadline(transport({ url, address: addresses[0], timeoutMs: remainingMs, maxBytes, headers, signal: controller.signal }));
119
+ if (!Number.isInteger(response?.status) || response.status < 100 || response.status > 599) throw new Error('invalid transport status');
120
+ const body = text(response.body);
121
+ if (body.length > maxBytes) throw new Error('response byte limit exceeded');
122
+ const savedHeaders = safeHeaders(response.headers);
123
+ if (REDIRECTS.has(response.status) && savedHeaders.location) {
124
+ if (hop === maxRedirects) throw new Error('redirect limit exceeded');
125
+ let next;
126
+ try { next = assertUrlInScope(new URL(savedHeaders.location, url).href, context.scope); }
127
+ catch {
128
+ const error = new Error('redirect outside scope');
129
+ error.code = 'redirect_outside_scope';
130
+ error.redirect_chain = [...redirect_chain, { url: url.href, status: response.status, location: '[blocked]' }];
131
+ throw error;
132
+ }
133
+ if (allowedAuxiliary) {
134
+ try { undeclaredAuxiliary(next, allowedAuxiliary); }
135
+ catch (error) { error.redirect_chain = [...redirect_chain, { url: url.href, status: response.status, location: '[blocked]' }]; throw error; }
136
+ }
137
+ redirect_chain.push({ url: url.href, status: response.status, location: next.href });
138
+ url = next;
139
+ continue;
140
+ }
141
+ return {
142
+ requested_url, normalized_url: assertUrlInScope(requested_url, context.scope).href, final_url: url.href,
143
+ status: response.status, headers: savedHeaders, header_lines: headerLines(response), redirect_chain, raw_html_ref: null,
144
+ content_hash: createHash('sha256').update(body).digest('hex'),
145
+ capability: savedHeaders['content-type']?.toLowerCase().includes('html') &&
146
+ (!savedHeaders['content-encoding'] || savedHeaders['content-encoding'].toLowerCase() === 'identity') ? 'raw_html' : 'raw_response',
147
+ source_ref: 'optbrew:collector', evidence_ref: null, error: null, body,
148
+ };
149
+ }
150
+ throw new Error('redirect limit exceeded');
151
+ } finally { clearTimeout(timer); }
152
+ }
153
+
154
+ /** Index of the declared robots.txt artifact on the same origin as the page, as the audit and the report both select it. */
155
+ export function declaredRobotsIndex(artifacts, declaredRobots, pageFinalUrl) {
156
+ return artifacts.findIndex(x => {
157
+ try { const u = new URL(x.final_url); return declaredRobots.has(x.normalized_url) && u.origin === new URL(pageFinalUrl).origin && u.pathname === '/robots.txt'; }
158
+ catch { return false; }
159
+ });
160
+ }
161
+
162
+ function links(html, base) {
163
+ const out = [];
164
+ for (const match of html.matchAll(/<a\b[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
165
+ try { out.push(new URL(match[1] ?? match[2], base).href); } catch { /* malformed link */ }
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /** Bounded crawl of fixed protocol URLs and their in-scope links. */
171
+ export async function collect(context, { transport, resolve } = {}) {
172
+ const scope = context?.scope;
173
+ const limit = Math.min(scope?.max_urls ?? 50, 50);
174
+ const depthLimit = Math.min(scope?.max_depth ?? 3, 3);
175
+ const concurrency = Math.min(scope?.concurrency ?? 2, 2);
176
+ if (![limit, depthLimit, concurrency].every(Number.isInteger) || limit < 1 || depthLimit < 0 || concurrency < 1)
177
+ throw new Error('invalid collection limits');
178
+ const initial = context.protocol.page_types.flatMap(type => type.urls.map(ref => ref.normalized));
179
+ const auxiliary = context.protocol.auxiliary_resources ?? [];
180
+ const declaredAuxiliary = new Set(auxiliary.map(resource => resource.normalized));
181
+ const allowedDocuments = new Set([...initial, ...declaredAuxiliary]);
182
+ if (initial.some(url => /\/(?:robots\.txt|sitemap[^/]*\.xml)$/i.test(decodeURIComponent(new URL(url).pathname))))
183
+ throw new Error('robots and sitemap must be auxiliary resources');
184
+ if (auxiliary.length && initial.length + auxiliary.length > limit) throw new Error('selected pages and auxiliary resources exceed request scope');
185
+ const queue = []; const seen = new Set(); const skipped = [];
186
+ const enqueue = (url, depth, auxiliaryResource = false) => {
187
+ let normalized;
188
+ try { normalized = assertUrlInScope(url, scope).href; }
189
+ catch { skipped.push({ url, reason: 'outside_scope' }); return; }
190
+ if (depth > 0 && auxiliaryDocument(normalized) && !allowedDocuments.has(normalized)) {
191
+ skipped.push({ url: normalized, reason: 'auxiliary_undeclared' }); return;
192
+ }
193
+ if (seen.has(normalized)) return;
194
+ seen.add(normalized);
195
+ if (depth > depthLimit) { skipped.push({ url: normalized, reason: 'depth_limit' }); return; }
196
+ queue.push({ url: normalized, depth, auxiliaryResource });
197
+ };
198
+ initial.forEach(url => enqueue(url, 0));
199
+ for (const resource of auxiliary) {
200
+ if (seen.has(resource.normalized)) throw new Error('auxiliary resource duplicates selected URL');
201
+ const normalized = assertUrlInScope(resource.normalized, scope).href;
202
+ if (normalized !== resource.normalized || assertUrlInScope(resource.original, scope).href !== normalized)
203
+ throw new Error('auxiliary resource URL mismatch');
204
+ enqueue(normalized, 0, true);
205
+ }
206
+ const artifacts = []; let cursor = 0; let requestCount = 0; let evaluated = 0;
207
+ async function worker() {
208
+ while (cursor < queue.length) {
209
+ const item = queue[cursor++];
210
+ if (requestCount >= limit) { skipped.push({ url: item.url, reason: 'url_limit' }); continue; }
211
+ let used = 0;
212
+ const reserveRequest = () => {
213
+ if (requestCount >= limit) return false;
214
+ requestCount++; used++;
215
+ return true;
216
+ };
217
+ let artifact;
218
+ try { artifact = await fetchScoped(item.url, { context, transport, resolve, allowedAuxiliary: allowedDocuments, reserveRequest }); }
219
+ catch (error) {
220
+ const reason = ['scope_rejected', 'redirect_outside_scope', 'auxiliary_undeclared', 'request_limit'].includes(error.code) ? error.code : 'fetch_failed';
221
+ if (reason === 'request_limit' && !used) { skipped.push({ url: item.url, reason: 'url_limit' }); continue; }
222
+ if (reason !== 'fetch_failed') skipped.push({ url: item.url, reason });
223
+ artifact = { requested_url: item.url, normalized_url: item.url, final_url: null, status: null,
224
+ headers: {}, header_lines: [], redirect_chain: error.redirect_chain ?? [], raw_html_ref: null, content_hash: null, capability: 'none',
225
+ source_ref: 'optbrew:collector', evidence_ref: null, error: reason, body: Buffer.alloc(0) };
226
+ }
227
+ if (used) evaluated++;
228
+ artifacts.push(artifact);
229
+ if (!item.auxiliaryResource && artifact.status === 200 && artifact.capability === 'raw_html')
230
+ for (const next of links(artifact.body.toString('utf8'), artifact.final_url)) enqueue(next, item.depth + 1);
231
+ }
232
+ }
233
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
234
+ return { artifacts, discovered_count: seen.size, evaluated_count: evaluated, request_count: requestCount, skipped,
235
+ limit_reached: skipped.some(x => x.reason === 'url_limit' || x.reason === 'request_limit') };
236
+ }
@@ -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
+ }
@@ -0,0 +1,95 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname, basename, join } from 'node:path';
4
+ import { validateEntity } from './contracts.mjs';
5
+
6
+ function amountKey(item, provider) { return `${provider}\u0000${item.unit}\u0000${item.currency ?? ''}`; }
7
+ function validAmounts(items, allowZero = false) {
8
+ if (!Array.isArray(items) || !items.length) throw new Error('cost upper bounds required');
9
+ const seen = new Set();
10
+ for (const item of items) {
11
+ if (!['credits', 'tokens', 'search_calls', 'currency_minor'].includes(item.unit) ||
12
+ !Number.isSafeInteger(item.amount) || (allowZero ? item.amount < 0 : item.amount < 1) ||
13
+ (item.unit === 'currency_minor' ? !/^[A-Z]{3}$/.test(item.currency ?? '') : item.currency != null))
14
+ throw new Error('invalid cost unit or amount');
15
+ const key = amountKey(item, '');
16
+ if (seen.has(key)) throw new Error('duplicate cost unit');
17
+ seen.add(key);
18
+ }
19
+ }
20
+
21
+ async function locked(file, action) {
22
+ const lock = `${file}.lock`;
23
+ // ponytail: one ledger lock limits throughput; split by run only if this becomes measurable.
24
+ let acquired = false;
25
+ for (let attempt = 0; attempt < 30; attempt++) {
26
+ try { await mkdir(lock); acquired = true; break; }
27
+ catch (error) {
28
+ if (error.code !== 'EEXIST') throw error;
29
+ if (attempt === 29) throw new Error('cost ledger lock exists; manual recovery required');
30
+ await new Promise(resolve => setTimeout(resolve, 10));
31
+ }
32
+ }
33
+ if (!acquired) throw new Error('cost ledger lock exists');
34
+ try {
35
+ const ledger = validateEntity('CostLedger', JSON.parse(await readFile(file, 'utf8')));
36
+ const result = await action(ledger);
37
+ const temporary = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`);
38
+ try { await writeFile(temporary, `${JSON.stringify(ledger, null, 2)}\n`, { flag: 'wx' }); await rename(temporary, file); }
39
+ catch (error) { await rm(temporary, { force: true }); throw error; }
40
+ return result ?? ledger;
41
+ } finally { await rm(lock, { recursive: true }); }
42
+ }
43
+
44
+ function heldAmount(ledger, provider, unit) {
45
+ return ledger.reservations.reduce((total, reservation) => {
46
+ if (reservation.provider !== provider) return total;
47
+ const upper = reservation.upperBounds.find(x => amountKey(x, provider) === amountKey(unit, provider));
48
+ if (!upper) return total;
49
+ const settled = ledger.settlements.find(x => x.reservation_id === reservation.reservation_id && ['settled', 'overrun'].includes(x.status));
50
+ return total + BigInt(settled?.actual?.find(x => amountKey(x, provider) === amountKey(unit, provider))?.amount ?? upper.amount);
51
+ }, 0n);
52
+ }
53
+
54
+ /** Reserve a per-attempt upper bound before any paid provider request. */
55
+ export async function reserveCost(ledgerPath, { provider, upperBounds, approvalRef, runId }) {
56
+ if (typeof provider !== 'string' || !provider || typeof approvalRef !== 'string' || !approvalRef) throw new Error('provider and approval required');
57
+ validAmounts(upperBounds);
58
+ return locked(ledgerPath, ledger => {
59
+ if (ledger.approval_ref !== approvalRef) throw new Error('cost approval mismatch');
60
+ if (runId !== undefined && ledger.run_id !== runId) throw new Error('cost ledger run mismatch');
61
+ if (ledger.reservations.some(x => x.status === 'overrun')) throw new Error('cost spending blocked by observed overrun');
62
+ for (const requested of upperBounds) {
63
+ const budget = ledger.budgets.find(x => x.provider === provider && amountKey(x, provider) === amountKey(requested, provider));
64
+ if (!budget || !Number.isSafeInteger(budget.amount) || budget.amount < 0) throw new Error('cost cap missing');
65
+ if (heldAmount(ledger, provider, requested) + BigInt(requested.amount) > BigInt(budget.amount)) throw new Error('cost cap exceeded');
66
+ }
67
+ const reservation = { reservation_id: randomUUID(), provider, upperBounds, status: 'reserved', approval_ref: approvalRef,
68
+ created_at: new Date().toISOString() };
69
+ ledger.reservations.push(reservation);
70
+ return reservation;
71
+ });
72
+ }
73
+
74
+ /** Unknown billing retains the bound; an observed overrun is recorded and blocks new spend. */
75
+ export async function settleCost(ledgerPath, reservationId, actualOrUnknown) {
76
+ return locked(ledgerPath, ledger => {
77
+ const reservation = ledger.reservations.find(x => x.reservation_id === reservationId);
78
+ if (!reservation || reservation.status !== 'reserved') throw new Error('reservation not open');
79
+ const unknown = actualOrUnknown === 'unknown';
80
+ let overrun = false;
81
+ if (!unknown) {
82
+ validAmounts(actualOrUnknown, true);
83
+ for (const actual of actualOrUnknown) {
84
+ const upper = reservation.upperBounds.find(x => amountKey(x, reservation.provider) === amountKey(actual, reservation.provider));
85
+ if (!upper) throw new Error('actual cost unit was not reserved');
86
+ if (actual.amount > upper.amount) overrun = true;
87
+ }
88
+ if (actualOrUnknown.length !== reservation.upperBounds.length) throw new Error('actual cost units incomplete');
89
+ }
90
+ reservation.status = unknown ? 'unknown' : overrun ? 'overrun' : 'settled';
91
+ ledger.settlements.push({ reservation_id: reservationId, status: reservation.status, actual: unknown ? null : actualOrUnknown,
92
+ settled_at: new Date().toISOString() });
93
+ return ledger;
94
+ });
95
+ }