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
@@ -1,193 +1,17 @@
1
- import { createHash } from 'node:crypto';
2
- import { validateEntity } from './contracts.mjs';
3
- import { auditCollected } from '../../modules/naver-searchadvisor/scripts/audit.mjs';
4
- import { parseRobots, robotsVerdict } from '../../modules/naver-searchadvisor/scripts/robots.mjs';
5
- import { anchorLinks, decodeEntities } from './html.mjs';
6
- import { agentFor } from './crawlers.mjs';
7
- import { declaredRobotsIndex } from './collector.mjs';
8
- import { compareFacts, pageFacts } from './facts.mjs';
1
+ import { blocks } from './checks/index.mjs';
2
+ import { prepare } from './checks/prepare.mjs';
3
+ import { publicOnlyFamily } from './config.mjs';
9
4
 
10
5
  /**
11
6
  * Every collector verdict, derived from collected responses alone. The audit writes these observations and the
12
7
  * report replays this same function over hashed raw evidence, so a producer and a verifier cannot disagree.
13
8
  * `artifacts` carry the collection record fields plus evidence_ref; `textOf(artifact)` returns the raw body text.
9
+ * One shared `prepare` step computes what the blocks have in common; each planned family's block then judges it.
10
+ * A family that only matters for indexed pages (`public_only` in the registry) sees only the pages meant to be indexed.
14
11
  */
15
- /** Link and sitemap target comparison key: no fragment, and a directory index file equals its directory. */
16
- function canonicalUrl(value) {
17
- try {
18
- const url = new URL(value); url.hash = '';
19
- url.pathname = url.pathname.replace(/\/index\.(?:html?|php)$/i, '/');
20
- return url.href;
21
- } catch { return null; }
22
- }
23
-
24
- export function deriveObservations({ context, artifacts, collection = {}, textOf }) {
25
- const protocol = context.protocol;
26
- const planned = new Set(protocol.families.filter(x => x.applicable).map(x => x.family_id));
27
- const selected = new Map(protocol.page_types.flatMap(type => type.urls).map(url => [url.normalized, url]));
28
- const declared = kind => new Set((protocol.auxiliary_resources ?? []).filter(x => x.kind === kind).map(x => x.normalized));
29
- const declaredRobots = declared('robots'), declaredSitemaps = declared('sitemap');
30
- const out = [];
31
- const add = (family, engine, url, state, reason, evidence_ref) => {
32
- const unit_key = `${family}|${engine ? `${engine}|` : ''}${url}`;
33
- out.push(validateEntity('CheckObservation', { schema_version: '1.0.0',
34
- observation_id: createHash('sha256').update(unit_key).digest('hex'), check_id: family, family_id: family, unit_key,
35
- state, q: state === 'pass' ? 1 : state === 'fail' ? 0 : null, partial_basis: null, reason, evaluator_type: 'code',
36
- source_ref: 'optbrew:collector', evidence_ref }));
37
- };
38
- const pageRows = [];
39
- for (const artifact of artifacts) {
40
- const url = artifact.normalized_url;
41
- if (!selected.has(url)) continue;
42
- if (planned.has('fetch-response')) add('fetch-response', null, url,
43
- artifact.error || artifact.status < 200 || artifact.status >= 300 && artifact.status < 400 ? 'unknown' : artifact.status >= 400 ? 'fail' : 'pass',
44
- artifact.error ? 'Request did not finish' : `HTTP ${artifact.status}`, artifact.evidence_ref);
45
- if (artifact.capability !== 'raw_html' || artifact.status !== 200) continue;
46
- const html = textOf(artifact);
47
- const robotsIndex = declaredRobotsIndex(artifacts, declaredRobots, artifact.final_url);
48
- const robots = robotsIndex < 0 ? null : artifacts[robotsIndex];
49
- const info = auditCollected({ artifact, html, engines: protocol.engines,
50
- robotsArtifact: robots?.status === 200 ? { ...robots, body: textOf(robots) } : null });
51
- pageRows.push({ url, artifact, html, info, robots, publicIndexing: selected.get(url).public_indexing !== false });
52
- }
53
- const normalizeTitle = title => title.toLowerCase().replace(/\s+/g, ' ').trim();
54
- const titleCounts = new Map();
55
- for (const row of pageRows) if (row.info.title) {
56
- const key = normalizeTitle(row.info.title);
57
- titleCounts.set(key, new Set([...(titleCounts.get(key) ?? []), row.artifact.final_url]));
58
- }
59
- const facts = pageRows.map(row => ({ url: row.url, row, facts: pageFacts(row.html) }));
60
- const comparison = compareFacts(facts);
61
- for (const row of pageRows) {
62
- const { url, artifact, info } = row, ref = artifact.evidence_ref;
63
- if (planned.has('document-title')) {
64
- const dup = info.title ? pageRows.filter(x => x !== row && x.info.title && normalizeTitle(x.info.title) === normalizeTitle(info.title) &&
65
- x.artifact.final_url !== artifact.final_url) : [];
66
- add('document-title', null, url, !info.title || dup.length ? 'fail' : 'unknown',
67
- !info.title ? 'Raw HTML has no title' : dup.length ? `Title duplicates ${dup.map(x => x.url).join(', ')}` :
68
- 'Raw HTML has a title; uniqueness and descriptiveness are not established', ref);
69
- }
70
- if (planned.has('index-policy') && row.publicIndexing)
71
- add('index-policy', null, url, info.index_blocked ? 'fail' : 'pass',
72
- info.index_blocked ? 'Raw response blocks indexing for the target engine' : 'Raw response has no applicable noindex directive', ref);
73
- if (planned.has('search-crawl-policy') && row.publicIndexing && row.robots?.status === 200) {
74
- const parsed = parseRobots(textOf(row.robots));
75
- const target = new URL(artifact.final_url);
76
- for (const engine of protocol.engines) {
77
- const agent = agentFor(engine);
78
- if (!agent) continue;
79
- const verdict = robotsVerdict(parsed, agent, target.pathname + target.search);
80
- add('search-crawl-policy', engine, url, verdict.allowed ? 'pass' : 'fail',
81
- verdict.allowed ? `Collected robots.txt allows ${agent} path` : `Collected robots.txt disallows ${agent} path`, row.robots.evidence_ref);
82
- }
83
- }
84
- if (planned.has('naver-markup') && protocol.engines.includes('naver'))
85
- add('naver-markup', 'naver', url, info.markup_findings.length ? 'fail' : 'pass',
86
- info.markup_findings.length ? info.markup_findings.map(x => x.message).join('; ') : 'Open Graph, description, canonical, viewport and title checks pass', ref);
87
- if (planned.has('structured-data-truth') && info.jsonld_parse_errors > 0)
88
- add('structured-data-truth', null, url, 'fail', `${info.jsonld_parse_errors} JSON-LD block(s) fail to parse`, ref);
89
- if (planned.has('canonical-consistency')) {
90
- const hops = artifact.redirect_chain?.length ?? 0;
91
- let canonicalState = null, canonicalNote = '';
92
- if (info.canonical) {
93
- try {
94
- const target = new URL(info.canonical, artifact.final_url); target.hash = '';
95
- const other = artifacts.find(x => x.normalized_url === target.href || x.final_url === target.href);
96
- if (target.href === artifact.final_url || target.href === artifact.normalized_url) canonicalState = 'pass';
97
- else if (other && other.status >= 200 && other.status < 300) canonicalState = 'pass';
98
- else if (other && Number.isInteger(other.status)) { canonicalState = 'fail'; canonicalNote = `canonical target ${target.href} returns HTTP ${other.status}`; }
99
- } catch { canonicalState = 'fail'; canonicalNote = 'canonical is not a valid URL'; }
100
- }
101
- if (hops >= 2) add('canonical-consistency', null, url, 'fail', `Redirect chain of ${hops} hops${canonicalNote ? `; ${canonicalNote}` : ''}`, ref);
102
- else if (canonicalState === 'fail') add('canonical-consistency', null, url, 'fail', canonicalNote, ref);
103
- else if (canonicalState === 'pass') add('canonical-consistency', null, url, 'pass', 'Canonical target is this page or a reachable page and redirects stay within one hop', ref);
104
- }
105
- }
106
- /** True when any other collected page's raw bytes name this page's path (whole path segment, not a longer path). */
107
- const pathMentioned = ({ url, artifact }, bodies) => {
108
- const own = canonicalUrl(url);
109
- for (const target of new Set([url, artifact.final_url])) {
110
- let path;
111
- try { path = decodeURIComponent(new URL(target).pathname); } catch { continue; }
112
- if (path === '/') continue;
113
- const pattern = new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![A-Za-z0-9_-])');
114
- for (const [page, body] of bodies) if (page !== own && pattern.test(body)) return true;
115
- }
116
- return false;
117
- };
118
- if (planned.has('discovery')) {
119
- const sitemapUrls = new Set();
120
- const brokenSitemaps = [];
121
- let sitemapIndex = false;
122
- for (const artifact of artifacts) {
123
- if (!declaredSitemaps.has(artifact.normalized_url)) continue;
124
- if (artifact.status >= 200 && artifact.status < 300) {
125
- const body = String(textOf(artifact) ?? '');
126
- // Child sitemaps of an index are not fetched, so pages missing from the index prove nothing.
127
- if (/<sitemapindex\b/i.test(body)) sitemapIndex = true;
128
- for (const match of body.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)) sitemapUrls.add(canonicalUrl(decodeEntities(match[1])));
129
- } else brokenSitemaps.push(`${artifact.normalized_url} (${artifact.status ?? artifact.error ?? 'no response'})`);
130
- }
131
- const htmlPages = artifacts.filter(x => x.capability === 'raw_html' && x.status === 200);
132
- const linked = new Map();
133
- // A page with scripts and no anchors may build its navigation in the browser. That only hides an orphan when the
134
- // script can be read: an inline script is searched for the path below, and a same-origin script file that was not
135
- // collected cannot be read at all.
136
- const collected = new Set(artifacts.flatMap(x => [x.normalized_url, x.final_url].filter(Boolean).map(canonicalUrl)));
137
- let uncollectedScript = false;
138
- const bodies = new Map();
139
- for (const page of htmlPages) {
140
- const body = textOf(page);
141
- bodies.set(canonicalUrl(page.normalized_url), body);
142
- const hrefs = anchorLinks(body, page.final_url);
143
- if (!hrefs.length && /<script\b/i.test(body)) {
144
- for (const match of body.matchAll(/<script\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
145
- try {
146
- const source = new URL(decodeEntities(match[1] ?? match[2]), page.final_url);
147
- if (source.origin === new URL(page.final_url).origin && !collected.has(canonicalUrl(source.href))) uncollectedScript = true;
148
- } catch { /* malformed script URL */ }
149
- }
150
- }
151
- for (const href of hrefs) {
152
- const target = canonicalUrl(href);
153
- if (target && target !== canonicalUrl(page.final_url) && target !== canonicalUrl(page.normalized_url)) linked.set(target, (linked.get(target) ?? 0) + 1);
154
- }
155
- }
156
- const complete = !collection.limit_reached && !(collection.skipped ?? []).some(x => ['depth_limit', 'url_limit', 'request_limit'].includes(x.reason));
157
- for (const row of pageRows) {
158
- if (!row.publicIndexing) continue;
159
- const { url, artifact } = row;
160
- const keys = [canonicalUrl(url), canonicalUrl(artifact.final_url)];
161
- const inSitemap = keys.some(key => sitemapUrls.has(key));
162
- if (inSitemap || keys.some(key => linked.has(key)))
163
- add('discovery', null, url, 'pass', inSitemap ? 'Listed in a declared sitemap' : 'Linked from another collected page', artifact.evidence_ref);
164
- // An unlinked page is unknown when a collected page's own bytes (inline scripts included) name its path, or when a
165
- // script file that could build the link was not collected, unless a fetched sitemap that lists other pages leaves
166
- // it out. Anything else is an orphan.
167
- else if (htmlPages.length >= 2 && complete && !sitemapIndex && (sitemapUrls.size || !(uncollectedScript || pathMentioned(row, bodies))))
168
- add('discovery', null, url, 'fail', `Not linked from any collected page and not in a sitemap${brokenSitemaps.length ? `; declared sitemap unusable: ${brokenSitemaps.join(', ')}` : ''}`, artifact.evidence_ref);
169
- }
170
- }
171
- if (planned.has('fact-consistency')) {
172
- for (const page of facts) {
173
- if (comparison.separate.has(page.url)) continue;
174
- const found = comparison.deviations.get(page.url) ?? [];
175
- const comparable = Object.keys(comparison.references).some(type => page.facts.visible[type].size || page.facts.linked[type].size || (type === 'phone' && page.facts.tel.phone.size));
176
- if (found.length) add('fact-consistency', null, page.url, 'fail', `Cross-page fact comparison (no approved fact list): ${found.join('; ')}`, page.row.artifact.evidence_ref);
177
- else if (comparable) add('fact-consistency', null, page.url, 'pass', 'Facts on this page match the cross-page reference values (no approved fact list; consistency is not accuracy)', page.row.artifact.evidence_ref);
178
- }
179
- }
180
- if (planned.has('entity-identity') && facts.length) {
181
- const reasons = [];
182
- const home = facts.find(x => new URL(x.row.artifact.final_url).pathname === '/') ?? facts[0];
183
- const orgPages = facts.filter(x => x.facts.orgs.length && !comparison.separate.has(x.url));
184
- const nameReference = comparison.references.name;
185
- const nameDeviants = facts.filter(x => (comparison.deviations.get(x.url) ?? []).some(text => text.startsWith('name ')));
186
- if (nameDeviants.length) reasons.push(`name differs from "${nameReference.label}" on ${nameDeviants.map(x => x.url).join(', ')}`);
187
- for (const page of orgPages) if (page.facts.orgs.some(org => !org.sameAs)) reasons.push(`Organization JSON-LD without sameAs on ${page.url}`);
188
- if (orgPages.length && !home.facts.orgs.length) reasons.push(`no Organization or LocalBusiness JSON-LD on ${home.url}`);
189
- if (reasons.length) add('entity-identity', null, 'site', 'fail', `Entity identity: ${reasons.join('; ')}`, (nameDeviants[0] ?? home).row.artifact.evidence_ref);
190
- else if (orgPages.length) add('entity-identity', null, 'site', 'pass', 'Organization JSON-LD with name and sameAs is present on the home page and names agree', home.row.artifact.evidence_ref);
191
- }
192
- return out;
12
+ export function deriveObservations(input) {
13
+ const prepared = prepare(input);
14
+ const publicOnly = { ...prepared, pageRows: prepared.pageRows.filter(row => row.publicIndexing) };
15
+ return blocks.filter(block => prepared.planned.has(block.family_id))
16
+ .flatMap(block => block.derive(publicOnlyFamily(block.family_id) ? publicOnly : prepared));
193
17
  }
@@ -0,0 +1,112 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ const INDEX_FILE = 'evidence-index.json';
7
+ // Raw collected bodies keep their own extension; every other kind is a JSON record.
8
+ const RAW_KINDS = new Set(['html', 'bin', 'render-dom', 'owner-export']);
9
+ // Written with the index so an audit that fetched nothing still has them; the others appear on first append.
10
+ const BASE_SECTIONS = ['raw', 'responses', 'failures'];
11
+ const ALL_SECTIONS = [...BASE_SECTIONS, 'renders', 'render_ledger', 'cwv'];
12
+ // One queue per index file for the whole process, so two stores on one directory never interleave a read-modify-write.
13
+ const queues = new Map();
14
+ const sha256Of = bytes => createHash('sha256').update(bytes).digest('hex');
15
+ const within = (root, file) => file === root || file.startsWith(`${root}${path.sep}`);
16
+
17
+ /**
18
+ * Read evidence bytes after checking that the file stays inside root and matches its recorded hash.
19
+ * `ref` is absolute or relative to root. Report replay and Store.read both read through this.
20
+ */
21
+ export function readVerified(root, ref, sha256) {
22
+ const file = path.resolve(root, ref);
23
+ if (!within(path.resolve(root), file) || !within(realpathSync(root), realpathSync(file)))
24
+ throw new Error(`evidence path outside run directory: ${ref}`);
25
+ const bytes = readFileSync(file);
26
+ if (sha256Of(bytes) !== sha256) throw new Error(`evidence hash mismatch: ${ref}`);
27
+ return bytes;
28
+ }
29
+
30
+ /**
31
+ * One directory of run evidence. Objects are content-addressed and written once; the index is a JSON file replaced
32
+ * atomically on every append, so a crash leaves either the old index or the new one, never a half-written file.
33
+ * `header` holds the index's leading fields and is used only when the run has no index yet.
34
+ */
35
+ export function openStore(dir, header = null) {
36
+ const indexRef = path.join(dir, INDEX_FILE);
37
+ const empty = () => ({ ...header, ...Object.fromEntries(BASE_SECTIONS.map(name => [name, []])) });
38
+ const queueKey = path.resolve(indexRef);
39
+ const serial = job => {
40
+ const run = (queues.get(queueKey) ?? Promise.resolve()).then(job);
41
+ const tail = run.catch(() => {}).then(() => { if (queues.get(queueKey) === tail) queues.delete(queueKey); });
42
+ queues.set(queueKey, tail);
43
+ return run;
44
+ };
45
+ const load = async () => {
46
+ try { return JSON.parse(await readFile(indexRef, 'utf8')); }
47
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
48
+ };
49
+ const save = async index => {
50
+ const temporary = `${indexRef}.${randomUUID()}.tmp`;
51
+ try {
52
+ await writeFile(temporary, `${JSON.stringify(index, null, 2)}\n`);
53
+ await rename(temporary, indexRef);
54
+ } catch (error) {
55
+ await rm(temporary, { force: true }).catch(() => {});
56
+ throw error;
57
+ }
58
+ };
59
+
60
+ return {
61
+ indexRef,
62
+ /** Write `bytes` once. The file is named by its hash, or by `name` for a fixed-name run document. */
63
+ async write(kind, bytes, { name } = {}) {
64
+ if (!/^[a-z][a-z0-9-]*$/.test(kind)) throw new Error(`invalid evidence kind: ${kind}`);
65
+ if (name !== undefined && (!name || path.basename(name) !== name)) throw new Error(`invalid evidence name: ${name}`);
66
+ const data = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
67
+ const sha256 = sha256Of(data);
68
+ const ref = path.join(dir, name ?? `${sha256}.${RAW_KINDS.has(kind) ? kind : `${kind}.json`}`);
69
+ try { await writeFile(ref, data, { flag: 'wx' }); }
70
+ catch (error) {
71
+ if (error.code !== 'EEXIST') throw error;
72
+ if (!(await readFile(ref)).equals(data)) throw new Error(`evidence ${path.basename(ref)} already exists with different content`);
73
+ }
74
+ return { ref, sha256 };
75
+ },
76
+ /** Add one entry to a section of the index (created on first use) and replace the index file. */
77
+ append(section, entry) {
78
+ if (!/^[a-z][a-z0-9_]*$/.test(section)) throw new Error(`invalid index section: ${section}`);
79
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) throw new Error(`index entry must be an object: ${section}`);
80
+ return serial(async () => {
81
+ const index = (await load()) ?? empty();
82
+ if (!Object.hasOwn(index, section)) index[section] = [];
83
+ else if (!Array.isArray(index[section])) throw new Error(`not an index section: ${section}`);
84
+ index[section].push(entry);
85
+ await save(index);
86
+ });
87
+ },
88
+ /** Record one value that is a single object, not a list (such as the Chrome result), replacing an earlier value of the same name. */
89
+ set(field, value) {
90
+ if (!/^[a-z][a-z0-9_]*$/.test(field) || ALL_SECTIONS.includes(field) || Object.hasOwn(header ?? {}, field)) throw new Error(`invalid index field: ${field}`);
91
+ return serial(async () => {
92
+ const index = (await load()) ?? empty();
93
+ index[field] = value;
94
+ await save(index);
95
+ });
96
+ },
97
+ /** The current index with every section present. With a header and no index file yet, the index is created first. */
98
+ index() {
99
+ return serial(async () => {
100
+ let index = await load();
101
+ if (!index && header) await save(index = empty());
102
+ return { ...Object.fromEntries(ALL_SECTIONS.map(name => [name, []])), ...index };
103
+ });
104
+ },
105
+ /** Bytes of a ref returned by write or listed in the index; throws if the file is outside the store or its hash differs. */
106
+ async read(ref, sha256) {
107
+ // A ref from write() is dir-joined (cwd-relative when dir is); any other relative ref is relative to dir, as in readVerified.
108
+ const returned = path.resolve(ref);
109
+ return readVerified(dir, within(path.resolve(dir), returned) ? returned : ref, sha256);
110
+ },
111
+ };
112
+ }
@@ -1,4 +1,7 @@
1
1
  import { decodeEntities, jsonLdBlocks, stripComments, stripInert, visibleText } from './html.mjs';
2
+ import { endpoints } from './config.mjs';
3
+ import { canonicalUrl } from './link-graph.mjs';
4
+ import { messageOf } from './messages.mjs';
2
5
 
3
6
  /**
4
7
  * Cross-page entity fact extraction and comparison, pure functions of the raw HTML of the selected pages.
@@ -7,13 +10,17 @@ import { decodeEntities, jsonLdBlocks, stripComments, stripInert, visibleText }
7
10
  * result is unknown, not a failure.
8
11
  */
9
12
  const FACT_TYPES = ['name', 'phone', 'year', 'address'];
13
+ /** The catalog keys of the two ways a page's fact can conflict; the check that states them lists them as its own. */
14
+ export const FACT_MESSAGE_KEYS = { differs: 'fact-consistency.differs', jsonld: 'fact-consistency.jsonld-differs' };
10
15
  const ORG_TYPES = /^(?:Organization|Corporation|LocalBusiness|Store|Restaurant|CafeOrCoffeeShop|OnlineStore|NGO|[A-Za-z]*Business)$/;
11
16
  const squash = value => String(value).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
12
17
  const BRANCH = /\b(?:branch)\b|지점|지사/i;
13
18
  // URL path segments that list branches: /branch/, /branches/, /locations/, /stores/, /지점/.
14
19
  const BRANCH_PATH = /^(?:branch|branches|locations|stores|지점)$/i;
20
+ /** A node that states a sub-organization: a branch or department of another entity, by its own link or by a branch word in its name. */
21
+ const isBranchNode = node => Boolean(node.branchOf || node.parentOrganization || BRANCH.test(typeof node.name === 'string' ? node.name : ''));
15
22
  const branchPath = url => {
16
- try { return new URL(url, 'http://page.invalid').pathname.split('/').some(part => BRANCH_PATH.test(decodeURIComponent(part))); }
23
+ try { return new URL(url, endpoints().relative_url_base).pathname.split('/').some(part => BRANCH_PATH.test(decodeURIComponent(part))); }
17
24
  catch { return false; }
18
25
  };
19
26
 
@@ -103,8 +110,11 @@ function readPhone(rest) {
103
110
  return null;
104
111
  }
105
112
 
106
- /** Facts on one page: visible labelled values and JSON-LD / tel: values, keyed by fact type. */
107
- export function pageFacts(html) {
113
+ /**
114
+ * Facts on one page: visible labelled values and JSON-LD / tel: values, keyed by fact type. `pageUrls` are the addresses the page
115
+ * was requested at and served from; `entities` are the JSON-LD nodes that speak for the page.
116
+ */
117
+ export function pageFacts(html, pageUrls = []) {
108
118
  const live = stripComments(html);
109
119
  const visible = Object.fromEntries(FACT_TYPES.map(type => [type, new Map()]));
110
120
  const linked = Object.fromEntries(FACT_TYPES.map(type => [type, new Map()]));
@@ -125,29 +135,53 @@ export function pageFacts(html) {
125
135
  put(visible, 'year', match[1] ?? match[2], match[1] ?? match[2]);
126
136
  for (const match of text.matchAll(/\bAddress\b\s*[::]\s*([^\n]+?)(?=\.\s|\.?\s*$|\n)/gim)) put(visible, 'address', match[1], addressKey(match[1]));
127
137
  for (const match of text.matchAll(/주소\s*[::]\s*([^\n.]+)/g)) put(visible, 'address', match[1], addressKey(match[1]));
128
- const orgs = [];
138
+ const described = [];
139
+ // The @id values a node of the page names as what it is about (WebPage.about, mainEntity).
140
+ const aboutIds = new Set();
129
141
  for (const block of jsonLdBlocks(live)) {
130
142
  if (!block.ok) continue;
131
143
  for (const node of nodesOf(block.value)) {
144
+ for (const key of ['about', 'mainEntity']) for (const ref of [].concat(node[key] ?? []))
145
+ if (typeof ref === 'string' ? ref : ref?.['@id']) aboutIds.add(typeof ref === 'string' ? ref : ref['@id']);
132
146
  if (!typesOf(node).some(type => ORG_TYPES.test(type))) continue;
133
147
  const name = typeof node.name === 'string' ? node.name : '';
134
- const org = { name, id: typeof node['@id'] === 'string' ? node['@id'] : null,
148
+ described.push({ node, org: { name, id: typeof node['@id'] === 'string' ? node['@id'] : null,
135
149
  sameAs: [].concat(node.sameAs ?? []).some(x => typeof x === 'string' && x.trim()),
136
- branch: Boolean(node.branchOf || node.parentOrganization || BRANCH.test(name)) };
137
- if (name) put(linked, 'name', name, squash(name));
138
- if (typeof node.telephone === 'string') put(linked, 'phone', node.telephone, phoneKey(node.telephone));
139
- const address = addressText(node.address);
140
- if (address) put(linked, 'address', address, addressKey(address));
141
- const year = /\b((?:19|20)\d{2})\b/.exec(String(node.foundingDate ?? ''))?.[1];
142
- if (year) put(linked, 'year', year, year);
143
- orgs.push(org);
150
+ branch: isBranchNode(node) } });
144
151
  }
145
152
  }
153
+ // A sub-organization (a branch, a department) states facts of its own, not the page's. A sub-organization whose own url is this
154
+ // page, or that this page names as what it is about, is the page's entity: the page is that branch's page, whatever else its
155
+ // graph carries (the site's main organization is on every page of many sites). An @id counts only when it is the page address
156
+ // itself. The site home never belongs to one branch: an address that is the root path, with or without a fragment (`/#annex`), is
157
+ // the home page's. What the page is about names a branch only when it names exactly one sub-organization (a locations page lists
158
+ // several).
159
+ const ownKeys = new Set(pageUrls.map(canonicalUrl).filter(Boolean));
160
+ const atRoot = value => { try { return new URL(value).pathname === '/'; } catch { return false; } };
161
+ const claimsRoot = ({ node, org }) => [node.url, org.id].some(value => typeof value === 'string' && atRoot(value));
162
+ const aboutSubs = described.filter(item => item.org.branch && item.org.id && aboutIds.has(item.org.id));
163
+ const isOwn = item => item.org.branch && !claimsRoot(item) &&
164
+ (ownKeys.has(canonicalUrl(item.node.url)) || pageUrls.includes(item.org.id) || (aboutSubs.length === 1 && aboutSubs[0] === item));
165
+ const own = described.filter(isOwn);
166
+ // Otherwise, when the page also carries a node that is not a sub-organization, that node is the page's entity and only it
167
+ // speaks for the page; a page whose every node is a sub-organization is a separate branch page.
168
+ const speaking = own.length ? own : described.some(item => !item.org.branch) ? described.filter(item => !item.org.branch) : described;
169
+ const orgs = [];
170
+ for (const { node, org } of speaking) {
171
+ if (org.name) put(linked, 'name', org.name, squash(org.name));
172
+ if (typeof node.telephone === 'string') put(linked, 'phone', node.telephone, phoneKey(node.telephone));
173
+ const address = addressText(node.address);
174
+ if (address) put(linked, 'address', address, addressKey(address));
175
+ const year = /\b((?:19|20)\d{2})\b/.exec(String(node.foundingDate ?? ''))?.[1];
176
+ if (year) put(linked, 'year', year, year);
177
+ orgs.push(org);
178
+ }
146
179
  // A branch word in the title or first heading is only a hint (titleBranch). A page is a separate entity when its
147
180
  // JSON-LD says so (branch), when its URL is a branch list, or when the hint comes with an address of its own.
148
181
  const heading = /<(?:title|h1)\b[^>]*>([\s\S]*?)<\/(?:title|h1)>/gi;
149
182
  const headings = [...live.matchAll(heading)].map(match => match[1]).join(' ');
150
- return { visible, linked, tel, orgs, branch: orgs.some(org => org.branch), titleBranch: BRANCH.test(headings) };
183
+ return { visible, linked, tel, orgs, entities: speaking.map(item => item.node), branch: orgs.length > 0 && orgs.every(org => org.branch),
184
+ titleBranch: BRANCH.test(headings) };
151
185
  }
152
186
 
153
187
  /**
@@ -180,6 +214,8 @@ export function compareFacts(allPages) {
180
214
  const pages = allPages.filter(page => !separate(page));
181
215
  const references = {};
182
216
  const deviations = new Map(allPages.map(page => [page.url, []]));
217
+ // What `deviations` says, in the form the report's Korean text fills: one entry for each text, in the same order.
218
+ const deviation_messages = new Map(allPages.map(page => [page.url, []]));
183
219
  for (const type of FACT_TYPES) {
184
220
  const counts = new Map();
185
221
  const carrying = pages.filter(page => keysOf(page, type).size);
@@ -201,15 +237,137 @@ export function compareFacts(allPages) {
201
237
  if (![...keysOf(page, type)].some(agrees)) {
202
238
  const found = [...evidenceOf(page, type).values()].join(' / ');
203
239
  deviations.get(page.url).push(`${type} "${found}" differs from "${label}" on ${count} pages`);
240
+ deviation_messages.get(page.url).push(messageOf(FACT_MESSAGE_KEYS.differs, { type, found, label, count }));
204
241
  }
205
242
  }
206
243
  }
207
244
  for (const page of pages) {
208
245
  const v = [...page.facts.visible[type].keys()], l = [...page.facts.linked[type].keys()];
209
246
  const same = type === 'address' ? v.some(a => l.some(b => sameAddress(a, b))) : v.some(key => l.includes(key));
210
- if (v.length && l.length && !same && !deviations.get(page.url).some(x => x.startsWith(`${type} `)))
211
- deviations.get(page.url).push(`${type} in JSON-LD "${[...page.facts.linked[type].values()].join(' / ')}" differs from visible "${[...page.facts.visible[type].values()].join(' / ')}"`);
247
+ if (v.length && l.length && !same && !deviations.get(page.url).some(x => x.startsWith(`${type} `))) {
248
+ const linked = [...page.facts.linked[type].values()].join(' / '), visible = [...page.facts.visible[type].values()].join(' / ');
249
+ deviations.get(page.url).push(`${type} in JSON-LD "${linked}" differs from visible "${visible}"`);
250
+ deviation_messages.get(page.url).push(messageOf(FACT_MESSAGE_KEYS.jsonld, { type, linked, visible }));
251
+ }
252
+ }
253
+ }
254
+ return { references, deviations, deviation_messages, separate: new Set(allPages.filter(separate).map(page => page.url)) };
255
+ }
256
+
257
+ // A phone number with its separators is never longer than this, so a number is read from this many characters after its first digit.
258
+ const PHONE_SPAN = 32;
259
+ /** The phone keys of every number `text` shows, read with the same rules as a labelled number. */
260
+ function numbersIn(text) {
261
+ const keys = new Set();
262
+ for (const match of text.matchAll(/(?<!\d)[+(]?\d/g)) {
263
+ const number = readPhone(text.slice(match.index, match.index + PHONE_SPAN));
264
+ const key = number && phoneKey(number);
265
+ if (key) keys.add(key);
266
+ }
267
+ return keys;
268
+ }
269
+ /** True when `tokens` stand next to each other, in that order, among `words`. */
270
+ const runIn = (words, tokens) => words.some((_, i) => tokens.every((token, k) => words[i + k] === token));
271
+ /** True when `tokens` (at least two, one of them a word) stand next to each other, in that order, among `words`. */
272
+ const hasRun = (words, tokens) => tokens.length > 1 && tokens.some(token => /^\p{L}/u.test(token)) && runIn(words, tokens);
273
+ const wordsOf = text => String(text).toLowerCase().match(/\p{L}+|\p{N}+/gu) ?? [];
274
+ // Scripts written without spaces between words: a name in them is found as a run of characters, not of words.
275
+ const UNSPACED = /[\p{Script=Hangul}\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
276
+ /** True when the page text shows the name: as whole words (three letters at least), or for an unspaced script as a run of characters. */
277
+ function showsName(name, squashed, words) {
278
+ if (UNSPACED.test(name)) return squash(name).length > 1 && squashed.includes(squash(name));
279
+ const tokens = wordsOf(name);
280
+ return tokens.join('').length > 2 && runIn(words, tokens);
281
+ }
282
+
283
+ const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
284
+ const YEAR = '((?:19|20)\\d{2})';
285
+ const MONTH_NAME = `(${MONTHS.join('|')})[a-z]*\\.?`;
286
+ /**
287
+ * The full calendar dates the page shows, by yyyy-mm-dd, each with the text it was read from and where: in the visible text
288
+ * (`text`: 2026-09-16, 2026.9.16, 2026년 9월 16일, September 16, 2026, 16 Sep 2026) or as the datetime of a `<time>` element that has
289
+ * visible text (`time`; not inside script, template, noscript or a hidden element). A day without a year is not a date, and a
290
+ * `time` element is close to circular, because the site writes it and its JSON-LD from one source: the source is recorded.
291
+ */
292
+ function datesIn(text, html) {
293
+ const found = new Map();
294
+ const add = (year, month, day, shown, source) => {
295
+ const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
296
+ if (!found.has(key)) found.set(key, { text: shown.replace(/\s+/g, ' ').trim(), source });
297
+ };
298
+ for (const m of text.matchAll(new RegExp(`\\b${YEAR}\\s*[-./]\\s*(\\d{1,2})\\s*[-./]\\s*(\\d{1,2})\\b`, 'g'))) add(m[1], m[2], m[3], m[0], 'text');
299
+ for (const m of text.matchAll(new RegExp(`${YEAR}\\s*년\\s*(\\d{1,2})\\s*월\\s*(\\d{1,2})\\s*일`, 'g'))) add(m[1], m[2], m[3], m[0], 'text');
300
+ for (const m of text.matchAll(new RegExp(`\\b${MONTH_NAME}\\s+(\\d{1,2})(?:st|nd|rd|th)?,?\\s+${YEAR}\\b`, 'gi'))) add(m[3], MONTHS.indexOf(m[1].toLowerCase()) + 1, m[2], m[0], 'text');
301
+ for (const m of text.matchAll(new RegExp(`\\b(\\d{1,2})(?:st|nd|rd|th)?\\s+${MONTH_NAME},?\\s+${YEAR}\\b`, 'gi'))) add(m[3], MONTHS.indexOf(m[2].toLowerCase()) + 1, m[1], m[0], 'text');
302
+ const rendered = stripInert(String(html).replace(/<(template|noscript)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ''));
303
+ for (const m of rendered.matchAll(/<time\b([^>]*)>([\s\S]*?)<\/time\s*>/gi)) {
304
+ const shown = decodeEntities(m[2].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
305
+ const datetime = /\bdatetime\s*=\s*["'](\d{4})-(\d{2})-(\d{2})/i.exec(m[1]);
306
+ if (shown && datetime && !/\bhidden\b|display\s*:\s*none/i.test(m[1])) add(datetime[1], datetime[2], datetime[3], shown, 'time');
307
+ }
308
+ return found;
309
+ }
310
+ /** True when two headings are the same words: one holds the other and is at least four fifths of its length, in letters and digits only. */
311
+ function sameHeadline(a, b) {
312
+ const [short, long] = [squash(a), squash(b)].sort((x, y) => x.length - y.length);
313
+ return short.length > 2 && short.length * 5 >= long.length * 4 && long.includes(short);
314
+ }
315
+ const ARTICLE_TYPES = /^(?:[A-Za-z]*Article|BlogPosting|LiveBlogPosting|Report)$/;
316
+ /** The texts of the page's h1 elements, then of its title: where a headline is expected to appear. */
317
+ const headingTexts = html => ['h1', 'title'].flatMap(tag => [...stripInert(html).matchAll(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`, 'gi'))]
318
+ .map(match => decodeEntities(match[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim()).filter(Boolean));
319
+
320
+ /**
321
+ * How the facts a page's JSON-LD states about its own selected type stand against the page's visible text. Only the page's own
322
+ * nodes are read: top-level and @graph nodes, never an organization nested inside another node (a publisher, a parent). An
323
+ * Organization or LocalBusiness that speaks for the page (`facts.entities`) states a name, a phone number and a street address;
324
+ * an Article-family node states a headline and a publication date. Every fact is in exactly one list.
325
+ *
326
+ * `matched`: the page shows it (a name as whole words, a phone number, a street run, a headline in the page's h1 or title, a date).
327
+ * `mismatched`: the page shows a different value in the field that would hold it (a labelled phone or address, or an h1/title that
328
+ * does not contain the headline). `unevaluated`: the page neither shows nor contradicts it. Other types (Service, FAQPage, Event, ...)
329
+ * state nothing this compares. The date and the name have no contradicting field, so they only match or stay unevaluated.
330
+ */
331
+ export function jsonLdTruth(html, facts) {
332
+ const live = stripComments(html);
333
+ const text = visibleText(live);
334
+ const squashed = squash(text);
335
+ const words = wordsOf(text);
336
+ const numbers = new Set([...numbersIn(hardText(live)), ...facts.visible.phone.keys(), ...facts.tel.phone.keys()]);
337
+ const addressWords = addressKey(text).split(' ');
338
+ const labelled = [...facts.visible.address.keys()];
339
+ const found = new Map();
340
+ // `visible` is the value the page shows in the field that contradicts; `shown` is the text a matched date or headline was read from, and where.
341
+ const note = (list, type, jsonld, visible, shown) => found.has(`${type}|${jsonld}`) ||
342
+ found.set(`${type}|${jsonld}`, { list, type, jsonld, ...(visible ? { visible } : {}), ...(list === 'matched' && shown ? { shown } : {}) });
343
+ for (const node of facts.entities) {
344
+ if (typeof node.name === 'string' && squash(node.name)) note(showsName(node.name, squashed, words) ? 'matched' : 'unevaluated', 'name', node.name);
345
+ const key = typeof node.telephone === 'string' ? phoneKey(node.telephone) : null;
346
+ if (key) {
347
+ if (numbers.has(key)) note('matched', 'phone', node.telephone);
348
+ else note(facts.visible.phone.size ? 'mismatched' : 'unevaluated', 'phone', node.telephone, [...facts.visible.phone.values()].join(' / '));
349
+ }
350
+ const street = typeof node.address === 'string' ? node.address : node.address?.streetAddress;
351
+ const streetKey = typeof street === 'string' ? addressKey(street) : '';
352
+ if (streetKey) {
353
+ if (hasRun(addressWords, streetKey.split(' ')) || labelled.some(other => sameAddress(other, streetKey))) note('matched', 'address', street);
354
+ else note(labelled.length ? 'mismatched' : 'unevaluated', 'address', street, [...facts.visible.address.values()].join(' / '));
355
+ }
356
+ }
357
+ const headings = headingTexts(live);
358
+ const hasH1 = /<h1[\s>]/i.test(stripInert(live));
359
+ const shownDates = datesIn(text, live);
360
+ for (const block of jsonLdBlocks(live)) if (block.ok) for (const node of nodesOf(block.value)) {
361
+ if (!typesOf(node).some(type => ARTICLE_TYPES.test(type))) continue;
362
+ const headline = typeof node.headline === 'string' ? node.headline.trim() : '';
363
+ if (squash(headline).length > 2) {
364
+ const same = headings.find(heading => sameHeadline(heading, headline));
365
+ // A title often differs from the headline on purpose, so only a page that has an h1 can contradict it.
366
+ note(same ? 'matched' : hasH1 ? 'mismatched' : 'unevaluated', 'headline', headline, headings[0], same && { text: same, source: 'text' });
212
367
  }
368
+ const published = /^(\d{4}-\d{2}-\d{2})/.exec(String(node.datePublished ?? ''))?.[1];
369
+ if (published) note(shownDates.has(published) ? 'matched' : 'unevaluated', 'date', published, undefined, shownDates.get(published));
213
370
  }
214
- return { references, deviations, separate: new Set(allPages.filter(separate).map(page => page.url)) };
371
+ const listed = list => [...found.values()].filter(item => item.list === list).map(({ list: _, ...fact }) => fact);
372
+ return { matched: listed('matched'), mismatched: listed('mismatched'), unevaluated: listed('unevaluated') };
215
373
  }
@@ -3,6 +3,22 @@ import { ContractError, validateEntity } from './contracts.mjs';
3
3
  const rank = { P0: 0, P1: 1, P2: 2, P3: 3 };
4
4
  const evidenceRank = { official: 0, reproduced: 1, research: 2, case: 3, hypothesis: 4 };
5
5
 
6
+ /** The text two findings must share, ignoring case and spacing, to be the same issue. */
7
+ export const messageKey = message => message.trim().replace(/\s+/g, ' ').toLowerCase();
8
+
9
+ /** A finding's text with its own unit's page address replaced, so the same problem on two pages reads the same. */
10
+ export const pageNeutral = (text, unitKey) => {
11
+ const page = unitKey.split('|').at(-1);
12
+ // Only a page address is replaced: the unit of a site-level check ends in a word such as "site", which is just text.
13
+ return /^https?:\/\//.test(page) ? text.split(page).join('{page}') : text;
14
+ };
15
+
16
+ /**
17
+ * The distinct problems one failed verdict states. A check that finds several fixed-sentence problems on one page joins them in
18
+ * its reason and lists them in `measurement.problems`; each is its own fix. Any other verdict states one problem, its reason.
19
+ */
20
+ export const problemsOf = observation => observation.measurement?.problems ?? [observation.reason];
21
+
6
22
  /** Merge the same check, unit and issue while preserving every module source. */
7
23
  export function mergeFindings(findings) {
8
24
  if (!Array.isArray(findings)) throw new ContractError('findings must be an array');
@@ -11,7 +27,7 @@ export function mergeFindings(findings) {
11
27
  validateEntity('Finding', item);
12
28
  if (item.check_id !== item.unit_key.split('|')[0] || !Object.hasOwn(rank, item.priority))
13
29
  throw new ContractError('finding check or priority invalid');
14
- const key = `${item.check_id}\0${item.unit_key}\0${item.message.trim().replace(/\s+/g, ' ').toLowerCase()}`;
30
+ const key = `${item.check_id}\0${item.unit_key}\0${messageKey(item.message)}`;
15
31
  const source = { source_ref: item.source_ref, evidence_ref: item.evidence_ref };
16
32
  if (!byIssue.has(key)) byIssue.set(key, { ...item, priority: item.blocker ? 'P0' : item.priority,
17
33
  finding_ids: [item.finding_id], sources: [source] });
@@ -23,6 +39,8 @@ export function mergeFindings(findings) {
23
39
  existing.blocker ||= item.blocker;
24
40
  if (existing.blocker) existing.priority = 'P0';
25
41
  existing.finding_ids = [...new Set([...existing.finding_ids, item.finding_id])];
42
+ // A finding that names several pages keeps all of them when two sources of the same issue are merged.
43
+ if (existing.unit_keys || item.unit_keys) existing.unit_keys = [...new Set([...(existing.unit_keys ?? [existing.unit_key]), ...(item.unit_keys ?? [item.unit_key])])];
26
44
  existing.remediation_ids = [...new Set([...existing.remediation_ids, ...item.remediation_ids])];
27
45
  }
28
46
  }