optbrew 0.1.2 → 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 (119) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/AGENT_INSTALL.md +94 -0
  5. package/CHANGELOG.md +74 -1
  6. package/README.ko.md +129 -105
  7. package/README.md +129 -105
  8. package/THIRD_PARTY_NOTICES.md +88 -0
  9. package/docs/platforms.md +45 -8
  10. package/docs/release.md +37 -4
  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 +29 -9
  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 +409 -49
  24. package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +4 -4
  25. package/skills/optbrew/references/ai-crawlers.json +1 -1
  26. package/skills/optbrew/references/ai-crawlers.md +1 -1
  27. package/skills/optbrew/references/api.md +6 -4
  28. package/skills/optbrew/references/check-registry.json +16 -16
  29. package/skills/optbrew/references/chrome-paths.json +31 -0
  30. package/skills/optbrew/references/endpoints.json +10 -0
  31. package/skills/optbrew/references/harness-signals.json +5 -0
  32. package/skills/optbrew/references/intake.md +76 -5
  33. package/skills/optbrew/references/judging.md +233 -0
  34. package/skills/optbrew/references/limits.json +26 -0
  35. package/skills/optbrew/references/measurement.md +1 -1
  36. package/skills/optbrew/references/messages.ko.json +170 -0
  37. package/skills/optbrew/references/report.md +18 -6
  38. package/skills/optbrew/references/routing.md +2 -2
  39. package/skills/optbrew/references/runbooks/anthropic-api-key.md +34 -0
  40. package/skills/optbrew/references/runbooks/chrome-install.md +56 -0
  41. package/skills/optbrew/references/runbooks/cwv-site-collection.md +38 -0
  42. package/skills/optbrew/references/runbooks/google-api-key.md +51 -0
  43. package/skills/optbrew/references/runbooks/gsc-export.md +32 -0
  44. package/skills/optbrew/references/runbooks/index.json +18 -0
  45. package/skills/optbrew/references/runbooks/key-set-claude-cloud.md +32 -0
  46. package/skills/optbrew/references/runbooks/key-set-codex.md +33 -0
  47. package/skills/optbrew/references/runbooks/key-set-local.md +40 -0
  48. package/skills/optbrew/references/runbooks/naver-searchadvisor-export.md +32 -0
  49. package/skills/optbrew/references/runbooks/network-claude-cloud-custom.md +33 -0
  50. package/skills/optbrew/references/runbooks/network-codex-approval.md +26 -0
  51. package/skills/optbrew/references/runbooks/network-codex-config.md +36 -0
  52. package/skills/optbrew/references/runbooks/seranking.md +38 -0
  53. package/skills/optbrew/references/schemas/contract.schema.json +42 -15
  54. package/skills/optbrew/references/scoring.md +40 -5
  55. package/skills/optbrew/references/source-analysis.md +40 -0
  56. package/skills/optbrew/references/versions.json +9 -0
  57. package/skills/optbrew/scripts/adapters/anthropic.mjs +5 -5
  58. package/skills/optbrew/scripts/adapters/imports.mjs +138 -1
  59. package/skills/optbrew/scripts/audit.mjs +10 -96
  60. package/skills/optbrew/scripts/cli.mjs +5 -2
  61. package/skills/optbrew/scripts/core/bundle.mjs +30 -13
  62. package/skills/optbrew/scripts/core/capabilities.mjs +8 -4
  63. package/skills/optbrew/scripts/core/cdp.mjs +105 -0
  64. package/skills/optbrew/scripts/core/checks/canonical-consistency.mjs +56 -0
  65. package/skills/optbrew/scripts/core/checks/cwv-field.mjs +77 -0
  66. package/skills/optbrew/scripts/core/checks/discovery.mjs +43 -0
  67. package/skills/optbrew/scripts/core/checks/document-title.mjs +62 -0
  68. package/skills/optbrew/scripts/core/checks/entity-identity.mjs +31 -0
  69. package/skills/optbrew/scripts/core/checks/fact-consistency.mjs +31 -0
  70. package/skills/optbrew/scripts/core/checks/fetch-response.mjs +18 -0
  71. package/skills/optbrew/scripts/core/checks/index-policy.mjs +16 -0
  72. package/skills/optbrew/scripts/core/checks/index.mjs +18 -0
  73. package/skills/optbrew/scripts/core/checks/internal-navigation.mjs +67 -0
  74. package/skills/optbrew/scripts/core/checks/locale-links.mjs +55 -0
  75. package/skills/optbrew/scripts/core/checks/naver-markup.mjs +21 -0
  76. package/skills/optbrew/scripts/core/checks/observation.mjs +37 -0
  77. package/skills/optbrew/scripts/core/checks/prepare.mjs +103 -0
  78. package/skills/optbrew/scripts/core/checks/render-content.mjs +104 -0
  79. package/skills/optbrew/scripts/core/checks/search-crawl-policy.mjs +29 -0
  80. package/skills/optbrew/scripts/core/checks/structured-data-truth.mjs +46 -0
  81. package/skills/optbrew/scripts/core/chrome-temp.mjs +68 -0
  82. package/skills/optbrew/scripts/core/chrome.mjs +546 -0
  83. package/skills/optbrew/scripts/core/collector.mjs +65 -59
  84. package/skills/optbrew/scripts/core/compare.mjs +18 -1
  85. package/skills/optbrew/scripts/core/config.mjs +31 -0
  86. package/skills/optbrew/scripts/core/contracts.mjs +31 -2
  87. package/skills/optbrew/scripts/core/cwv.mjs +100 -0
  88. package/skills/optbrew/scripts/core/derive.mjs +10 -161
  89. package/skills/optbrew/scripts/core/evidence-store.mjs +112 -0
  90. package/skills/optbrew/scripts/core/facts.mjs +203 -19
  91. package/skills/optbrew/scripts/core/findings.mjs +19 -1
  92. package/skills/optbrew/scripts/core/html.mjs +41 -3
  93. package/skills/optbrew/scripts/core/index-targets.mjs +118 -0
  94. package/skills/optbrew/scripts/core/judgments.mjs +675 -0
  95. package/skills/optbrew/scripts/core/keys.mjs +86 -0
  96. package/skills/optbrew/scripts/core/link-graph.mjs +93 -0
  97. package/skills/optbrew/scripts/core/messages.mjs +23 -0
  98. package/skills/optbrew/scripts/core/metrics.mjs +4 -2
  99. package/skills/optbrew/scripts/core/migration.mjs +37 -0
  100. package/skills/optbrew/scripts/core/page-select.mjs +136 -0
  101. package/skills/optbrew/scripts/core/probe.mjs +83 -0
  102. package/skills/optbrew/scripts/core/protocol.mjs +38 -14
  103. package/skills/optbrew/scripts/core/render.mjs +160 -0
  104. package/skills/optbrew/scripts/core/report-compose.mjs +377 -0
  105. package/skills/optbrew/scripts/core/report-html.mjs +198 -0
  106. package/skills/optbrew/scripts/core/report-page.mjs +14 -0
  107. package/skills/optbrew/scripts/core/report.mjs +357 -131
  108. package/skills/optbrew/scripts/core/runbooks.mjs +63 -0
  109. package/skills/optbrew/scripts/core/scope.mjs +23 -5
  110. package/skills/optbrew/scripts/core/score.mjs +12 -4
  111. package/skills/optbrew/scripts/core/secrets.mjs +2 -0
  112. package/skills/optbrew/scripts/core/stages.mjs +694 -0
  113. package/skills/optbrew/scripts/core/transport.mjs +157 -0
  114. package/skills/optbrew/scripts/core/weights.mjs +2 -1
  115. package/skills/optbrew/scripts/key.mjs +90 -0
  116. package/skills/optbrew/scripts/preflight.mjs +176 -0
  117. package/skills/optbrew/scripts/run.mjs +81 -0
  118. package/skills/optbrew/source-lock.json +1 -1
  119. package/source-lock.json +1 -1
@@ -3,9 +3,9 @@ import { createHash } from 'node:crypto';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { reserveCost, settleCost } from '../core/cost-ledger.mjs';
6
+ import { endpoints, limits } from '../core/config.mjs';
6
7
 
7
- const ENDPOINT = new URL('https://api.anthropic.com/v1/messages');
8
- const VERSION = '2023-06-01';
8
+ const ENDPOINT = new URL(endpoints().anthropic_messages);
9
9
  // Official model overview, checked 2026-10-14. Accepted requests reserve the full context window plus max output.
10
10
  const MODELS = new Map(['claude-fable-5-1', 'claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5']
11
11
  .map(id => [id, { contextTokens: 1_000_000, maxOutputTokens: 128_000 }]));
@@ -16,7 +16,7 @@ async function nodeTransport({ headers, body, timeoutMs, signal }) {
16
16
  const chunks = []; let size = 0;
17
17
  res.on('data', chunk => {
18
18
  size += chunk.length;
19
- if (size > 5242880) { req.destroy(new Error('provider response too large')); return; }
19
+ if (size > limits().max_bytes) { req.destroy(new Error('provider response too large')); return; }
20
20
  chunks.push(chunk);
21
21
  });
22
22
  res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
@@ -76,12 +76,12 @@ export async function requestAnthropic({ context, request, env = process.env, tr
76
76
  try { reservation = await reserveCost(ledgerPath, { provider: 'anthropic', upperBounds, approvalRef: decision.approval_ref, runId: context.run_id }); }
77
77
  catch { return observation(request, 'cost_reservation_failed'); }
78
78
  const controller = new AbortController();
79
- const timer = setTimeout(() => controller.abort(), 20000);
79
+ const timer = setTimeout(() => controller.abort(), limits().timeout_ms);
80
80
  let response;
81
81
  try {
82
82
  response = await Promise.race([
83
83
  transport({ url: ENDPOINT, headers: { 'content-type': 'application/json', 'x-api-key': env.ANTHROPIC_API_KEY,
84
- 'anthropic-version': VERSION }, body, timeoutMs: 20000, signal: controller.signal }),
84
+ 'anthropic-version': endpoints().anthropic_version }, body, timeoutMs: limits().timeout_ms, signal: controller.signal }),
85
85
  new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(new Error('timeout')), { once: true })),
86
86
  ]);
87
87
  } catch {
@@ -1,4 +1,141 @@
1
+ import { inflateRawSync } from 'node:zlib';
1
2
  import { validateEntity, ContractError } from '../core/contracts.mjs';
3
+ import { limits, versions } from '../core/config.mjs';
4
+
5
+ // ZIP record signatures and the two compression methods Search Console's export uses.
6
+ const ZIP_END = 0x06054b50;
7
+ const ZIP_CENTRAL = 0x02014b50;
8
+ const ZIP_STORED = 0;
9
+ const ZIP_DEFLATED = 8;
10
+ const ZIP_END_SIZE = 22;
11
+ const ZIP_CENTRAL_SIZE = 46;
12
+ const ZIP_LOCAL_SIZE = 30;
13
+ // Search Console dates its performance report in Pacific time; Naver Search Advisor in Korea time.
14
+ const GSC_TIMEZONE = 'America/Los_Angeles';
15
+ const NAVER_TIMEZONE = 'Asia/Seoul';
16
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
17
+ const NAVER_PERIOD = /(\d{4})[.-](\d{2})[.-](\d{2})\s*~\s*(\d{4})[.-](\d{2})[.-](\d{2})/;
18
+ // Column names as the English and Korean screens print them.
19
+ const DATE_COLUMNS = ['date', '날짜', '일자'];
20
+ const CLICK_COLUMNS = ['clicks', '클릭수', '클릭'];
21
+ const IMPRESSION_COLUMNS = ['impressions', '노출수', '노출'];
22
+
23
+ /** A file the owner handed over that cannot be used; `reason` is a short code the report shows next to the missing numbers. */
24
+ const unusable = reason => Object.assign(new Error(reason), { reason });
25
+
26
+ /** The entries of a ZIP archive, read from its central directory. */
27
+ function zipEntries(bytes) {
28
+ try {
29
+ let end = bytes.length - ZIP_END_SIZE;
30
+ while (end >= 0 && bytes.readUInt32LE(end) !== ZIP_END) end--;
31
+ if (end < 0) throw unusable('export_not_a_zip');
32
+ let offset = bytes.readUInt32LE(end + 16);
33
+ const entries = [];
34
+ for (let count = bytes.readUInt16LE(end + 10); count > 0; count--) {
35
+ if (bytes.readUInt32LE(offset) !== ZIP_CENTRAL) throw unusable('export_zip_damaged');
36
+ const [nameLength, extraLength, commentLength] = [28, 30, 32].map(at => bytes.readUInt16LE(offset + at));
37
+ entries.push({ name: bytes.toString('utf8', offset + ZIP_CENTRAL_SIZE, offset + ZIP_CENTRAL_SIZE + nameLength), method: bytes.readUInt16LE(offset + 10),
38
+ compressed: bytes.readUInt32LE(offset + 20), original: bytes.readUInt32LE(offset + 24), local: bytes.readUInt32LE(offset + 42) });
39
+ offset += ZIP_CENTRAL_SIZE + nameLength + extraLength + commentLength;
40
+ }
41
+ return entries;
42
+ } catch (error) { throw error.reason ? error : unusable('export_zip_damaged'); }
43
+ }
44
+
45
+ /** The text of one archive entry; an entry larger than the collection byte limit is refused, so a small file cannot expand into memory. */
46
+ function zipText(bytes, entry) {
47
+ if (entry.original > limits().max_bytes) throw unusable('export_file_too_large');
48
+ try {
49
+ const start = entry.local + ZIP_LOCAL_SIZE + bytes.readUInt16LE(entry.local + 26) + bytes.readUInt16LE(entry.local + 28);
50
+ const data = bytes.subarray(start, start + entry.compressed);
51
+ if (entry.method === ZIP_STORED) return data.toString('utf8');
52
+ if (entry.method === ZIP_DEFLATED) return inflateRawSync(data, { maxOutputLength: limits().max_bytes }).toString('utf8');
53
+ } catch { throw unusable('export_zip_damaged'); }
54
+ throw unusable('export_zip_method_unsupported');
55
+ }
56
+
57
+ /** Rows of a CSV text (quoted cells, doubled quotes, CRLF), without a leading byte-order mark. */
58
+ function csvRows(text) {
59
+ const rows = [];
60
+ let row = [], cell = '', quoted = false;
61
+ const input = text.replace(/^/, '');
62
+ for (let i = 0; i < input.length; i++) {
63
+ const char = input[i];
64
+ if (quoted) {
65
+ if (char === '"' && input[i + 1] === '"') { cell += '"'; i++; }
66
+ else if (char === '"') quoted = false;
67
+ else cell += char;
68
+ } else if (char === '"') quoted = true;
69
+ else if (char === ',') { row.push(cell); cell = ''; }
70
+ else if (char === '\n' || char === '\r') {
71
+ if (char === '\r' && input[i + 1] === '\n') i++;
72
+ row.push(cell); cell = '';
73
+ if (row.some(value => value !== '')) rows.push(row);
74
+ row = [];
75
+ } else cell += char;
76
+ }
77
+ row.push(cell);
78
+ if (row.some(value => value !== '')) rows.push(row);
79
+ return rows;
80
+ }
81
+
82
+ const column = (header, names) => header.findIndex(name => names.includes(name.trim().toLowerCase()));
83
+ const count = value => { const number = Number(String(value).replace(/,/g, '')); return Number.isFinite(number) ? number : null; };
84
+
85
+ /** Clicks and impressions as totals, with ctr as their ratio. */
86
+ function trafficRows(clicks, impressions, label) {
87
+ return [
88
+ { metric_id: 'clicks', value: clicks, provider_definition: `${label}: clicks summed over the rows of the export` },
89
+ { metric_id: 'impressions', value: impressions, provider_definition: `${label}: impressions summed over the rows of the export` },
90
+ ...(impressions > 0 ? [{ metric_id: 'ctr', value: clicks / impressions, numerator: clicks, denominator: impressions,
91
+ provider_definition: `${label}: clicks divided by impressions over the rows of the export` }] : []),
92
+ ];
93
+ }
94
+
95
+ /** Search Console's CSV export: the archive's daily table (date, clicks, impressions) gives the totals and the period. */
96
+ function readGsc(bytes) {
97
+ const archive = zipEntries(bytes);
98
+ for (const entry of archive.filter(x => x.name.toLowerCase().endsWith('.csv'))) {
99
+ const [header, ...body] = csvRows(zipText(bytes, entry));
100
+ if (!header) continue;
101
+ const [date, clicks, impressions] = [DATE_COLUMNS, CLICK_COLUMNS, IMPRESSION_COLUMNS].map(names => column(header, names));
102
+ if (date < 0 || clicks < 0 || impressions < 0 || !body.length || !body.every(row => ISO_DATE.test(row[date]))) continue;
103
+ const days = body.map(row => row[date]).sort();
104
+ const totals = body.map(row => [count(row[clicks]), count(row[impressions])]);
105
+ if (totals.some(pair => pair.includes(null))) throw unusable('export_numbers_unreadable');
106
+ return { provider: 'gsc', window: { start: days[0], end: days.at(-1), timezone: GSC_TIMEZONE }, sample_selection: { kind: 'all' },
107
+ rows: trafficRows(totals.reduce((sum, pair) => sum + pair[0], 0), totals.reduce((sum, pair) => sum + pair[1], 0), 'Search Console performance report') };
108
+ }
109
+ throw unusable('export_table_not_found');
110
+ }
111
+
112
+ /** Naver Search Advisor's pasted table: tab- or space-separated, a header with the click and impression columns, and the period printed above it. */
113
+ function readNaver(bytes) {
114
+ const text = bytes.toString('utf8').replace(/^/, '');
115
+ const period = NAVER_PERIOD.exec(text);
116
+ if (!period) throw unusable('export_period_missing');
117
+ const lines = text.split(/\r?\n/).map(line => line.split(/\t| {2,}/).map(cell => cell.trim()).filter(Boolean));
118
+ const at = lines.findIndex(cells => column(cells, CLICK_COLUMNS) >= 0 && column(cells, IMPRESSION_COLUMNS) >= 0);
119
+ if (at < 0) throw unusable('export_table_not_found');
120
+ const [clicks, impressions] = [CLICK_COLUMNS, IMPRESSION_COLUMNS].map(names => column(lines[at], names));
121
+ const body = lines.slice(at + 1).filter(cells => cells.length > Math.max(clicks, impressions) && count(cells[clicks]) !== null && count(cells[impressions]) !== null);
122
+ if (!body.length) throw unusable('export_numbers_unreadable');
123
+ const [start, end] = [period.slice(1, 4), period.slice(4, 7)].map(parts => parts.join('-'));
124
+ // A pasted top-N table lists only its leading rows, so its totals say nothing about the whole site.
125
+ return { provider: 'naver', window: { start, end, timezone: NAVER_TIMEZONE }, sample_selection: { kind: 'unknown' },
126
+ rows: trafficRows(body.reduce((sum, cells) => sum + count(cells[clicks]), 0), body.reduce((sum, cells) => sum + count(cells[impressions]), 0), 'Naver Search Advisor table as pasted') };
127
+ }
128
+
129
+ /**
130
+ * The numbers an owner's export holds: `gsc` is the Search Console CSV export (a zip), `naver` the table pasted from Search
131
+ * Advisor as text. Returns `{ provider, window, sample_selection, rows }` for `normalizeImport`; a file that cannot be read
132
+ * throws an error whose `reason` names why, so the caller can report the gap instead of failing.
133
+ */
134
+ export function readOwnerExport(kind, bytes) {
135
+ if (kind === 'gsc') return readGsc(bytes);
136
+ if (kind === 'naver') return readNaver(bytes);
137
+ throw unusable('export_kind_unknown');
138
+ }
2
139
 
3
140
  const providers = new Set(['gsc', 'ga4', 'google_ai', 'bing_ai', 'naver', 'se_ranking']);
4
141
  const day = 86400000;
@@ -61,7 +198,7 @@ export function normalizeImport(provider, rows, metadata) {
61
198
  const dimensions = ['property', 'hostname', 'path', 'query', 'search_type', 'device', 'country', 'aggregation'];
62
199
  const dimension = key => row.filters?.[key] ?? metadata[key] ?? 'unknown';
63
200
  const export_scope = Object.fromEntries(dimensions.map(key => [key, metadata[key] ?? 'unknown']));
64
- const observation = { schema_version: '1.0.0', metric_id: `${provider}:${row.metric_id}`,
201
+ const observation = { schema_version: versions().contract_schema, metric_id: `${provider}:${row.metric_id}`,
65
202
  value_state, value, numerator: row.numerator ?? null, denominator: row.denominator ?? null,
66
203
  definition: row.definition ?? row.provider_definition ?? metadata.provider_definition,
67
204
  filters: { ...(row.filters ?? {}), provider, property: dimension('property'),
@@ -1,111 +1,25 @@
1
1
  #!/usr/bin/env node
2
- import { createHash } from 'node:crypto';
3
- import { spawn } from 'node:child_process';
4
2
  import { realpathSync } from 'node:fs';
5
- import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { readFile } from 'node:fs/promises';
6
4
  import path from 'node:path';
7
5
  import { fileURLToPath } from 'node:url';
8
6
  import { validateEntity } from './core/contracts.mjs';
9
- import { collect, declaredRobotsIndex } from './core/collector.mjs';
10
- import { auditCollected } from '../modules/naver-searchadvisor/scripts/audit.mjs';
11
- import { deriveObservations } from './core/derive.mjs';
7
+ import { runStages, stages } from './core/stages.mjs';
12
8
 
13
- const magnusScript = fileURLToPath(new URL('../modules/magnus-seo/scripts/aeo_audit.py', import.meta.url));
14
9
  const supported = new Set(['naver-searchadvisor', 'magnus-seo']);
15
-
16
- async function magnus(artifact) {
17
- return new Promise(resolve => {
18
- const child = spawn(process.platform === 'win32' ? 'python' : 'python3', [magnusScript, '--stdin', '--source-url', artifact.final_url, '--json'],
19
- { stdio: ['pipe', 'pipe', 'pipe'] });
20
- const chunks = []; let bytes = 0;
21
- child.stdout.on('data', chunk => { bytes += chunk.length; if (bytes > 5242880) child.kill(); else chunks.push(chunk); });
22
- child.on('error', () => resolve({ status: 'unavailable', reason: 'python_unavailable' }));
23
- child.on('close', code => {
24
- if (code !== 0) return resolve({ status: 'unavailable', reason: 'python_audit_failed' });
25
- try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
26
- catch { resolve({ status: 'unavailable', reason: 'python_output_invalid' }); }
27
- });
28
- child.stdin.on('error', () => {});
29
- child.stdin.end(artifact.body);
30
- });
31
- }
10
+ // The collection and the derivation of the collector verdicts: the stages `run.mjs` also runs, without rendering, field data or scoring.
11
+ const AUDIT_STAGES = ['collect', 'derive'];
32
12
 
33
13
  /** Persist collector bytes and offline module output in the same run evidence directory. */
34
14
  export async function runAudit({ context, modules, outputDir, transport, resolve }) {
35
15
  validateEntity('RunContext', context);
36
16
  if (!Array.isArray(modules) || modules.some(x => !supported.has(x)) || !outputDir) throw new Error('unsupported modules or output directory');
37
- const evidenceDir = path.join(outputDir, 'evidence');
38
- await mkdir(evidenceDir, { recursive: true });
39
- try { await access(path.join(evidenceDir, 'collection.json')); throw new Error('run evidence index already exists'); }
40
- catch (error) { if (error.code !== 'ENOENT') throw error; }
41
- const collection = await collect(context, { transport, resolve });
42
- const artifacts = [];
43
- const responses = [];
44
- for (const artifact of collection.artifacts) {
45
- const { body, ...saved } = artifact;
46
- if (artifact.content_hash) {
47
- const name = `${artifact.content_hash}.${artifact.capability === 'raw_html' ? 'html' : 'bin'}`;
48
- const ref = path.join(evidenceDir, name);
49
- try { await writeFile(ref, body, { flag: 'wx' }); }
50
- catch (error) {
51
- if (error.code !== 'EEXIST' || !Buffer.from(await readFile(ref)).equals(body)) throw error;
52
- }
53
- saved.raw_html_ref = artifact.capability === 'raw_html' ? ref : null;
54
- saved.evidence_ref = ref;
55
- } else saved.evidence_ref = path.join(evidenceDir, 'collection.json');
56
- if (Number.isInteger(artifact.status)) {
57
- // Hash the final URL, redirect chain and response header lines next to the raw body so the report replays from them.
58
- const bytes = Buffer.from(`${JSON.stringify({ record_version: '1', requested_url: artifact.requested_url,
59
- normalized_url: artifact.normalized_url, final_url: artifact.final_url, status: artifact.status,
60
- redirect_chain: artifact.redirect_chain, header_lines: artifact.header_lines,
61
- content_hash: artifact.content_hash, capability: artifact.capability }, null, 2)}\n`);
62
- const content_hash = createHash('sha256').update(bytes).digest('hex');
63
- const ref = path.join(evidenceDir, `${content_hash}.response.json`);
64
- try { await writeFile(ref, bytes, { flag: 'wx' }); }
65
- catch (error) {
66
- if (error.code !== 'EEXIST' || !Buffer.from(await readFile(ref)).equals(bytes)) throw error;
67
- }
68
- saved.response_ref = ref;
69
- responses.push({ normalized_url: artifact.normalized_url, evidence_ref: ref, content_hash });
70
- }
71
- artifacts.push(saved);
72
- }
73
- const module_outputs = Object.fromEntries(modules.map(x => [x, []]));
74
- const declaredRobots = new Set((context.protocol.auxiliary_resources ?? [])
75
- .filter(resource => resource.kind === 'robots').map(resource => resource.normalized));
76
- const selectedUrls = new Set(context.protocol.page_types.flatMap(type => type.urls.map(x => x.normalized)));
77
- for (const artifact of collection.artifacts) {
78
- if (!selectedUrls.has(artifact.normalized_url) || artifact.capability !== 'raw_html' || artifact.status !== 200) continue;
79
- const html = artifact.body.toString('utf8');
80
- if (modules.includes('naver-searchadvisor')) {
81
- const robotsIndex = declaredRobotsIndex(collection.artifacts, declaredRobots, artifact.final_url);
82
- const robots = robotsIndex < 0 ? null : collection.artifacts[robotsIndex];
83
- const robotsEvidenceRef = robots?.status === 200 ? artifacts[robotsIndex].evidence_ref : null;
84
- const output = auditCollected({ artifact, html, engines: context.protocol.engines,
85
- robotsArtifact: robots ? { ...robots, body: robots.body.toString('utf8') } : null });
86
- output.robots_evidence_ref = robotsEvidenceRef;
87
- module_outputs['naver-searchadvisor'].push(output);
88
- }
89
- if (modules.includes('magnus-seo')) module_outputs['magnus-seo'].push(await magnus(artifact));
90
- }
91
- // Verdicts come from one derivation shared with the report replay.
92
- const bodies = new Map(collection.artifacts.map(x => [x.normalized_url, x.body]));
93
- const observations = deriveObservations({ context, artifacts, collection,
94
- textOf: x => bodies.get(x.normalized_url)?.toString('utf8') ?? null });
95
- const result = { context_ref: context.evidence_ref, artifacts, observations, module_outputs,
96
- evidence_ref: path.join(evidenceDir, 'evidence-index.json'), discovered_count: collection.discovered_count,
97
- evaluated_count: collection.evaluated_count, request_count: collection.request_count, skipped: collection.skipped, limit_reached: collection.limit_reached };
98
- await writeFile(path.join(evidenceDir, 'collection.json'), `${JSON.stringify({ artifacts, discovered_count: collection.discovered_count,
99
- evaluated_count: collection.evaluated_count, request_count: collection.request_count, skipped: collection.skipped, limit_reached: collection.limit_reached }, null, 2)}\n`, { flag: 'wx' });
100
- await writeFile(path.join(evidenceDir, 'ledger.json'), `${JSON.stringify({ entries: collection.ledger, limit: Math.min(context.scope?.max_urls ?? 50, 50) }, null, 2)}\n`, { flag: 'wx' });
101
- await writeFile(path.join(evidenceDir, 'modules.json'), `${JSON.stringify(module_outputs, null, 2)}\n`, { flag: 'wx' });
102
- await writeFile(path.join(evidenceDir, 'observations.json'), `${JSON.stringify(observations, null, 2)}\n`, { flag: 'wx' });
103
- await writeFile(result.evidence_ref, `${JSON.stringify({ run_id: context.run_id, context_ref: context.evidence_ref,
104
- collection_ref: path.join(evidenceDir, 'collection.json'), module_output_ref: path.join(evidenceDir, 'modules.json'),
105
- observations_ref: path.join(evidenceDir, 'observations.json'), ledger_ref: path.join(evidenceDir, 'ledger.json'), modules,
106
- raw: artifacts.map(x => ({ normalized_url: x.normalized_url, evidence_ref: x.evidence_ref, content_hash: x.content_hash })),
107
- responses }, null, 2)}\n`, { flag: 'wx' });
108
- return result;
17
+ const parts = stages().filter(stage => AUDIT_STAGES.includes(stage.name));
18
+ const { state } = await runStages(parts, { context, modules, out: outputDir, route: { transport, resolve } }, {});
19
+ const { audit } = state;
20
+ return { context_ref: context.evidence_ref, artifacts: audit.artifacts, observations: state.observations, module_outputs: audit.module_outputs,
21
+ evidence_ref: state.store.indexRef, discovered_count: audit.discovered_count, evaluated_count: audit.evaluated_count,
22
+ request_count: audit.request_count, skipped: audit.skipped, limit_reached: audit.limit_reached };
109
23
  }
110
24
 
111
25
  if (process.argv[1] && realpathSync(path.resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url';
5
5
  import { score } from './core/score.mjs';
6
6
  import { compare } from './core/compare.mjs';
7
7
  import { writeReport } from './core/report.mjs';
8
+ import { htmlReportWriter } from './core/report-page.mjs';
9
+ import { MigrationRequired } from './core/migration.mjs';
8
10
 
9
11
  /** Local calculation CLI. */
10
12
  export async function runCli(argv = process.argv.slice(2)) {
@@ -16,7 +18,8 @@ export async function runCli(argv = process.argv.slice(2)) {
16
18
  packet.evidence_root = path.resolve(path.dirname(input), packet.evidence_root ?? '.');
17
19
  const relative = path.relative(await realpath(path.dirname(input)), await realpath(packet.evidence_root));
18
20
  if (relative === '..' || relative.startsWith(`..${path.sep}`)) throw new Error('evidence root must stay under packet run directory');
19
- return writeReport(packet, path.resolve(argv[4]));
21
+ // The page is written beside the other two, as a run writes it. A packet does not record the guidance level, so the full steps are used.
22
+ return writeReport(packet, path.resolve(argv[4]), { renderHtml: htmlReportWriter({ level: 'beginner', target: packet.run_context?.target, synthetic: packet.data_class === 'synthetic' }) });
20
23
  } else if (argv.length === 5 && argv[0] === 'score' && argv[1] === '--input' && argv[3] === '--output' && argv[2] && argv[4]) {
21
24
  result = score(JSON.parse(await readFile(argv[2], 'utf8')));
22
25
  destination = path.resolve(argv[4]);
@@ -37,5 +40,5 @@ export async function runCli(argv = process.argv.slice(2)) {
37
40
  }
38
41
 
39
42
  if (process.argv[1] && realpathSync(path.resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
40
- runCli().catch(error => { process.stderr.write(`${error.name}: ${error.message}\n`); process.exitCode = 1; });
43
+ runCli().catch(error => { process.stderr.write(`${error instanceof MigrationRequired ? '' : `${error.name}: `}${error.message}\n`); process.exitCode = 1; });
41
44
  }
@@ -1,13 +1,27 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFile, readdir } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { versions } from './config.mjs';
4
5
 
5
6
  const digest = bytes => createHash('sha256').update(bytes).digest('hex');
7
+ /** Hash a file as committed: a checkout with core.autocrlf may turn LF into CRLF. A NUL byte marks a binary file, hashed as is. */
8
+ export const fileDigest = bytes => digest(bytes.includes(0) ? bytes : Buffer.from(bytes.toString('latin1').replaceAll('\r\n', '\n'), 'latin1'));
6
9
  const manifestName = 'bundle-manifest.json';
10
+ /**
11
+ * The files whose bytes decide a verdict or a score: the registry, profiles and scoring rules, the code that turns evidence into
12
+ * verdicts (derive, every check, the index-target, judgment, fact, link and page-selection rules, the vendored Naver audit) and the
13
+ * Korean catalog the verdict sentences come from. A bundle that changes any of them has a different `evaluation_hash` (T22 S1).
14
+ * `checks/*` is listed file by file; a test fails when a file is added to that folder and not here.
15
+ */
7
16
  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'];
17
+ 'references/scoring.md', 'references/measurement.md', 'references/messages.ko.json', 'scripts/core/score.mjs', 'scripts/core/weights.mjs',
18
+ 'scripts/core/contracts.mjs', 'scripts/core/compare.mjs', 'scripts/core/metrics.mjs', 'scripts/core/bootstrap.mjs',
19
+ 'scripts/core/derive.mjs', 'scripts/core/index-targets.mjs', 'scripts/core/judgments.mjs', 'scripts/core/facts.mjs',
20
+ 'scripts/core/link-graph.mjs', 'scripts/core/page-select.mjs', 'modules/naver-searchadvisor/scripts/audit.mjs',
21
+ ...['canonical-consistency', 'cwv-field', 'discovery', 'document-title', 'entity-identity', 'fact-consistency', 'fetch-response', 'index-policy',
22
+ 'index', 'internal-navigation', 'locale-links', 'naver-markup', 'observation', 'prepare', 'render-content', 'search-crawl-policy',
23
+ 'structured-data-truth'].map(name => `scripts/core/checks/${name}.mjs`)];
24
+ export const protocolSourcePaths = ['references/schemas/contract.schema.json', 'references/limits.json', 'scripts/core/protocol.mjs'];
11
25
 
12
26
  /** Match the package.json skill exclusions for generated Python cache files. */
13
27
  export function isDistributedBundlePath(relative) {
@@ -29,33 +43,36 @@ async function listFiles(root, relative = '') {
29
43
  return result.sort();
30
44
  }
31
45
 
32
- /** Hash every distributed file; the manifest never hashes itself. */
33
- export async function createBundleManifest({ bundleRoot, releaseVersion }) {
46
+ /** Hash every distributed file; the manifest never hashes itself. `sources` names the identity files; verification passes the lists a manifest recorded. */
47
+ export async function createBundleManifest({ bundleRoot, releaseVersion, sources = { evaluation: evaluationSourcePaths, protocol: protocolSourcePaths } }) {
34
48
  if (!/^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(releaseVersion)) throw new Error('invalid release version');
35
49
  const files = [];
36
50
  for (const name of await listFiles(bundleRoot)) {
37
- files.push({ path: name, sha256: digest(await readFile(path.join(bundleRoot, name))) });
51
+ files.push({ path: name, sha256: fileDigest(await readFile(path.join(bundleRoot, name))) });
38
52
  }
39
53
  // Synthetic install fixtures contain only SKILL.md; production release checks completeness.
40
54
  const selected = paths => paths.every(name => files.some(file => file.path === name))
41
55
  ? paths.map(name => files.find(file => file.path === name)) : [];
42
- const evaluation_sources = selected(evaluationSourcePaths);
43
- const protocol_sources = selected(protocolSourcePaths);
56
+ const evaluation_sources = selected(sources.evaluation);
57
+ const protocol_sources = selected(sources.protocol);
44
58
  const identity = evaluation_sources.length && protocol_sources.length ? {
45
- evaluation_version: '0.2.0', protocol_version: '0.1.0',
59
+ evaluation_version: versions().evaluation, protocol_version: versions().protocol,
46
60
  evaluation_sources, evaluation_hash: digest(JSON.stringify(evaluation_sources)),
47
61
  protocol_sources, protocol_hash: digest(JSON.stringify(protocol_sources)) } : {};
48
- return { schema_version: '1.0.0', release_version: releaseVersion,
62
+ return { schema_version: versions().bundle_manifest_schema, release_version: releaseVersion,
49
63
  ...identity, files, bundle_hash: digest(JSON.stringify(files)) };
50
64
  }
51
65
 
52
- /** Verify the exact allowlist, byte hashes, and aggregate hash. */
66
+ const recordedPaths = (list, fallback) => Array.isArray(list) ? list.map(file => file?.path) : fallback;
67
+
68
+ /** Verify the exact allowlist, byte hashes, and aggregate hash. Identity is recomputed over the source lists the manifest recorded, so an older bundle with fewer sources still verifies. */
53
69
  export async function verifyBundle({ bundleRoot, manifest }) {
54
70
  const errors = [];
55
71
  let actual;
56
- try { actual = await createBundleManifest({ bundleRoot, releaseVersion: manifest?.release_version }); }
72
+ try { actual = await createBundleManifest({ bundleRoot, releaseVersion: manifest?.release_version, sources: {
73
+ evaluation: recordedPaths(manifest?.evaluation_sources, evaluationSourcePaths), protocol: recordedPaths(manifest?.protocol_sources, protocolSourcePaths) } }); }
57
74
  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' });
75
+ if (!Array.isArray(manifest?.files) || manifest.schema_version !== versions().bundle_manifest_schema) errors.push({ path: manifestName, code: 'invalid_manifest' });
59
76
  const expected = new Map(Array.isArray(manifest?.files) ? manifest.files.map(f => [f.path, f.sha256]) : []);
60
77
  const found = new Map(actual.files.map(f => [f.path, f.sha256]));
61
78
  if (expected.size !== manifest?.files?.length) errors.push({ path: manifestName, code: 'duplicate_path' });
@@ -1,6 +1,10 @@
1
+ import { messageOf } from './messages.mjs';
2
+
1
3
  /** Match actual evidence surfaces to requested checks without inventing missing capability. */
2
4
  export function selectCapabilities(available, requirements) {
3
- const selected = []; const missing = []; const limitations = [];
5
+ const selected = []; const missing = []; const limitations = []; const limitation_messages = [];
6
+ // Each limitation keeps its English text and, at the same position, the catalog key and values of its Korean sentence.
7
+ const limit = (text, message) => { limitations.push(text); limitation_messages.push(message); };
4
8
  for (const requirement of requirements) {
5
9
  const names = requirement === 'structured_data' ? ['raw_html'] : [requirement];
6
10
  const candidate = available.find(item => names.includes(item.capability) && item.bounded === true &&
@@ -8,12 +12,12 @@ export function selectCapabilities(available, requirements) {
8
12
  if (!candidate || (requirement === 'bulk_crawl' && candidate.cost_enforcement !== 'code')) {
9
13
  missing.push(requirement);
10
14
  if (requirement === 'bulk_crawl' && available.some(item => item.capability === 'bulk_crawl'))
11
- limitations.push('bulk_crawl: boundary or cost enforcement is host_policy/unverified');
15
+ limit('bulk_crawl: boundary or cost enforcement is host_policy/unverified', messageOf('cost.bulk-crawl-unverified'));
12
16
  continue;
13
17
  }
14
18
  selected.push({ capability: candidate.capability, provider: candidate.provider, tool_ref: candidate.tool_ref,
15
19
  cost_enforcement: candidate.cost_enforcement });
16
- if (candidate.cost_enforcement === 'host_policy') limitations.push(`${candidate.provider}: host_policy cannot enforce paid cost in code`);
20
+ if (candidate.cost_enforcement === 'host_policy') limit(`${candidate.provider}: host_policy cannot enforce paid cost in code`, messageOf('cost.host-policy', { provider: candidate.provider }));
17
21
  }
18
- return { selected, missing, limitations };
22
+ return { selected, missing, limitations, limitation_messages };
19
23
  }
@@ -0,0 +1,105 @@
1
+ /** A DevTools transport is `{ send(text), close(), onmessage, onclose }`; the client sets the two callbacks. */
2
+
3
+ /** Chrome's `--remote-debugging-pipe`: JSON texts, each ended by a NUL byte, written to fd 3 and read from fd 4. */
4
+ export function pipeTransport(writable, readable) {
5
+ let pending = [];
6
+ const transport = {
7
+ onmessage: null,
8
+ onclose: null,
9
+ send: text => { writable.write(`${text}\0`); },
10
+ close: () => { writable.destroy(); readable.destroy(); },
11
+ };
12
+ // A write to a pipe that Chrome has closed raises EPIPE; the client already sees the close.
13
+ writable.on('error', () => {});
14
+ readable.on('error', () => {});
15
+ readable.on('data', chunk => {
16
+ pending.push(chunk);
17
+ if (chunk.indexOf(0) === -1) return;
18
+ const all = Buffer.concat(pending);
19
+ let start = 0;
20
+ for (let end = all.indexOf(0); end !== -1; end = all.indexOf(0, start)) {
21
+ const text = all.toString('utf8', start, end);
22
+ start = end + 1;
23
+ transport.onmessage?.(text);
24
+ }
25
+ pending = start < all.length ? [all.subarray(start)] : [];
26
+ });
27
+ readable.on('close', () => transport.onclose?.());
28
+ return transport;
29
+ }
30
+
31
+ /** The port variant: a browser-level WebSocket from `DevToolsActivePort`, using the global `WebSocket` (Node 22.4+). */
32
+ export function webSocketTransport(url) {
33
+ return new Promise((resolve, reject) => {
34
+ const socket = new WebSocket(url);
35
+ const transport = {
36
+ onmessage: null,
37
+ onclose: null,
38
+ send: text => { socket.send(text); },
39
+ close: () => { socket.close(); },
40
+ };
41
+ socket.addEventListener('message', event => transport.onmessage?.(String(event.data)));
42
+ socket.addEventListener('close', () => transport.onclose?.());
43
+ socket.addEventListener('open', () => resolve(transport), { once: true });
44
+ socket.addEventListener('error', () => reject(new Error('devtools websocket failed')), { once: true });
45
+ });
46
+ }
47
+
48
+ /**
49
+ * `CdpClient = { send(method, params?, sessionId?), on(event, fn) }` over one transport. `on` hands the handler
50
+ * `(params, sessionId)` and returns a function that removes it. Errors name the method, never the parameters.
51
+ */
52
+ export function createCdpClient(transport, { timeoutMs }) {
53
+ let nextId = 0;
54
+ const pending = new Map();
55
+ const handlers = new Map();
56
+ let closed = false;
57
+
58
+ transport.onmessage = text => {
59
+ let message;
60
+ try { message = JSON.parse(text); } catch { return; }
61
+ if (message.id !== undefined) {
62
+ const entry = pending.get(message.id);
63
+ if (!entry) return;
64
+ pending.delete(message.id);
65
+ clearTimeout(entry.timer);
66
+ if (message.error) entry.reject(new Error(`${entry.method}: ${message.error.message}`));
67
+ else entry.resolve(message.result ?? {});
68
+ } else if (message.method) {
69
+ for (const fn of [...handlers.get(message.method) ?? []]) fn(message.params ?? {}, message.sessionId);
70
+ }
71
+ };
72
+ transport.onclose = () => {
73
+ closed = true;
74
+ for (const [id, entry] of pending) {
75
+ pending.delete(id);
76
+ clearTimeout(entry.timer);
77
+ entry.reject(new Error(`${entry.method}: devtools connection closed`));
78
+ }
79
+ };
80
+
81
+ return {
82
+ send(method, params, sessionId) {
83
+ if (closed) return Promise.reject(new Error(`${method}: devtools connection closed`));
84
+ const id = ++nextId;
85
+ return new Promise((resolve, reject) => {
86
+ const timer = setTimeout(() => {
87
+ pending.delete(id);
88
+ reject(new Error(`${method}: devtools command timed out`));
89
+ }, timeoutMs);
90
+ pending.set(id, { method, resolve, reject, timer });
91
+ try { transport.send(JSON.stringify({ id, method, params, sessionId })); }
92
+ catch (error) {
93
+ pending.delete(id);
94
+ clearTimeout(timer);
95
+ reject(error);
96
+ }
97
+ });
98
+ },
99
+ on(event, fn) {
100
+ if (!handlers.has(event)) handlers.set(event, new Set());
101
+ handlers.get(event).add(fn);
102
+ return () => { handlers.get(event).delete(fn); };
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,56 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { visibleText } from '../html.mjs';
3
+ import { makeObservation, problem, unitKey } from './observation.mjs';
4
+
5
+ const FAMILY = 'canonical-consistency';
6
+ const REDIRECT_CHAIN = problem('Redirect chain of two or more hops', 'canonical-consistency.redirect-chain');
7
+ const TARGET_STATUS = problem('canonical target does not return a successful status', 'canonical-consistency.target-status');
8
+ const INVALID_URL = problem('canonical is not a valid URL', 'canonical-consistency.invalid-url');
9
+ const NO_POLICY = 'The page declares no canonical URL and does not redirect';
10
+ const REPEATED = 'Another audited page has the same title or body text, so whether this page needs a representative URL is not established';
11
+
12
+ /** Redirect depth, and whether the declared canonical points at this page or at a reachable one. */
13
+ export const canonicalConsistency = {
14
+ family_id: FAMILY,
15
+ message_keys: [REDIRECT_CHAIN, TARGET_STATUS, INVALID_URL].map(item => item.message_key),
16
+ derive({ pageRows, artifacts }) {
17
+ const out = [];
18
+ // Pages that share their body text with another audited page, or their title, by url.
19
+ const bodyText = html => visibleText(/<body\b[^>]*>([\s\S]*)/i.exec(String(html ?? ''))?.[1] ?? html);
20
+ const bodyOf = new Map(pageRows.map(row => [row, createHash('sha256').update(bodyText(row.html)).digest('hex')]));
21
+ const repeated = new Map();
22
+ for (const row of pageRows) {
23
+ const others = pageRows.filter(other => other !== row && other.artifact.final_url !== row.artifact.final_url);
24
+ const urls = others.filter(other => bodyOf.get(other) === bodyOf.get(row) || row.titleKey !== null && other.titleKey === row.titleKey).map(other => other.url);
25
+ if (urls.length) repeated.set(row.url, urls);
26
+ }
27
+ for (const { url, artifact, info } of pageRows) {
28
+ const hops = artifact.redirect_chain?.length ?? 0;
29
+ let canonicalState = null, canonicalProblem = null;
30
+ const measurement = { redirect_hops: hops, canonical_target: null, canonical_status: null };
31
+ if (info.canonical) {
32
+ try {
33
+ const target = new URL(info.canonical, artifact.final_url); target.hash = '';
34
+ measurement.canonical_target = target.href;
35
+ const other = artifacts.find(x => x.normalized_url === target.href || x.final_url === target.href);
36
+ if (target.href === artifact.final_url || target.href === artifact.normalized_url) canonicalState = 'pass';
37
+ else if (other && other.status >= 200 && other.status < 300) canonicalState = 'pass';
38
+ else if (other && Number.isInteger(other.status)) { canonicalState = 'fail'; canonicalProblem = TARGET_STATUS; measurement.canonical_status = other.status; }
39
+ } catch { canonicalState = 'fail'; canonicalProblem = INVALID_URL; }
40
+ }
41
+ // Fixed sentences; the hop count, the target and its status are page-specific and travel in the measurement.
42
+ const observe = (state, stated) => out.push(makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, url), state, evidence_ref: artifact.evidence_ref, ...stated }));
43
+ const problems = [...(hops >= 2 ? [REDIRECT_CHAIN] : []), ...(canonicalProblem ? [canonicalProblem] : [])];
44
+ if (problems.length) observe('fail', { problems, extra: { measurement: { ...measurement, problems: problems.map(item => item.text) } } });
45
+ else if (canonicalState === 'pass') observe('pass', { reason: 'Canonical target is this page or a reachable page and redirects stay within one hop', extra: { measurement } });
46
+ // With no canonical tag the page declares no representative URL: after one redirect hop the redirect is that policy and nothing
47
+ // disagrees with it; with no redirect either, there is no policy to be consistent with. That holds only for a page no other
48
+ // audited page repeats: a page with the same title or body as another is one that needs a representative URL.
49
+ else if (!info.canonical && hops < 2 && repeated.has(url)) observe('unknown', { reason: REPEATED, extra: { measurement: { ...measurement, repeated_with: repeated.get(url) } } });
50
+ else if (!info.canonical && hops === 1) observe('pass', { reason: 'No canonical is declared; the one redirect hop is the page\'s URL policy and stays within the limit', extra: { measurement } });
51
+ else if (!info.canonical && hops === 0) observe('not_applicable', { reason: NO_POLICY, extra: { measurement,
52
+ applicability_decision: { condition: 'no_representative_url_policy', reason: NO_POLICY, evidence_ref: artifact.evidence_ref } } });
53
+ }
54
+ return out;
55
+ },
56
+ };