optbrew 0.1.3 → 0.2.0

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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/AGENT_INSTALL.md +13 -11
  5. package/CHANGELOG.md +52 -0
  6. package/README.ko.md +40 -15
  7. package/README.md +40 -15
  8. package/THIRD_PARTY_NOTICES.md +88 -0
  9. package/docs/platforms.md +10 -10
  10. package/docs/release.md +37 -5
  11. package/lib/cli.mjs +5 -2
  12. package/lib/install.mjs +2 -1
  13. package/lib/records.mjs +2 -1
  14. package/lib/transaction.mjs +12 -5
  15. package/lib/update.mjs +3 -2
  16. package/package.json +6 -4
  17. package/plugin.json +2 -2
  18. package/skills/.claude-plugin/icon.png +0 -0
  19. package/skills/.claude-plugin/plugin.json +23 -0
  20. package/skills/README.md +27 -0
  21. package/skills/optbrew/SKILL.md +223 -8
  22. package/skills/optbrew/THIRD_PARTY_NOTICES.md +88 -0
  23. package/skills/optbrew/bundle-manifest.json +407 -47
  24. package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +4 -4
  25. package/skills/optbrew/references/api.md +5 -3
  26. package/skills/optbrew/references/check-registry.json +16 -16
  27. package/skills/optbrew/references/chrome-paths.json +31 -0
  28. package/skills/optbrew/references/endpoints.json +10 -0
  29. package/skills/optbrew/references/harness-signals.json +5 -0
  30. package/skills/optbrew/references/intake.md +76 -5
  31. package/skills/optbrew/references/judging.md +233 -0
  32. package/skills/optbrew/references/limits.json +26 -0
  33. package/skills/optbrew/references/measurement.md +1 -1
  34. package/skills/optbrew/references/messages.ko.json +170 -0
  35. package/skills/optbrew/references/report.md +18 -6
  36. package/skills/optbrew/references/routing.md +2 -2
  37. package/skills/optbrew/references/runbooks/anthropic-api-key.md +34 -0
  38. package/skills/optbrew/references/runbooks/chrome-install.md +56 -0
  39. package/skills/optbrew/references/runbooks/cwv-site-collection.md +38 -0
  40. package/skills/optbrew/references/runbooks/google-api-key.md +51 -0
  41. package/skills/optbrew/references/runbooks/gsc-export.md +32 -0
  42. package/skills/optbrew/references/runbooks/index.json +18 -0
  43. package/skills/optbrew/references/runbooks/key-set-claude-cloud.md +32 -0
  44. package/skills/optbrew/references/runbooks/key-set-codex.md +33 -0
  45. package/skills/optbrew/references/runbooks/key-set-local.md +40 -0
  46. package/skills/optbrew/references/runbooks/naver-searchadvisor-export.md +32 -0
  47. package/skills/optbrew/references/runbooks/network-claude-cloud-custom.md +33 -0
  48. package/skills/optbrew/references/runbooks/network-codex-approval.md +26 -0
  49. package/skills/optbrew/references/runbooks/network-codex-config.md +36 -0
  50. package/skills/optbrew/references/runbooks/seranking.md +38 -0
  51. package/skills/optbrew/references/schemas/contract.schema.json +42 -15
  52. package/skills/optbrew/references/scoring.md +40 -5
  53. package/skills/optbrew/references/source-analysis.md +40 -0
  54. package/skills/optbrew/references/versions.json +9 -0
  55. package/skills/optbrew/scripts/adapters/anthropic.mjs +5 -5
  56. package/skills/optbrew/scripts/adapters/imports.mjs +138 -1
  57. package/skills/optbrew/scripts/audit.mjs +10 -112
  58. package/skills/optbrew/scripts/cli.mjs +5 -2
  59. package/skills/optbrew/scripts/core/bundle.mjs +30 -13
  60. package/skills/optbrew/scripts/core/capabilities.mjs +8 -4
  61. package/skills/optbrew/scripts/core/cdp.mjs +105 -0
  62. package/skills/optbrew/scripts/core/checks/canonical-consistency.mjs +56 -0
  63. package/skills/optbrew/scripts/core/checks/cwv-field.mjs +77 -0
  64. package/skills/optbrew/scripts/core/checks/discovery.mjs +43 -0
  65. package/skills/optbrew/scripts/core/checks/document-title.mjs +62 -0
  66. package/skills/optbrew/scripts/core/checks/entity-identity.mjs +31 -0
  67. package/skills/optbrew/scripts/core/checks/fact-consistency.mjs +31 -0
  68. package/skills/optbrew/scripts/core/checks/fetch-response.mjs +18 -0
  69. package/skills/optbrew/scripts/core/checks/index-policy.mjs +16 -0
  70. package/skills/optbrew/scripts/core/checks/index.mjs +18 -0
  71. package/skills/optbrew/scripts/core/checks/internal-navigation.mjs +67 -0
  72. package/skills/optbrew/scripts/core/checks/locale-links.mjs +55 -0
  73. package/skills/optbrew/scripts/core/checks/naver-markup.mjs +21 -0
  74. package/skills/optbrew/scripts/core/checks/observation.mjs +37 -0
  75. package/skills/optbrew/scripts/core/checks/prepare.mjs +103 -0
  76. package/skills/optbrew/scripts/core/checks/render-content.mjs +104 -0
  77. package/skills/optbrew/scripts/core/checks/search-crawl-policy.mjs +29 -0
  78. package/skills/optbrew/scripts/core/checks/structured-data-truth.mjs +46 -0
  79. package/skills/optbrew/scripts/core/chrome-temp.mjs +68 -0
  80. package/skills/optbrew/scripts/core/chrome.mjs +546 -0
  81. package/skills/optbrew/scripts/core/collector.mjs +45 -49
  82. package/skills/optbrew/scripts/core/compare.mjs +18 -1
  83. package/skills/optbrew/scripts/core/config.mjs +31 -0
  84. package/skills/optbrew/scripts/core/contracts.mjs +31 -2
  85. package/skills/optbrew/scripts/core/cwv.mjs +100 -0
  86. package/skills/optbrew/scripts/core/derive.mjs +10 -186
  87. package/skills/optbrew/scripts/core/evidence-store.mjs +112 -0
  88. package/skills/optbrew/scripts/core/facts.mjs +175 -17
  89. package/skills/optbrew/scripts/core/findings.mjs +19 -1
  90. package/skills/optbrew/scripts/core/html.mjs +41 -3
  91. package/skills/optbrew/scripts/core/index-targets.mjs +118 -0
  92. package/skills/optbrew/scripts/core/judgments.mjs +675 -0
  93. package/skills/optbrew/scripts/core/keys.mjs +86 -0
  94. package/skills/optbrew/scripts/core/link-graph.mjs +93 -0
  95. package/skills/optbrew/scripts/core/messages.mjs +23 -0
  96. package/skills/optbrew/scripts/core/metrics.mjs +4 -2
  97. package/skills/optbrew/scripts/core/migration.mjs +37 -0
  98. package/skills/optbrew/scripts/core/page-select.mjs +136 -0
  99. package/skills/optbrew/scripts/core/probe.mjs +83 -0
  100. package/skills/optbrew/scripts/core/protocol.mjs +38 -14
  101. package/skills/optbrew/scripts/core/render.mjs +160 -0
  102. package/skills/optbrew/scripts/core/report-compose.mjs +377 -0
  103. package/skills/optbrew/scripts/core/report-html.mjs +198 -0
  104. package/skills/optbrew/scripts/core/report-page.mjs +14 -0
  105. package/skills/optbrew/scripts/core/report.mjs +327 -122
  106. package/skills/optbrew/scripts/core/runbooks.mjs +63 -0
  107. package/skills/optbrew/scripts/core/scope.mjs +23 -5
  108. package/skills/optbrew/scripts/core/score.mjs +12 -4
  109. package/skills/optbrew/scripts/core/secrets.mjs +2 -0
  110. package/skills/optbrew/scripts/core/stages.mjs +694 -0
  111. package/skills/optbrew/scripts/core/transport.mjs +157 -0
  112. package/skills/optbrew/scripts/core/weights.mjs +2 -1
  113. package/skills/optbrew/scripts/key.mjs +90 -0
  114. package/skills/optbrew/scripts/preflight.mjs +176 -0
  115. package/skills/optbrew/scripts/run.mjs +81 -0
  116. package/skills/optbrew/source-lock.json +1 -1
  117. package/source-lock.json +1 -1
@@ -0,0 +1,86 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { readFileSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ const ENV_NAME = 'OPTBREW_GOOGLE_API_KEY';
8
+ const HASH_CHARS = 8;
9
+ const OWNER_ONLY_FILE = 0o600;
10
+ const OWNER_ONLY_DIR = 0o700;
11
+
12
+ /** The home folder the key file lives in: USERPROFILE on Windows, HOME elsewhere, then the OS answer. Never a site or output folder. */
13
+ export function keyHome(env = process.env, platform = process.platform) {
14
+ return (platform === 'win32' ? env?.USERPROFILE : env?.HOME) || os.homedir();
15
+ }
16
+
17
+ const credentialsPath = home => path.join(home, '.optbrew', 'credentials.json');
18
+
19
+ const clean = value => typeof value === 'string' && value.trim() ? value.trim() : null;
20
+
21
+ /**
22
+ * The key file as a plain object for code that rewrites it. A missing file is empty; an unreadable, corrupt or
23
+ * non-object file is refused, so a hand-edited file with a typo is never overwritten or deleted. The error names
24
+ * the path only, never the file's contents.
25
+ */
26
+ function loadCredentials(file) {
27
+ let value;
28
+ try { value = JSON.parse(readFileSync(file, 'utf8')); }
29
+ catch (error) { if (error.code === 'ENOENT') return {}; value = null; }
30
+ if (!value || typeof value !== 'object' || Array.isArray(value))
31
+ throw new Error(`credentials file at ${file} is not a valid JSON object; fix it or delete it, then run the command again`);
32
+ return value;
33
+ }
34
+
35
+ /** The same file for readers: anything unusable means "no key". */
36
+ function readCredentials(file) {
37
+ try { return loadCredentials(file); } catch { return {}; }
38
+ }
39
+
40
+ /** Environment variable first, then the key file. An empty variable, a missing file or a corrupt file all mean "no key". */
41
+ export function readGoogleKey({ env = process.env, home = keyHome(env) } = {}) {
42
+ return clean(env?.[ENV_NAME]) ?? clean(readCredentials(credentialsPath(home)).google);
43
+ }
44
+
45
+ /**
46
+ * Presence and the first 8 hex characters of the SHA-256; the key value itself is never returned. A key file that exists but is
47
+ * not a JSON object adds `corrupt: true` (the file is only read, never changed), so the user is told to fix it instead of
48
+ * being told there is no key.
49
+ */
50
+ export function googleKeyStatus({ env = process.env, home = keyHome(env) } = {}) {
51
+ const key = readGoogleKey({ env, home });
52
+ let corrupt = false;
53
+ try { loadCredentials(credentialsPath(home)); } catch { corrupt = true; }
54
+ return { google: { present: key !== null, sha256_8: key === null ? null : createHash('sha256').update(key).digest('hex').slice(0, HASH_CHARS), ...(corrupt ? { corrupt: true } : {}) } };
55
+ }
56
+
57
+ /** Replace the key file atomically (temp file, then rename) so a reader never sees a half-written file. */
58
+ async function writeCredentials(file, credentials) {
59
+ const temp = `${file}.${process.pid}.tmp`;
60
+ await mkdir(path.dirname(file), { recursive: true, mode: OWNER_ONLY_DIR });
61
+ try {
62
+ await writeFile(temp, `${JSON.stringify(credentials)}\n`, { flag: 'wx', mode: OWNER_ONLY_FILE });
63
+ // Windows has no POSIX modes (chmod only toggles read-only there); the home folder's own ACL protects the file.
64
+ if (process.platform !== 'win32') {
65
+ await chmod(path.dirname(file), OWNER_ONLY_DIR);
66
+ await chmod(temp, OWNER_ONLY_FILE);
67
+ }
68
+ await rename(temp, file);
69
+ } finally { await rm(temp, { force: true }); }
70
+ }
71
+
72
+ /** Store the key and keep every other entry already in the file. */
73
+ export async function saveGoogleKey({ home = keyHome(), key }) {
74
+ const file = credentialsPath(home);
75
+ await writeCredentials(file, { ...loadCredentials(file), google: key });
76
+ }
77
+
78
+ /** Drop only the google entry; the file itself goes away once nothing else is left in it. Returns whether an entry existed. */
79
+ export async function removeGoogleKey({ home = keyHome() } = {}) {
80
+ const file = credentialsPath(home);
81
+ const { google, ...rest } = loadCredentials(file);
82
+ if (google === undefined) return false;
83
+ if (Object.keys(rest).length) await writeCredentials(file, rest);
84
+ else await rm(file, { force: true });
85
+ return true;
86
+ }
@@ -0,0 +1,93 @@
1
+ import { anchorLinks, decodeEntities } from './html.mjs';
2
+
3
+ /** Link and sitemap target comparison key: no fragment, and a directory index file equals its directory. */
4
+ export function canonicalUrl(value) {
5
+ try {
6
+ const url = new URL(value); url.hash = '';
7
+ url.pathname = url.pathname.replace(/\/index\.(?:html?|php)$/i, '/');
8
+ return url.href;
9
+ } catch { return null; }
10
+ }
11
+
12
+ /** The distinct links of `hrefs` that stay on the origin of `base`, absolute and without fragments, sorted. */
13
+ export function sameOriginLinks(hrefs, base) {
14
+ const { origin } = new URL(base);
15
+ const found = new Set();
16
+ for (const href of hrefs) {
17
+ let url;
18
+ try { url = new URL(href, base); } catch { continue; }
19
+ url.hash = '';
20
+ if (url.origin === origin) found.add(url.href);
21
+ }
22
+ return [...found].sort();
23
+ }
24
+
25
+ /**
26
+ * The anchor links between collected HTML pages. `rows` are `{ url, artifact, html }` for every collected page with a 200
27
+ * raw HTML body, selected or not. `edges` lists `{ from, to }` canonical urls (a page's link to itself is not an edge).
28
+ * `depthOf(url)` is the click distance from the home page, `inbound(url)` the number of links into a page.
29
+ * `scriptGap` is true when a page that has scripts and no anchors loads a same-origin script file that was not collected:
30
+ * its navigation cannot be read, so a missing link proves nothing.
31
+ */
32
+ export function linkGraph(rows, artifacts) {
33
+ const collected = new Set(artifacts.flatMap(x => [x.normalized_url, x.final_url].filter(Boolean).map(canonicalUrl)));
34
+ const edges = [];
35
+ let scriptGap = false;
36
+ for (const { artifact, html } of rows) {
37
+ const hrefs = anchorLinks(html, artifact.final_url);
38
+ if (!hrefs.length && /<script\b/i.test(html)) {
39
+ for (const match of html.matchAll(/<script\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
40
+ try {
41
+ const source = new URL(decodeEntities(match[1] ?? match[2]), artifact.final_url);
42
+ if (source.origin === new URL(artifact.final_url).origin && !collected.has(canonicalUrl(source.href))) scriptGap = true;
43
+ } catch { /* malformed script URL */ }
44
+ }
45
+ }
46
+ const from = canonicalUrl(artifact.normalized_url);
47
+ for (const href of hrefs) {
48
+ const to = canonicalUrl(href);
49
+ if (to && to !== canonicalUrl(artifact.final_url) && to !== from) edges.push({ from, to });
50
+ }
51
+ }
52
+ const next = new Map();
53
+ for (const { from, to } of edges) next.set(from, [...next.get(from) ?? [], to]);
54
+ const home = rows.find(row => new URL(row.artifact.final_url).pathname === '/') ?? rows[0];
55
+ const depth = new Map(home ? [[canonicalUrl(home.url), 0]] : []);
56
+ for (const page of depth.keys()) for (const to of next.get(page) ?? []) if (!depth.has(to)) depth.set(to, depth.get(page) + 1);
57
+ return {
58
+ edges, scriptGap,
59
+ depthOf: url => depth.get(canonicalUrl(url)) ?? null,
60
+ inbound: url => edges.filter(edge => edge.to === canonicalUrl(url)).length,
61
+ };
62
+ }
63
+
64
+ /** True when any other collected page's raw bytes name this page's path (whole path segment, not a longer path). */
65
+ function pathMentioned({ url, artifact }, rows) {
66
+ const own = canonicalUrl(url);
67
+ for (const target of new Set([url, artifact.final_url])) {
68
+ let path;
69
+ try { path = decodeURIComponent(new URL(target).pathname); } catch { continue; }
70
+ if (path === '/') continue;
71
+ const pattern = new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![A-Za-z0-9_-])');
72
+ for (const page of rows) if (canonicalUrl(page.url) !== own && pattern.test(page.html)) return true;
73
+ }
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * Selected pages that nothing links to and no declared sitemap lists, when the evidence is complete enough to say so.
79
+ * An unlinked page stays undecided when another page's own bytes (inline scripts included) name its path, or when a
80
+ * script file that could build the link was not collected, unless a fetched sitemap that lists other pages leaves it out.
81
+ * No page is an orphan when the crawl was cut short, fewer than two pages were collected, or the sitemap is an index
82
+ * whose child sitemaps were not fetched, or a declared sitemap was never fetched.
83
+ */
84
+ export function findOrphans({ pageRows, rows, graph, sitemaps, complete }) {
85
+ const orphans = new Set();
86
+ if (rows.length < 2 || !complete || sitemaps.index || sitemaps.unread.length) return orphans;
87
+ for (const row of pageRows) {
88
+ const keys = [canonicalUrl(row.url), canonicalUrl(row.artifact.final_url)];
89
+ if (keys.some(key => sitemaps.urls.has(key) || graph.inbound(key))) continue;
90
+ if (sitemaps.urls.size || !(graph.scriptGap || pathMentioned(row, rows))) orphans.add(row.url);
91
+ }
92
+ return orphans;
93
+ }
@@ -0,0 +1,23 @@
1
+ import { messagesKo } from './config.mjs';
2
+
3
+ /** `{name}` takes the value as it is; `{name:table}` looks the value up as `table.<value>` in the catalog (a word the value stands for). */
4
+ const PLACEHOLDER = /\{(\w+)(?::([\w-]+))?\}/g;
5
+ const UNKNOWN_WORD = 'word.unknown';
6
+
7
+ /** `{ message_key, message_values }`: what a check records beside its English message so the report can say it in Korean. */
8
+ export const messageOf = (message_key, message_values = {}) => ({ message_key, message_values });
9
+
10
+ /**
11
+ * The Korean sentence for a message key with its page-specific values filled in; `fallback` (the English text) when the key is
12
+ * absent or has no entry. Values stay as they are: the renderer escapes them for its format. A missing value reads as the unknown word.
13
+ */
14
+ export function koText(key, values = {}, fallback = null) {
15
+ const catalog = messagesKo();
16
+ const template = key ? catalog[key] : undefined;
17
+ if (typeof template !== 'string') return fallback;
18
+ return template.replace(PLACEHOLDER, (_, name, table) => {
19
+ const value = values?.[name];
20
+ if (value === undefined || value === null || value === '') return catalog[UNKNOWN_WORD];
21
+ return table ? catalog[`${table}.${value}`] ?? String(value) : String(value);
22
+ });
23
+ }
@@ -1,15 +1,17 @@
1
1
  import { ContractError, validateEntity } from './contracts.mjs';
2
+ import { versions } from './config.mjs';
2
3
 
3
4
  function ratio(id, numerator, denominator, definition, panel, selection, evaluable = true) {
4
5
  const measured = evaluable && denominator > 0;
5
- const observation = { schema_version: '1.0.0', metric_id: id,
6
+ const observation = { schema_version: versions().contract_schema, metric_id: id,
6
7
  value_state: measured ? 'measured' : 'unknown', value: measured ? numerator / denominator : null,
7
8
  numerator: measured ? numerator : null, denominator: measured ? denominator : null,
8
9
  definition, filters: { engine: panel.engine, surface: panel.surface, model: panel.model,
9
10
  search_enabled: panel.search_enabled, locale: panel.locale, region: panel.region,
10
11
  ...(panel.collected_at ? { collected_at: panel.collected_at } : {}),
11
12
  ...(panel.last_complete_date ? { last_complete_date: panel.last_complete_date } : {}) },
12
- window: panel.window, panel_hash: panel.panel_hash, provider_definition: 'optbrew fixed AI response panel v0.1.0',
13
+ // The panel is a measurement definition, so its label is the evaluation version.
14
+ window: panel.window, panel_hash: panel.panel_hash, provider_definition: `optbrew fixed AI response panel v${versions().evaluation}`,
13
15
  sample_selection: selection, source_ref: panel.source_ref, evidence_ref: panel.evidence_ref };
14
16
  return validateEntity('MetricObservation', observation);
15
17
  }
@@ -0,0 +1,37 @@
1
+ import { ContractError } from './contracts.mjs';
2
+ import { versions } from './config.mjs';
3
+
4
+ /** Evidence an older optbrew made: one plain sentence that says what is old and what to do. */
5
+ export class MigrationRequired extends ContractError {
6
+ constructor(what, detail) {
7
+ super(`migration_required: this ${what} ${detail}; run the audit again with optbrew ${versions().release} in a new output folder`);
8
+ }
9
+ }
10
+
11
+ const numbers = version => typeof version === 'string' && /^\d+\.\d+\.\d+$/.test(version) ? version.split('.').map(Number) : null;
12
+
13
+ function isOlder(found, current) {
14
+ const left = numbers(found), right = numbers(current);
15
+ if (!left || !right) return false;
16
+ const at = left.findIndex((part, i) => part !== right[i]);
17
+ return at !== -1 && left[at] < right[at];
18
+ }
19
+
20
+ /**
21
+ * The first version label of an input (a ScoreInput, a ReportPacket or a RunContext) that is older than the one this release
22
+ * uses, as `{ name, found }`; null when none is. The protocol is named first because it is what a comparison reports.
23
+ */
24
+ export function olderVersion(input) {
25
+ const context = input?.run_context ?? input;
26
+ const now = versions();
27
+ const checks = [['audit protocol', context?.protocol_version, now.protocol], ['evaluation', context?.evaluation_version, now.evaluation],
28
+ ['contract', input?.schema_version, now.contract_schema], ['contract', context?.schema_version, now.contract_schema]];
29
+ const hit = checks.find(([, found, current]) => isOlder(found, current));
30
+ return hit ? { name: hit[0], found: hit[1] } : null;
31
+ }
32
+
33
+ /** Refuse evidence an older optbrew made, in one sentence, before any schema check can turn it into a stack trace. */
34
+ export function assertCurrentEvidence(input, what) {
35
+ const older = olderVersion(input);
36
+ if (older) throw new MigrationRequired(what, `comes from an older optbrew (${older.name} ${older.found})`);
37
+ }
@@ -0,0 +1,136 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { links } from './collector.mjs';
5
+ import { limits } from './config.mjs';
6
+ import { decodeEntities } from './html.mjs';
7
+ import { assertUrlInScope } from './scope.mjs';
8
+
9
+ const START_FILE = path.join('preflight', 'start.json');
10
+ const EVIDENCE_INDEX = path.join('preflight', 'evidence', 'evidence-index.json');
11
+ /** Robots and sitemap files are auxiliary resources of the protocol, never audited pages. */
12
+ const AUXILIARY_PATH = /\/(?:robots\.txt|sitemap[^/]*\.xml)$/i;
13
+ // A sitemap is an XML document whose root element is <urlset> or <sitemapindex>: before the root only a byte order mark, white
14
+ // space, the XML declaration, other processing instructions (Yoast's stylesheet) and comments may stand. An HTML page that shows
15
+ // sample sitemap XML in its text or a script is a page, never a sitemap. Each instruction and comment ends at its first
16
+ // closing mark, so the prolog has one reading and the match stays linear on a body with many leading comments.
17
+ const SITEMAP_ROOT = /^?(?:\s|<\?(?:[^?]|\?(?!>))*\?>|<!--(?:[^-]|-(?!->))*-->)*<(urlset|sitemapindex)\b/i;
18
+ // Files a sitemap or a link may name that are not pages to audit.
19
+ const NOT_A_PAGE = /\.(?:pdf|jpe?g|png|gif|webp|svg|ico|zip|xml|txt|css|js|json|mp4|mp3)$/i;
20
+ const KOREAN_PAGE = /<html\b[^>]*\blang\s*=\s*["']?ko/i;
21
+ const LOC = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
22
+ const HOME_TYPE = 'home';
23
+ const TOP_LEVEL_TYPE = 'pages';
24
+ const TYPE_NAME_LENGTH = 40;
25
+
26
+ const readJson = async file => { try { return JSON.parse(await readFile(file, 'utf8')); } catch { return null; } };
27
+
28
+ /** The same site under the other spelling of its name: with or without `www.`, over http or https. */
29
+ export const sameSite = (a, b) => a.hostname.replace(/^www\./, '') === b.hostname.replace(/^www\./, '') && a.port === b.port;
30
+
31
+ /**
32
+ * What the unscored read before the start questions (`preflight/` in `out`) left behind, if anything: the address the first
33
+ * request landed on (or the other site it was sent to), whether the start page declares Korean, the page addresses of every
34
+ * sitemap file it fetched and the links of the start page. A sitemap is any fetched body whose XML root is `<urlset>` or
35
+ * `<sitemapindex>`, whatever its file name (Yoast and WordPress name their children `page-sitemap.xml`, `wp-sitemap-posts-page-1.xml`). Bodies are used only when
36
+ * they still match the hash the preflight index lists. `status` says what was there: `missing` (no preflight in this folder),
37
+ * `read`, or `unreadable` (a preflight exists but none of its evidence could be used). A preflight made for another address is
38
+ * not this run's and counts as missing.
39
+ */
40
+ export async function readPreflight(out, startHref) {
41
+ const start = await readJson(path.join(out, START_FILE));
42
+ const index = await readJson(path.join(out, EVIDENCE_INDEX));
43
+ const found = { status: 'missing', requestedUrl: null, finalUrl: null, redirectedTo: null, korean: false, sitemapLocs: [], pageLinks: [], sitemapFiles: [] };
44
+ if (!start && !index) return found;
45
+ if (start?.requested_url && !sameSite(new URL(start.requested_url), new URL(startHref))) return found;
46
+ found.status = 'unreadable';
47
+ found.requestedUrl = start?.requested_url ?? null;
48
+ found.finalUrl = start?.final_url ?? null;
49
+ found.redirectedTo = start?.redirected_to ?? null;
50
+ if (start?.sitemap_final_url) found.sitemapFiles.push(start.sitemap_final_url);
51
+ for (const entry of index?.raw ?? []) {
52
+ let text;
53
+ try {
54
+ const bytes = await readFile(entry.evidence_ref);
55
+ if (createHash('sha256').update(bytes).digest('hex') !== entry.content_hash) continue;
56
+ text = bytes.toString('utf8');
57
+ } catch { continue; }
58
+ found.status = 'read';
59
+ const root = SITEMAP_ROOT.exec(text)?.[1].toLowerCase();
60
+ if (root) found.sitemapFiles.push(entry.normalized_url);
61
+ if (root === 'urlset') for (const match of text.matchAll(LOC)) found.sitemapLocs.push(decodeEntities(match[1]));
62
+ else if (!root && (entry.normalized_url === startHref || entry.normalized_url === found.finalUrl)) {
63
+ found.korean = KOREAN_PAGE.test(text);
64
+ found.pageLinks = links(text, found.finalUrl ?? startHref);
65
+ }
66
+ }
67
+ return found;
68
+ }
69
+
70
+ /**
71
+ * How many pages the selection may take within the request `limit`, with `declared` auxiliary resources (robots, sitemaps) fetched
72
+ * first. A redirect hop is a request the page count does not see, so a margin of `redirect_reserve` (a share of the pages, at least
73
+ * a minimum) stays free: a redirect then cannot push a selected page or a declared resource out of the limit. One page is always left while the limit has room for one.
74
+ */
75
+ export function pageBudget(limit, declared) {
76
+ const { share, min } = limits().redirect_reserve;
77
+ const room = Math.max(0, limit - declared);
78
+ let pages = room;
79
+ while (pages > 1 && pages + Math.max(min, Math.ceil(pages * share)) > room) pages--;
80
+ return pages;
81
+ }
82
+
83
+ /**
84
+ * The pages one audit covers, fixed before the first request: the start page, the pages the owner confirmed facts on, then the
85
+ * sitemap's pages in the order it lists them, then the start page's own links. Every address must be inside `scope`; a file that
86
+ * is not a page, a duplicate, an `auxiliary` resource (robots, a sitemap file), or anything past `budget` is left out. A fact page
87
+ * that is left out is reported back in `dropped`.
88
+ */
89
+ export function selectPages({ home, factUrls, preflight, scope, budget, auxiliary }) {
90
+ const chosen = [];
91
+ const declared = new Set(auxiliary);
92
+ const seen = new Set();
93
+ const dropped = [];
94
+ // A fact page listed twice is reported once.
95
+ const drop = (url, reason) => { if (!dropped.some(entry => entry.url === url && entry.reason === reason)) dropped.push({ url, reason }); };
96
+ const add = (href, owner) => {
97
+ let normalized;
98
+ try { normalized = assertUrlInScope(href, scope).href; }
99
+ catch { if (owner) drop(href, 'outside_scope'); return; }
100
+ const { pathname } = new URL(normalized);
101
+ // A declared sitemap or robots file is an auxiliary resource, never a page; a fact page that names one is reported, not lost.
102
+ const reason = declared.has(normalized) || AUXILIARY_PATH.test(pathname) ? 'auxiliary_resource' : NOT_A_PAGE.test(pathname) ? 'not_a_page' : null;
103
+ if (reason) { if (owner) drop(href, reason); return; }
104
+ if (seen.has(normalized)) return;
105
+ if (chosen.length >= budget) { if (owner) drop(href, 'over_limit'); return; }
106
+ seen.add(normalized);
107
+ chosen.push(normalized);
108
+ };
109
+ add(home, true);
110
+ for (const href of factUrls) add(href, true);
111
+ const before = chosen.length;
112
+ for (const href of preflight.sitemapLocs) add(href, false);
113
+ const fromSitemap = chosen.length - before;
114
+ for (const href of preflight.pageLinks) add(href, false);
115
+ return { pages: chosen, dropped, sources: { preflight: preflight.status, sitemap_pages: fromSitemap, linked_pages: chosen.length - before - fromSitemap } };
116
+ }
117
+
118
+ /** The type of a page: `home` for the start page, the first path segment for a page inside a section, `pages` for the other top-level pages. */
119
+ function typeOf(href, home) {
120
+ if (href === home) return HOME_TYPE;
121
+ const segments = new URL(href).pathname.split('/').filter(Boolean);
122
+ if (segments.length < 2) return TOP_LEVEL_TYPE;
123
+ let name = segments[0];
124
+ try { name = decodeURIComponent(name); } catch { /* an undecodable segment names its type as written */ }
125
+ return name.slice(0, TYPE_NAME_LENGTH);
126
+ }
127
+
128
+ /** Page types in the order their first page appears; the same pages always give the same types. */
129
+ export function groupPages(pages, home) {
130
+ const types = new Map();
131
+ for (const href of pages) {
132
+ const id = typeOf(href, home);
133
+ types.set(id, [...(types.get(id) ?? []), href]);
134
+ }
135
+ return [...types].map(([id, urls]) => ({ id, urls }));
136
+ }
@@ -0,0 +1,83 @@
1
+ import { harnessSignals } from './config.mjs';
2
+ import { urlTarget } from './scope.mjs';
3
+
4
+ const OTHER = 'other';
5
+
6
+ /**
7
+ * Which `harness:` values in the runbook front matter apply to each probe harness. This is the one place the two
8
+ * vocabularies meet: `local` runbooks serve both local hosts, `any` serves everyone, and an unknown host (which may be a
9
+ * local one that sets no signal, such as Codex with full network) gets `any` and `local` (R43).
10
+ */
11
+ const RUNBOOK_HARNESSES = {
12
+ 'claude-code': ['local', 'any'],
13
+ codex: ['local', 'codex', 'any'],
14
+ 'claude-code-cloud': ['claude-code-cloud', 'any'],
15
+ [OTHER]: ['any', 'local'],
16
+ };
17
+
18
+ /** The runbook that saves a Google key for each probe harness (beside RUNBOOK_HARNESSES, the other place the two vocabularies meet). */
19
+ const KEY_SET_RUNBOOKS = { 'claude-code': 'key-set-local', codex: 'key-set-codex', 'claude-code-cloud': 'key-set-claude-cloud', [OTHER]: 'key-set-local' };
20
+
21
+ export function keySetRunbook(harness) {
22
+ const id = KEY_SET_RUNBOOKS[harness];
23
+ if (!id) throw new Error(`unknown harness: ${harness}`);
24
+ return id;
25
+ }
26
+
27
+ export function runbookHarnesses(harness) {
28
+ const values = RUNBOOK_HARNESSES[harness];
29
+ if (!values) throw new Error(`unknown harness: ${harness}`);
30
+ return values;
31
+ }
32
+
33
+ const isSet = (env, names) => names.some(name => typeof env?.[name] === 'string' && env[name] !== '');
34
+
35
+ /** The first harness in `harness-signals.json` (file order) with a signal variable set; no signal means `other`. */
36
+ const detectHarness = env => Object.keys(harnessSignals()).find(harness => isSet(env, harnessSignals()[harness].env_any)) ?? OTHER;
37
+
38
+ /**
39
+ * Why the site cannot be reached, from the harness. A host that cut its own network (Codex sandbox with the
40
+ * network-disabled variable, Claude Code cloud network level) is told apart from a site that is simply down.
41
+ */
42
+ function blockReason(harness, env) {
43
+ if (harness === 'claude-code-cloud') return 'cloud_network_level';
44
+ if (harness === 'codex' && isSet(env, harnessSignals().codex.network_blocked_env_any ?? [])) return 'codex_sandbox';
45
+ return 'site_down';
46
+ }
47
+
48
+ const proxyFailure = error => typeof error?.code === 'string' && error.code.startsWith('proxy_');
49
+ /** The request was refused by the scope rules (another site, a private address): that says nothing about the site being down. */
50
+ const scopeRejection = error => ['scope_rejected', 'redirect_outside_scope'].includes(error?.code);
51
+
52
+ /**
53
+ * The start probe: harness, how the site is reached, Chrome, key presence and source tools. Everything is injected so a
54
+ * test needs no network or Chrome. `selectTransport` takes `{ env, probeHost }`. `reach(selected)`, when given, does a
55
+ * real first request; a proxy that refuses it is a network block, a scope rejection is no block at all, any other
56
+ * failure is the site. A `selectTransport` error other than a DNS failure is thrown on purpose: the CLI then fails
57
+ * with exit 2. The proxy is recorded as host and port only, and the key is recorded as present or absent, never by value.
58
+ */
59
+ export async function probeEnvironment({ env = process.env, url, selectTransport, findChrome, readGoogleKey, home, sourceTools = [], reach = null }) {
60
+ const harness = detectHarness(env);
61
+ let selected = null;
62
+ let failure = null;
63
+ try { selected = await selectTransport({ env, probeHost: urlTarget(new URL(url)).host }); }
64
+ catch (error) { if (error?.code !== 'dns_unresolvable') throw error; failure = error; }
65
+ if (selected && reach) {
66
+ try { await reach(selected); } catch (error) { if (!scopeRejection(error)) failure = error; }
67
+ }
68
+ let block = null;
69
+ if (failure) block = !selected || proxyFailure(failure) ? blockReason(harness, env) : 'site_down';
70
+ const chrome = await findChrome({ env });
71
+ const found = Boolean(chrome?.path);
72
+ return {
73
+ harness,
74
+ transport: selected ? (selected.proxy ? 'proxy' : 'direct') : null,
75
+ address_vetting: selected?.address_vetting ?? null,
76
+ proxy: selected?.proxy ? { host: selected.proxy.host, port: selected.proxy.port } : null,
77
+ block_reason: block,
78
+ chrome: { found, kind: found ? chrome.kind : null, reason: found ? null : chrome?.reason ?? 'chrome_not_found' },
79
+ google_key: (await readGoogleKey({ env, home })) !== null,
80
+ source_tools: [...new Set(sourceTools.map(tool => tool.trim()).filter(Boolean))],
81
+ sitemap_pages: null,
82
+ };
83
+ }
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { ContractError, validateEntity, validateApplicabilityDecision } from './contracts.mjs';
4
4
  import { assertUrlInScope } from './scope.mjs';
5
+ import { limits, publicOnlyFamily, requestLimit } from './config.mjs';
5
6
 
6
7
  const registry = JSON.parse(readFileSync(new URL('../../references/check-registry.json', import.meta.url)));
7
8
  const knownFamilies = new Map(registry.families.map(x => [x.family_id, x]));
@@ -12,6 +13,27 @@ function unique(values, label) {
12
13
  }
13
14
 
14
15
  const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
16
+
17
+ /**
18
+ * Whether the run's own fixed conditions decide a family's applicability, and the condition code that names the reason when it is not
19
+ * applicable; null when no fixed condition decides it (the owner's or the model's decision then stands). Read from the registry, never from
20
+ * a list of family names: no inapplicable condition at all means always applicable; `public_only` plus `no_public_index_target` follows the
21
+ * public index targets (and the target engines, where `no_target_engine` is also a condition); an engine-bound family with a `*_not_target`
22
+ * condition follows its engines; `no_fixed_questions` follows the question panel. A test holds the registry flag and this rule together (T22 S2).
23
+ */
24
+ function fixedApplicability(definition, protocol, publicCount) {
25
+ const conditions = definition.na_conditions ?? [];
26
+ if (!conditions.length) return { applicable: true, condition: null };
27
+ if (conditions.includes('no_fixed_questions')) return { applicable: protocol.question_panel.length > 0, condition: 'no_fixed_questions' };
28
+ if (definition.public_only === true && conditions.includes('no_public_index_target')) {
29
+ const needsEngine = conditions.includes('no_target_engine');
30
+ return { applicable: publicCount > 0 && (!needsEngine || protocol.engines.length > 0),
31
+ condition: needsEngine && !protocol.engines.length ? 'no_target_engine' : 'no_public_index_target' };
32
+ }
33
+ const engineCondition = conditions.find(code => code.endsWith('_not_target'));
34
+ if (definition.engines?.length && engineCondition) return { applicable: definition.engines.some(engine => protocol.engines.includes(engine)), condition: engineCondition };
35
+ return null;
36
+ }
15
37
  const sameSet = (a, b) => a.length === b.length && a.every(value => b.includes(value));
16
38
 
17
39
  function stable(value) {
@@ -35,11 +57,16 @@ export function freezeProtocol(context) {
35
57
  const urls = p.page_types.flatMap(x => x.urls.map(u => u.normalized));
36
58
  unique(urls, 'normalized URLs');
37
59
  if (!urls.length) throw new ContractError('protocol requires selected public audit URLs');
60
+ const { render } = p;
61
+ unique(render.pages.map(x => x.normalized), 'render pages');
62
+ if (render.pages.length > limits().render_pages) throw new ContractError('render pages exceed the render page limit');
63
+ if (render.pages.some(x => !urls.includes(x.normalized))) throw new ContractError('render page is not a selected URL');
64
+ if (!Number.isInteger(render.max_render_requests) || render.max_render_requests > limits().max_render_requests)
65
+ throw new ContractError('render request limit is not a whole number within the render ceiling');
38
66
  const resources = p.auxiliary_resources ?? [];
39
67
  unique(resources.map(x => x.normalized), 'auxiliary resources');
40
68
  if (resources.some(x => urls.includes(x.normalized))) throw new ContractError('auxiliary resource duplicates a selected URL');
41
- const requestLimit = Math.min(context.scope.max_urls ?? 50, 50);
42
- if (urls.length + resources.length > requestLimit) throw new ContractError('selected pages and auxiliary resources exceed request scope');
69
+ if (urls.length + resources.length > requestLimit(context.scope)) throw new ContractError('selected pages and auxiliary resources exceed request scope');
43
70
  for (const resource of resources) {
44
71
  for (const url of [resource.original, resource.normalized]) {
45
72
  try { assertUrlInScope(url, context.scope); }
@@ -66,7 +93,7 @@ export function freezeProtocol(context) {
66
93
  for (const family of p.families) {
67
94
  const definition = knownFamilies.get(family.family_id);
68
95
  if (!definition) throw new ContractError(`unregistered family: ${family.family_id}`);
69
- const requiredUrls = ['index-policy', 'search-crawl-policy', 'discovery'].includes(family.family_id) ? publicUrls : urls;
96
+ const requiredUrls = publicOnlyFamily(family.family_id) ? publicUrls : urls;
70
97
  if (family.urls) {
71
98
  unique(family.urls, `${family.family_id}.urls`);
72
99
  if (definition.unit_scope === 'site' || !sameSet(family.urls, requiredUrls))
@@ -78,23 +105,18 @@ export function freezeProtocol(context) {
78
105
  if (definition.unit_scope !== 'engine_url' || !sameSet(family.engines, requiredEngines))
79
106
  throw new ContractError(`${family.family_id}: target engine units cannot be omitted`);
80
107
  }
81
- const expected = family.family_id === 'fetch-response' ? true :
82
- ['index-policy', 'discovery'].includes(family.family_id) ? publicUrls.length > 0 :
83
- family.family_id === 'search-crawl-policy' ? publicUrls.length > 0 && p.engines.length > 0 :
84
- family.family_id === 'question-coverage' ? p.question_panel.length > 0 :
85
- family.family_id === 'naver-markup' ? p.engines.includes('naver') : null;
86
- if (expected !== null && family.applicable !== expected)
108
+ // A family that only matters for indexed pages has no unit when every selected page is a non-index target.
109
+ if (publicOnlyFamily(family.family_id) && family.applicable && !publicUrls.length)
110
+ throw new ContractError(`${family.family_id}: applicable family has no public index target`);
111
+ const fixed = fixedApplicability(definition, p, publicUrls.length);
112
+ if (fixed && family.applicable !== fixed.applicable)
87
113
  throw new ContractError(`${family.family_id}: applicability contradicts fixed URL/engine conditions`);
88
114
  if (family.applicable) {
89
115
  if (family.applicability_decision) throw new ContractError(`${family.family_id}: decision only allowed when inapplicable`);
90
116
  } else {
91
117
  const decision = validateApplicabilityDecision(family.applicability_decision,
92
118
  definition.na_conditions, `${family.family_id}.applicability_decision`);
93
- const automaticCondition = family.family_id === 'search-crawl-policy' ?
94
- (p.engines.length ? 'no_public_index_target' : 'no_target_engine') :
95
- ['index-policy', 'discovery'].includes(family.family_id) ? 'no_public_index_target' :
96
- family.family_id === 'question-coverage' ? 'no_fixed_questions' :
97
- family.family_id === 'naver-markup' ? 'naver_not_target' : null;
119
+ const automaticCondition = fixed?.condition ?? null;
98
120
  if (automaticCondition && decision.condition !== automaticCondition)
99
121
  throw new ContractError(`${family.family_id}: decision conflicts with fixed conditions`);
100
122
  }
@@ -103,6 +125,7 @@ export function freezeProtocol(context) {
103
125
  }
104
126
  const sorted = { ...p,
105
127
  ...(p.auxiliary_resources ? { auxiliary_resources: [...resources].sort((a, b) => compare(a.normalized, b.normalized)) } : {}),
128
+ render: { ...render, pages: [...render.pages].sort((a, b) => compare(a.normalized, b.normalized)) },
106
129
  engines: [...p.engines].sort(),
107
130
  families: [...p.families].sort((a, b) => compare(a.family_id, b.family_id)).map(f => ({ ...f,
108
131
  ...(f.urls ? { urls: [...f.urls].sort() } : {}),
@@ -115,6 +138,7 @@ export function freezeProtocol(context) {
115
138
  const hashInput = structuredClone(protocol);
116
139
  for (const type of hashInput.page_types) for (const url of type.urls) if (url.indexing_decision)
117
140
  url.indexing_decision = { condition: url.indexing_decision.condition };
141
+ for (const url of hashInput.render.pages) if (url.indexing_decision) url.indexing_decision = { condition: url.indexing_decision.condition };
118
142
  for (const family of hashInput.families) if (family.applicability_decision)
119
143
  family.applicability_decision = { condition: family.applicability_decision.condition };
120
144
  if (hashInput.target_identity) {