optbrew 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/AGENT_INSTALL.md +13 -11
  5. package/CHANGELOG.md +52 -0
  6. package/README.ko.md +40 -15
  7. package/README.md +40 -15
  8. package/THIRD_PARTY_NOTICES.md +88 -0
  9. package/docs/platforms.md +10 -10
  10. package/docs/release.md +37 -5
  11. package/lib/cli.mjs +5 -2
  12. package/lib/install.mjs +2 -1
  13. package/lib/records.mjs +2 -1
  14. package/lib/transaction.mjs +12 -5
  15. package/lib/update.mjs +3 -2
  16. package/package.json +6 -4
  17. package/plugin.json +2 -2
  18. package/skills/.claude-plugin/icon.png +0 -0
  19. package/skills/.claude-plugin/plugin.json +23 -0
  20. package/skills/README.md +27 -0
  21. package/skills/optbrew/SKILL.md +223 -8
  22. package/skills/optbrew/THIRD_PARTY_NOTICES.md +88 -0
  23. package/skills/optbrew/bundle-manifest.json +407 -47
  24. package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +4 -4
  25. package/skills/optbrew/references/api.md +5 -3
  26. package/skills/optbrew/references/check-registry.json +16 -16
  27. package/skills/optbrew/references/chrome-paths.json +31 -0
  28. package/skills/optbrew/references/endpoints.json +10 -0
  29. package/skills/optbrew/references/harness-signals.json +5 -0
  30. package/skills/optbrew/references/intake.md +76 -5
  31. package/skills/optbrew/references/judging.md +233 -0
  32. package/skills/optbrew/references/limits.json +26 -0
  33. package/skills/optbrew/references/measurement.md +1 -1
  34. package/skills/optbrew/references/messages.ko.json +170 -0
  35. package/skills/optbrew/references/report.md +18 -6
  36. package/skills/optbrew/references/routing.md +2 -2
  37. package/skills/optbrew/references/runbooks/anthropic-api-key.md +34 -0
  38. package/skills/optbrew/references/runbooks/chrome-install.md +56 -0
  39. package/skills/optbrew/references/runbooks/cwv-site-collection.md +38 -0
  40. package/skills/optbrew/references/runbooks/google-api-key.md +51 -0
  41. package/skills/optbrew/references/runbooks/gsc-export.md +32 -0
  42. package/skills/optbrew/references/runbooks/index.json +18 -0
  43. package/skills/optbrew/references/runbooks/key-set-claude-cloud.md +32 -0
  44. package/skills/optbrew/references/runbooks/key-set-codex.md +33 -0
  45. package/skills/optbrew/references/runbooks/key-set-local.md +40 -0
  46. package/skills/optbrew/references/runbooks/naver-searchadvisor-export.md +32 -0
  47. package/skills/optbrew/references/runbooks/network-claude-cloud-custom.md +33 -0
  48. package/skills/optbrew/references/runbooks/network-codex-approval.md +26 -0
  49. package/skills/optbrew/references/runbooks/network-codex-config.md +36 -0
  50. package/skills/optbrew/references/runbooks/seranking.md +38 -0
  51. package/skills/optbrew/references/schemas/contract.schema.json +42 -15
  52. package/skills/optbrew/references/scoring.md +40 -5
  53. package/skills/optbrew/references/source-analysis.md +40 -0
  54. package/skills/optbrew/references/versions.json +9 -0
  55. package/skills/optbrew/scripts/adapters/anthropic.mjs +5 -5
  56. package/skills/optbrew/scripts/adapters/imports.mjs +138 -1
  57. package/skills/optbrew/scripts/audit.mjs +10 -112
  58. package/skills/optbrew/scripts/cli.mjs +5 -2
  59. package/skills/optbrew/scripts/core/bundle.mjs +30 -13
  60. package/skills/optbrew/scripts/core/capabilities.mjs +8 -4
  61. package/skills/optbrew/scripts/core/cdp.mjs +105 -0
  62. package/skills/optbrew/scripts/core/checks/canonical-consistency.mjs +56 -0
  63. package/skills/optbrew/scripts/core/checks/cwv-field.mjs +77 -0
  64. package/skills/optbrew/scripts/core/checks/discovery.mjs +43 -0
  65. package/skills/optbrew/scripts/core/checks/document-title.mjs +62 -0
  66. package/skills/optbrew/scripts/core/checks/entity-identity.mjs +31 -0
  67. package/skills/optbrew/scripts/core/checks/fact-consistency.mjs +31 -0
  68. package/skills/optbrew/scripts/core/checks/fetch-response.mjs +18 -0
  69. package/skills/optbrew/scripts/core/checks/index-policy.mjs +16 -0
  70. package/skills/optbrew/scripts/core/checks/index.mjs +18 -0
  71. package/skills/optbrew/scripts/core/checks/internal-navigation.mjs +67 -0
  72. package/skills/optbrew/scripts/core/checks/locale-links.mjs +55 -0
  73. package/skills/optbrew/scripts/core/checks/naver-markup.mjs +21 -0
  74. package/skills/optbrew/scripts/core/checks/observation.mjs +37 -0
  75. package/skills/optbrew/scripts/core/checks/prepare.mjs +103 -0
  76. package/skills/optbrew/scripts/core/checks/render-content.mjs +104 -0
  77. package/skills/optbrew/scripts/core/checks/search-crawl-policy.mjs +29 -0
  78. package/skills/optbrew/scripts/core/checks/structured-data-truth.mjs +46 -0
  79. package/skills/optbrew/scripts/core/chrome-temp.mjs +68 -0
  80. package/skills/optbrew/scripts/core/chrome.mjs +546 -0
  81. package/skills/optbrew/scripts/core/collector.mjs +45 -49
  82. package/skills/optbrew/scripts/core/compare.mjs +18 -1
  83. package/skills/optbrew/scripts/core/config.mjs +31 -0
  84. package/skills/optbrew/scripts/core/contracts.mjs +31 -2
  85. package/skills/optbrew/scripts/core/cwv.mjs +100 -0
  86. package/skills/optbrew/scripts/core/derive.mjs +10 -186
  87. package/skills/optbrew/scripts/core/evidence-store.mjs +112 -0
  88. package/skills/optbrew/scripts/core/facts.mjs +175 -17
  89. package/skills/optbrew/scripts/core/findings.mjs +19 -1
  90. package/skills/optbrew/scripts/core/html.mjs +41 -3
  91. package/skills/optbrew/scripts/core/index-targets.mjs +118 -0
  92. package/skills/optbrew/scripts/core/judgments.mjs +675 -0
  93. package/skills/optbrew/scripts/core/keys.mjs +86 -0
  94. package/skills/optbrew/scripts/core/link-graph.mjs +93 -0
  95. package/skills/optbrew/scripts/core/messages.mjs +23 -0
  96. package/skills/optbrew/scripts/core/metrics.mjs +4 -2
  97. package/skills/optbrew/scripts/core/migration.mjs +37 -0
  98. package/skills/optbrew/scripts/core/page-select.mjs +136 -0
  99. package/skills/optbrew/scripts/core/probe.mjs +83 -0
  100. package/skills/optbrew/scripts/core/protocol.mjs +38 -14
  101. package/skills/optbrew/scripts/core/render.mjs +160 -0
  102. package/skills/optbrew/scripts/core/report-compose.mjs +377 -0
  103. package/skills/optbrew/scripts/core/report-html.mjs +198 -0
  104. package/skills/optbrew/scripts/core/report-page.mjs +14 -0
  105. package/skills/optbrew/scripts/core/report.mjs +327 -122
  106. package/skills/optbrew/scripts/core/runbooks.mjs +63 -0
  107. package/skills/optbrew/scripts/core/scope.mjs +23 -5
  108. package/skills/optbrew/scripts/core/score.mjs +12 -4
  109. package/skills/optbrew/scripts/core/secrets.mjs +2 -0
  110. package/skills/optbrew/scripts/core/stages.mjs +694 -0
  111. package/skills/optbrew/scripts/core/transport.mjs +157 -0
  112. package/skills/optbrew/scripts/core/weights.mjs +2 -1
  113. package/skills/optbrew/scripts/key.mjs +90 -0
  114. package/skills/optbrew/scripts/preflight.mjs +176 -0
  115. package/skills/optbrew/scripts/run.mjs +81 -0
  116. package/skills/optbrew/source-lock.json +1 -1
  117. package/source-lock.json +1 -1
@@ -0,0 +1,160 @@
1
+ import { isIP } from 'node:net';
2
+ import { chromeHandle, findChrome, launchChrome, scopedPage } from './chrome.mjs';
3
+ import { fetchScoped } from './collector.mjs';
4
+ import { limits } from './config.mjs';
5
+ import { ContractError } from './contracts.mjs';
6
+ import { sameOriginLinks } from './link-graph.mjs';
7
+ import { assertInScope, unbracket } from './scope.mjs';
8
+
9
+ /** The first URL of every page type, in the order given, cut at `limit`. The same page types always give the same pages. */
10
+ export function selectRenderPages(pageTypes, limit = limits().render_pages) {
11
+ return pageTypes.flatMap(type => type.urls.slice(0, 1)).slice(0, limit);
12
+ }
13
+
14
+ // A third-party fetch follows no redirect: zero hops.
15
+ const FOLLOW_NONE = 0;
16
+ // Response headers that decide whether the page may use a cross-origin resource (fonts need them); never Set-Cookie.
17
+ const CORS_HEADERS = ['access-control-allow-origin', 'access-control-allow-credentials', 'timing-allow-origin'];
18
+
19
+ const addressOf = value => typeof value === 'string' ? value : value?.address;
20
+
21
+ /**
22
+ * `--host-resolver-rules` for direct mode: Chrome resolves nothing but the allowed hosts, each pinned to the address address
23
+ * vetting accepted for it, so Chrome connects where the vetting looked. A host that cannot be vetted (no answer, or a private
24
+ * address outside `local_targets`) is mapped to `^NOTFOUND`, and so is every other name (the catch-all comes last): a
25
+ * third-party static host is fetched by `staticFetcher` instead. IP literals are excluded from the catch-all; the interception
26
+ * vets them.
27
+ */
28
+ async function hostRulesFor(scope, resolve) {
29
+ const byHost = new Map();
30
+ const literals = [];
31
+ for (const rule of scope.allowed) {
32
+ const { hostname } = new URL(rule.origin);
33
+ const host = unbracket(hostname);
34
+ if (isIP(host)) { literals.push(`EXCLUDE ${hostname}`); continue; }
35
+ byHost.set(host, [...(byHost.get(host) ?? []), new URL(rule.path_prefix ?? '/', rule.origin).href]);
36
+ }
37
+ const rules = [];
38
+ for (const [host, urls] of byHost) {
39
+ let target = '^NOTFOUND';
40
+ try {
41
+ const resolved = await resolve(host);
42
+ const addresses = Array.isArray(resolved) ? resolved : [resolved];
43
+ for (const url of urls) assertInScope(url, scope, addresses);
44
+ const [first] = addresses.map(addressOf);
45
+ target = isIP(first) === 6 ? `[${first}]` : first;
46
+ } catch { /* an unvetted host stays ^NOTFOUND */ }
47
+ rules.push(`MAP ${host} ${target}`);
48
+ }
49
+ return [...new Set(literals), ...rules, 'MAP * ^NOTFOUND'].join(',');
50
+ }
51
+
52
+ /**
53
+ * The bytes of a third-party static GET, fetched by Node with the collector's own vetted fetch: the address is resolved and
54
+ * vetted (private, loopback, link-local and metadata addresses are refused, no local_targets), the connection is pinned to
55
+ * it, the byte limit and deadline are the collection limits. No redirect is followed: one intercepted request is one fetch,
56
+ * so the render budget bounds the Node requests too, and a redirect answer is reported as `third_party_redirect`.
57
+ */
58
+ function staticFetcher(transport) {
59
+ return async url => {
60
+ let result;
61
+ try {
62
+ result = await fetchScoped(url, { context: { scope: { allowed: [{ origin: new URL(url).origin }] } },
63
+ transport: transport.transport, resolve: transport.resolve, addressVetting: 'direct', maxRedirects: FOLLOW_NONE, extraHeaders: CORS_HEADERS });
64
+ } catch (error) {
65
+ // The answered hops are recorded on the error; with no redirect allowed, one hop means the host answered with a redirect.
66
+ if (error.redirect_chain?.length) error.code = 'third_party_redirect';
67
+ throw error;
68
+ }
69
+ // Only these go to Chrome with the bytes, so a cookie or any other header of the third party never reaches the browser.
70
+ const forwarded = Object.fromEntries(Object.entries(result.headers).filter(([name]) => name === 'content-type' || CORS_HEADERS.includes(name)));
71
+ return { status: result.status, headers: forwarded, body: result.body };
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The chrome handle for one render. Direct mode pins the vetted addresses through host rules, fetches third-party static
77
+ * resources itself, and the render records say `pinned`; proxy mode hands Chrome the proxy and the records say `proxy`.
78
+ * `transport` is what `selectTransport` returned; the proxy credential only ever travels inside it, in memory.
79
+ * The handle also remembers why no Chrome was found.
80
+ */
81
+ export function renderChrome({ context, transport, find = findChrome, launch = launchChrome }) {
82
+ const pinned = transport.address_vetting === 'direct';
83
+ let lookup = null;
84
+ const handle = chromeHandle({ find: async () => (lookup = await find()), launch: async found => launch({ path: found.path,
85
+ ...(pinned ? { hostRules: await hostRulesFor(context.scope, transport.resolve) } : { proxy: transport.proxy }) }) });
86
+ return Object.assign(handle, { address_vetting: pinned ? 'pinned' : 'proxy', fetchStatic: pinned ? staticFetcher(transport) : null,
87
+ notFound: () => ({ found: false, reason: lookup?.reason ?? 'chrome_not_found' }) });
88
+ }
89
+
90
+ /**
91
+ * The request list of one page in the entry shape of `ledger.json`: one entry per URL and outcome, `requests` counting what
92
+ * went out. A request the render refused never reached the network, so it is `skipped` with no requests. A request that went
93
+ * out is a `response` when Chrome saw a status for it and an `error` (with Chrome's error text) when it failed or no outcome
94
+ * was seen; nothing is assumed to have succeeded.
95
+ */
96
+ function ledgerEntries(page, requests) {
97
+ const entries = new Map();
98
+ for (const request of requests) {
99
+ const refused = request.action === 'blocked';
100
+ const reason = refused ? (request.blocked_reason === 'out_of_scope' ? 'outside_scope' : request.blocked_reason) : null;
101
+ const error = refused ? null : request.error ?? (request.status === null ? 'no_response_recorded' : null);
102
+ const outcome = refused ? 'skipped' : error ? 'error' : 'response';
103
+ const key = JSON.stringify([request.url, outcome, reason, error]);
104
+ if (!entries.has(key)) entries.set(key, { page, url: request.url, requests: 0, outcome, skipped_reason: reason, error });
105
+ if (!refused) entries.get(key).requests += 1;
106
+ }
107
+ return [...entries.values()];
108
+ }
109
+
110
+ /**
111
+ * Replay check of one page's `render_ledger` entries against its stored RenderRecord: the entries are exactly what the
112
+ * record's requests give, no URL repeats with the same outcome and reason (it may repeat with distinct ones), nothing
113
+ * skipped carries requests, and the requests sent stay within `maxRenderRequests`, the render's own budget.
114
+ */
115
+ export function checkRenderLedger(entries, record, maxRenderRequests) {
116
+ const fail = message => { throw new ContractError(`render ledger: ${message}`); };
117
+ const mine = entries.filter(entry => entry.page === record.url);
118
+ if (JSON.stringify(mine) !== JSON.stringify(ledgerEntries(record.url, record.requests))) fail('entries differ from the request records of the page');
119
+ const keys = mine.map(entry => JSON.stringify([entry.url, entry.outcome, entry.skipped_reason, entry.error]));
120
+ if (new Set(keys).size !== keys.length) fail('a URL repeats with the same outcome and reason');
121
+ if (mine.some(entry => entry.outcome === 'skipped' && entry.requests !== 0)) fail('a skipped entry carries requests');
122
+ if (mine.reduce((sum, entry) => sum + entry.requests, 0) > maxRenderRequests) fail('more requests than the render limit');
123
+ if (record.requests.length > maxRenderRequests + 1) fail('more request records than the render limit allows');
124
+ }
125
+
126
+ async function renderOne({ ref, chrome, browser, context, store }) {
127
+ let result = null;
128
+ let requests = [];
129
+ try {
130
+ const page = await scopedPage({ chrome: browser, context, budget: context.protocol.render.max_render_requests, fetchStatic: chrome.fetchStatic });
131
+ try { result = await page.goto(ref.normalized); } finally { requests = page.requests; await page.close(); }
132
+ } catch { /* one page that cannot be rendered is recorded below; the others still render */ }
133
+ const written = result?.dom ? await store.write('render-dom', result.dom) : null;
134
+ const record = { url: ref.normalized, final_url: result?.final_url ?? null, status: result?.status ?? null,
135
+ dom_ref: written?.ref ?? null, dom_sha256: written?.sha256 ?? null, title: result?.title ?? null,
136
+ links: result?.final_url ? sameOriginLinks(result.links, result.final_url) : [], console_errors: [...(result?.console_errors ?? [])], requests,
137
+ blocked_reason: result ? result.blocked_reason ?? result.error : 'render_failed', address_vetting: chrome.address_vetting };
138
+ const { ref: evidenceRef, sha256 } = await store.write('render', `${JSON.stringify(record, null, 2)}\n`);
139
+ await store.append('renders', { url: record.url, evidence_ref: evidenceRef, sha256 });
140
+ for (const entry of ledgerEntries(record.url, requests)) await store.append('render_ledger', entry);
141
+ return record;
142
+ }
143
+
144
+ /**
145
+ * Render the pages the protocol fixed (`protocol.render.pages`, and only those) one after another through the scoped
146
+ * interception session. The final DOM, the record and every request go into `store`, so a verdict can be replayed from
147
+ * the evidence. `chrome` comes from `renderChrome`. Returns `{ renders, chrome: { found, reason } }`: without a Chrome
148
+ * nothing is rendered and `found` is false with the reason; when nothing was asked for, no Chrome is looked up and `found`
149
+ * is null.
150
+ */
151
+ export async function renderPages({ context, chrome, store }) {
152
+ const { render } = context.protocol;
153
+ if (!render.enabled || !render.pages.length) return { renders: [], chrome: { found: null, reason: 'render_not_requested' } };
154
+ if (!chrome.address_vetting) throw new Error('renderPages needs the chrome handle that renderChrome returns');
155
+ const browser = await chrome.get();
156
+ if (!browser) return { renders: [], chrome: chrome.notFound() };
157
+ const renders = [];
158
+ for (const ref of render.pages) renders.push(await renderOne({ ref, chrome, browser, context, store }));
159
+ return { renders, chrome: { found: true, reason: null } };
160
+ }
@@ -0,0 +1,377 @@
1
+ import { compileUnits, scoreWeighted } from './weights.mjs';
2
+ import { CWV_FAMILIES } from './checks/cwv-field.mjs';
3
+ import { CONTENT_AFTER_RENDER } from './checks/render-content.mjs';
4
+ import { messageKey } from './findings.mjs';
5
+ import { koText, messageOf } from './messages.mjs';
6
+ import { ENTITY_UNIT, OBSERVATION_SOURCES, QUESTION_UNIT, homeOf } from './judgments.mjs';
7
+ import { keySetRunbook, runbookHarnesses } from './probe.mjs';
8
+ import { remainingRunbooks, runbookEntry, startRunbooks } from './runbooks.mjs';
9
+
10
+ /** The optbrew operating rule (design spec, scoring.md): at least 80% of the applicable weight must be measured. */
11
+ const COVERAGE_THRESHOLD = 80;
12
+ const KEY_RUNBOOK = 'google-api-key';
13
+
14
+ /** Said under the run conditions only when a page showed its content after rendering; it never feeds a score (R36, R51). */
15
+ const JAVASCRIPT_NOTE = '네이버와 AI 크롤러는 자바스크립트를 실행하지 못할 수 있어서, 내용을 자바스크립트로 그리는 페이지는 이들에게 비어 보일 수 있습니다. 이 안내는 어떤 점수에도 들어가지 않습니다.';
16
+
17
+ /** Report text is Korean (R64); the ids and JSON keys around it stay English. */
18
+ export const UNKNOWN = '알 수 없음';
19
+ const LOCATION_NEEDED = '고칠 위치는 사이트 소유자가 정해야 합니다';
20
+ /** Said at the top of a report built from a synthetic example, in report.md and report.html alike. */
21
+ export const SYNTHETIC_BANNER = '예시 보고서 — 실제 사이트나 외부 서비스의 결과가 아닙니다.';
22
+ /** The section names, shared by report.md, report.html and the sentences that point to a section. */
23
+ export const SECTION_TITLES = { fixes: '먼저 고칠 것', completeness: '얼마나 확인했나', blank: '아직 비어 있는 곳', conditions: '실행 조건',
24
+ remaining: '남은 일', outcomes: '실제 결과', judgments: '모델 판단' };
25
+
26
+ const html = value => String(value ?? UNKNOWN).replace(/\\/g, '\\\\').replace(/&/g, '&amp;').replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
28
+ const table = (columns, rows) => `| ${columns.join(' | ')} |\n| ${columns.map(() => '---').join(' | ')} |\n${rows.map(row => `| ${row.map(html).join(' | ')} |`).join('\n') || `| ${columns.map(() => '—').join(' | ')} |`}`;
29
+ /** One rounding rule for every coverage the report shows: the floor to a tenth, the value the 80% threshold is judged on. */
30
+ const floorTenth = value => value === null ? null : Math.floor(value * 10 + 1e-9) / 10;
31
+ export const coverageOf = headline => floorTenth(headline.coverage_exact);
32
+ /** A percentage as text, or the unknown word when there is no number. */
33
+ export const percent = value => typeof value === 'number' ? `${value}%` : UNKNOWN;
34
+ const count = value => typeof value === 'number' ? `${value}건` : UNKNOWN;
35
+
36
+ const PRIORITY_LABELS = { P0: '가장 급함', P1: '중요', P2: '보통' };
37
+ const STATUS_LABELS = { confirmed: '확인됨', unknown: '확인하지 못함', proposed_only: '제안만 함', modified_unverified: '고쳤지만 확인 전', verification_failed: '고친 뒤 확인에 실패함',
38
+ verified_local: '내 컴퓨터에서 해결 확인됨', verified_production: '해결 확인됨' };
39
+ const CHROME_REASONS = { chrome_not_found: '설치된 크롬을 찾지 못함', optbrew_chrome_missing: '환경 변수 OPTBREW_CHROME가 가리키는 곳에 크롬이 없음' };
40
+ const HARNESS_NAMES = { 'claude-code': 'Claude Code', codex: 'Codex', 'claude-code-cloud': 'Claude Code 클라우드', other: '기타' };
41
+ const TRANSPORT_NAMES = { direct: '직접 연결', proxy: '프록시' };
42
+
43
+ /** One headline per profile. Below the coverage threshold the single number is withheld and only the measured subset is shown. */
44
+ function scoreHeadline(result) {
45
+ const exact = result.A > 0 ? 100 * result.M / result.A : null;
46
+ // Sufficiency reads the value the report shows, so a shown 79.9% is never called sufficient.
47
+ const coverageOk = result.score !== null && exact !== null && floorTenth(exact) >= COVERAGE_THRESHOLD;
48
+ // The design spec also asks for verdicts on the core access and indexing checks before a score counts as sufficient.
49
+ const coreOk = (result.unknown_core ?? []).length === 0;
50
+ const status = !coverageOk ? 'insufficient_coverage' : !coreOk ? 'insufficient_core' : 'sufficient';
51
+ return { profile: result.profile, status, headline_score: status === 'sufficient' ? result.score : null,
52
+ measured_subset_score: result.score, coverage: floorTenth(exact), coverage_exact: exact, coverage_threshold: COVERAGE_THRESHOLD,
53
+ unresolved_core: (result.unknown_core ?? []).length, missing_bounds: result.missing_bounds, readiness_status: result.readiness_status };
54
+ }
55
+
56
+ /** A phase with no audit cannot replay its verdicts: its score is never the headline (R49), and every status says so. */
57
+ const phaseOf = (result, input) => {
58
+ const headline = scoreHeadline(result);
59
+ const audited = input.run_context.audited === true;
60
+ return { headline: { ...headline, audited, ...(!audited && headline.status === 'sufficient' ? { status: 'unaudited', headline_score: null } : {}) },
61
+ coverage: headline.coverage, missing_bounds: result.missing_bounds, readiness_status: result.readiness_status };
62
+ };
63
+
64
+ const withoutCwv = (input, profile) => {
65
+ const { A, M } = scoreWeighted(compileUnits(input, profile).filter(unit => !CWV_FAMILIES.includes(unit.family_id)));
66
+ return A === 0 ? null : floorTenth(100 * M / A);
67
+ };
68
+
69
+ export const resolved = finding => finding.status === 'verified_production';
70
+ const priorityRank = priority => /^P(\d+)$/.test(priority) ? Number(priority.slice(1)) : Infinity;
71
+ const compareRank = (a, b) => a === b ? 0 : a < b ? -1 : 1;
72
+ const affectedKey = finding => `${finding.check_id}\0${messageKey(finding.message)}`;
73
+ /** The pages a finding affects: the ones it lists, or its own unit. */
74
+ const pagesOf = finding => finding.unit_keys ?? [finding.unit_key];
75
+ const PAGES_SHOWN = 5;
76
+ const SITE_UNIT = 'site';
77
+ /** A unit key is `<check>|<address>`; the address may hold `|` itself, so it is everything after the first one. */
78
+ const ENGINE_LABELS = { naver: '네이버', google: '구글' };
79
+ /** A unit of a check judged per engine reads `<engine>|<address>`: the address first, the engine in parentheses. */
80
+ const addressOf = unit => {
81
+ const address = unit.slice(unit.indexOf('|') + 1);
82
+ const engine = /^([A-Za-z][\w-]*)\|(.+)$/.exec(address);
83
+ if (engine) return `${engine[2]} (${ENGINE_LABELS[engine[1]] ?? engine[1]})`;
84
+ return address === SITE_UNIT ? '사이트 전체' : address;
85
+ };
86
+ /** How a fix row names its pages: the one address (a site-level check reads 「사이트 전체」), or the count and the first few addresses. */
87
+ export const pagesLabel = finding => {
88
+ const units = pagesOf(finding);
89
+ // The site's identity is read and fixed on the home page, so that unit names the home page's address, not the whole site (T22 M-2).
90
+ if (units.length === 1) return units[0] === ENTITY_UNIT && finding.site_address ? finding.site_address : addressOf(units[0]);
91
+ const shown = units.slice(0, PAGES_SHOWN).map(addressOf).join(', ');
92
+ return `(${units.length}개 페이지: ${shown}${units.length > PAGES_SHOWN ? `, 외 ${units.length - PAGES_SHOWN}개` : ''})`;
93
+ };
94
+
95
+ const REMEDIATION_FIELDS = ['mechanism', 'validation', 'effort', 'cost', 'location_hint'];
96
+ /**
97
+ * The Korean text of one fix: the sentence for its message key and, when a remediation was chosen, the sentence for each of that
98
+ * remediation's fields. Anything the catalog has no entry for (a model's finding, a remediation of someone's own catalog) keeps its own words.
99
+ */
100
+ const fixTextKo = finding => ({ message: koText(finding.message_key, finding.message_values, finding.message),
101
+ cited: citedOf(finding),
102
+ remediation: finding.remediation ? Object.fromEntries(REMEDIATION_FIELDS.map(field =>
103
+ [field, koText(`remediation.${finding.remediation.remediation_id}.${field}`, {}, finding.remediation[field])])) : stepsOf(finding) });
104
+
105
+ /** A model's fix (and the question coverage's) has no catalogued remediation; the catalog gives its steps under the key of its problem (R100). */
106
+ const MODEL_FIX_STEPS = ['mechanism', 'validation', 'effort'];
107
+ const stepsOf = finding => {
108
+ const steps = Object.fromEntries(MODEL_FIX_STEPS.map(field => [field, koText(finding.message_key && `${finding.message_key}.${field}`, finding.message_values, null)]));
109
+ return steps.mechanism ? { ...steps, cost: null, location_hint: null } : null;
110
+ };
111
+ /** Where to look for a model's fix: the body text of the page it names (every page it names, for a grouped one). */
112
+ const modelWhere = finding => finding.message_key?.startsWith('model-fix.') ? koText('model-fix.where', { pages: pagesLabel(finding) }, null) : null;
113
+ /** The text a model's verdict cites, as the page shows it, so the owner sees where to look. */
114
+ const citedOf = finding => finding.message_values?.quote ? koText('model-fix.cited', { quote: finding.message_values.quote }, null) : null;
115
+ /** What a fix row says in each column: the problem with its pages and cited text, the method, and the effort. */
116
+ const fixProblem = fix => [fix.text_ko.message, pagesLabel(fix), fix.text_ko.cited].filter(Boolean).join(' ');
117
+ export const fixMethod = fix => fix.remediation ? `${fix.text_ko.remediation.mechanism} (${fix.remediation.source_url}, 확인일 ${fix.remediation.checked_date})` : fix.text_ko.remediation?.mechanism ?? UNKNOWN;
118
+ export const fixEffort = fix => fix.remediation ? `${fix.text_ko.remediation.cost}; ${fix.text_ko.remediation.effort}` : fix.text_ko.remediation?.effort ?? UNKNOWN;
119
+
120
+ /** Open before resolved, then blocking, then priority (P0 first, an unknown one last), then the problem on the most pages, then the unit. */
121
+ function sortedFixes(findings, changes, home) {
122
+ const pages = new Map();
123
+ for (const finding of findings) pages.set(affectedKey(finding), new Set([...(pages.get(affectedKey(finding)) ?? []), ...pagesOf(finding)]));
124
+ const withPaths = findings.map(finding => {
125
+ const change_paths = changes.filter(change => change.finding_ids.some(id => finding.finding_ids.includes(id))).map(change => change.path);
126
+ const text_ko = fixTextKo(finding);
127
+ return { ...finding, ...(finding.unit_key === ENTITY_UNIT && home ? { site_address: home } : {}), change_paths, text_ko, where: change_paths.join('; ') || modelWhere({ ...finding, ...(finding.unit_key === ENTITY_UNIT && home ? { site_address: home } : {}) }) || text_ko.remediation?.location_hint || LOCATION_NEEDED,
128
+ priority_label: PRIORITY_LABELS[finding.priority] ?? finding.priority, status_label: STATUS_LABELS[finding.status] ?? finding.status };
129
+ });
130
+ return withPaths.sort((a, b) => Number(resolved(a)) - Number(resolved(b)) || Number(b.blocker) - Number(a.blocker) ||
131
+ compareRank(priorityRank(a.priority), priorityRank(b.priority)) || pages.get(affectedKey(b)).size - pages.get(affectedKey(a)).size ||
132
+ a.unit_key.localeCompare(b.unit_key));
133
+ }
134
+
135
+ /**
136
+ * A clean result never hides a thin one (D4): with no open fix but an incomplete check, the line says so and points to the
137
+ * completeness section, where the cause is shown. A report with no completeness row at all is as thin as it gets.
138
+ */
139
+ function conclusionOf(fixes, completeness) {
140
+ const open = fixes.filter(finding => !resolved(finding));
141
+ const blocking = open.filter(finding => finding.blocker).length;
142
+ const thin = completeness.length === 0 || completeness.some(row => (row.after ?? row.before).headline.status !== 'sufficient');
143
+ const none = thin ? `확인한 범위에서는 실패한 것이 없지만 점검이 끝나지 않았습니다. '${SECTION_TITLES.completeness}' 구역에서 이유를 확인하세요.` : '확인한 범위에서는 고칠 것이 남지 않았습니다.';
144
+ return { open: open.length, blocking, already_fixed: fixes.length - open.length,
145
+ text: open.length ? `먼저 고칠 것이 ${open.length}건 있습니다${blocking ? ` (그중 ${blocking}건이 가장 급합니다)` : ''}.` : none };
146
+ }
147
+
148
+ const FINDING_ROW = 'follow-up.finding';
149
+ const FOLLOW_UP_LINES = { problem: 'problem', reason: 'reason', required_input_or_authority: 'input', next_action: 'next', reobserve_when: 'when' };
150
+ /**
151
+ * The Korean text of one remaining-work row. A row about a fix is told from the fix: its Korean message, its status word, the chosen
152
+ * remediation's sentences. Any other row that names a catalog key fills that key's lines with its values. A row with no key keeps its own words.
153
+ */
154
+ function followUpText(row, fixes) {
155
+ const fix = row.message_key === FINDING_ROW ? fixes.find(item => item.finding_ids.includes(row.finding_id)) : null;
156
+ if (fix) {
157
+ const { remediation } = fix.text_ko;
158
+ const authority = fix.remediation && koText(`authority.${fix.remediation.required_authority}`, {}, row.required_input_or_authority);
159
+ return { problem: fixProblem(fix), reason: fix.status_label,
160
+ required_input_or_authority: authority ?? koText(`${FINDING_ROW}.input`, {}, row.required_input_or_authority),
161
+ next_action: remediation?.mechanism ?? koText(`${FINDING_ROW}.next`, {}, row.next_action),
162
+ reobserve_when: remediation?.validation ?? koText(`${FINDING_ROW}.when`, {}, row.reobserve_when) };
163
+ }
164
+ const named = row.finding_id ? fixes.find(item => item.finding_ids.includes(row.finding_id)) : null;
165
+ const values = { ...row.message_values, ...(named ? { fix: named.text_ko.message } : {}) };
166
+ return Object.fromEntries(Object.entries(FOLLOW_UP_LINES).map(([field, line]) =>
167
+ [field, koText(row.message_key && `${row.message_key}.${line}`, values, row[field])]));
168
+ }
169
+
170
+ const OUTCOME_CODES = /\b(?:comparable_windows|not_comparable_or_missing_window|measured|unknown|zero_or_missing|not_applicable)\b/g;
171
+ /** The codes in a cell of the actual-outcome table (the checked data keeps them) read as Korean words. */
172
+ const outcomeCell = cell => typeof cell === 'string' ? cell.replace(OUTCOME_CODES, code => koText(`outcome-word.${code}`, {}, code)) : cell;
173
+
174
+ /** A word the catalog has for a code (a check state, a readiness status); the code itself when it has none. */
175
+ const wordFor = (table, code) => koText(`${table}.${code}`, {}, code);
176
+
177
+ /** The cost and feature limits, one Korean sentence each; a note without a catalog key keeps its English text. */
178
+ const costIssuesKo = verified => verified.cost_issues.map((text, index) => {
179
+ const message = verified.cost_issue_messages?.[index];
180
+ return koText(message?.message_key, message?.message_values, text);
181
+ });
182
+
183
+ /** How many units the model declared not applicable in the latest phase, for each family: its own decision, shown apart from the coverage it affects. */
184
+ function modelNotApplicable(verified) {
185
+ const phase = verified.after ? 'after' : 'baseline';
186
+ const counts = {};
187
+ for (const row of verified.model_judgments) if (row.phase === phase && row.state === 'not_applicable') counts[row.family_id] = (counts[row.family_id] ?? 0) + 1;
188
+ return counts;
189
+ }
190
+ /**
191
+ * How many fails of the absence families (a missing operator or date) rest on absence and how many on text the verdict cites, in the latest phase.
192
+ * A fail is counted by the basis its fix sentence rests on (`sentence_basis`), so a shown freshness fail told as absence counts as absence (T22 M-1).
193
+ */
194
+ function absenceBasisCounts(verified) {
195
+ const phase = verified.after ? 'after' : 'baseline';
196
+ const counts = { absence: 0, shown: 0 };
197
+ for (const row of verified.model_judgments) if (row.phase === phase && row.basis) counts[row.sentence_basis ?? row.basis]++;
198
+ return counts;
199
+ }
200
+ const NOT_ACCEPTED_REASONS = { queue_coverage: '조각이 페이지를 충분히 덮지 못함', visible: '페이지에 날짜나 운영 주체가 보임', chrome: '인용한 조각이 모두 사이트 공통 글', empty_page: '페이지에 글이 없음' };
201
+ /** The model verdicts of the latest phase that rested on absence and were turned into unknown by the code, by reason. */
202
+ function notAcceptedCounts(input) {
203
+ const counts = {};
204
+ for (const obs of input.observations) {
205
+ const code = obs.evaluator_type === 'host_model' && obs.state === 'unknown' ? obs.measurement?.not_accepted : undefined;
206
+ if (NOT_ACCEPTED_REASONS[code]) counts[code] = (counts[code] ?? 0) + 1;
207
+ }
208
+ return counts;
209
+ }
210
+ /** Which audited pages answer which question of the fixed panel, from the derived question-coverage verdict of a phase; null when it has none. */
211
+ function questionCoverageList(input) {
212
+ const verdict = input.observations.find(obs => obs.unit_key === QUESTION_UNIT && obs.source_ref === OBSERVATION_SOURCES.derived);
213
+ if (!verdict) return null;
214
+ const questions = new Map(input.run_context.protocol.question_panel.map(question => [question.id, question.question]));
215
+ return verdict.measurement.list.map(({ question_id, answered_by }) => ({ question_id, question: questions.get(question_id) ?? question_id, pages: answered_by.map(addressOf) }));
216
+ }
217
+ const notApplicableText = counts => {
218
+ const entries = Object.entries(counts);
219
+ return entries.length ? `${count(entries.reduce((sum, [, n]) => sum + n, 0))} (${entries.map(([family, n]) => `${family} ${count(n)}`).join(', ')})` : '없음';
220
+ };
221
+
222
+ const blockerSummary = scores => ({ count: scores[0]?.blockers.length ?? 0, items: scores[0]?.blockers.map(x => `${x.family_id} ${x.unit_key}`) ?? [],
223
+ core_unknown: scores[0]?.unknown_core.length ?? 0 });
224
+
225
+ function completenessOf(verified) {
226
+ const { baseline, after } = verified;
227
+ return baseline.scores.map(first => {
228
+ const last = after?.scores.find(result => result.profile === first.profile) ?? null;
229
+ const before = phaseOf(first, baseline.input), later = last ? phaseOf(last, after.input) : null;
230
+ const delta = verified.comparison.score_deltas.find(x => x.profile === first.profile)?.percentage_point_delta ?? null;
231
+ return { profile: first.profile, pass_rate_in_measured: (last ?? first).score, coverage: coverageOf((later ?? before).headline),
232
+ coverage_without_cwv: withoutCwv(last ? after.input : baseline.input, first.profile), before, after: later, difference: delta,
233
+ between_measured_subsets: delta !== null && [before, later].some(phase => phase && phase.headline.status !== 'sufficient') };
234
+ });
235
+ }
236
+
237
+ /**
238
+ * What is still blank in the latest phase: the runbooks for its unknown reasons, then the start items the user left
239
+ * unanswered (D25). Nothing the probe already has is offered again, and the Google key item adds the runbook that saves
240
+ * the key for the probe's harness.
241
+ */
242
+ function remainingBlanks(verified, { observations, run_context: context }) {
243
+ const probe = context.environment_probe;
244
+ const has = { google_key: probe.google_key, chrome: probe.chrome.found };
245
+ // The blocking runbooks are not remaining work: a run that reached a report was not blocked.
246
+ const { improving } = startRunbooks({ ...probe, block_reason: null }, { level: 'brief', skillRoot: null, harnesses: runbookHarnesses(probe.harness) });
247
+ const answered = new Set(verified.answered_runbooks);
248
+ const ids = [...new Set([...remainingRunbooks(observations), ...improving.map(entry => entry.id).filter(id => !answered.has(id))])]
249
+ .filter(id => !has[runbookEntry(id).when.missing]);
250
+ return ids.map(id => {
251
+ const entry = runbookEntry(id);
252
+ const runbookIds = entry.expands_to ?? [id];
253
+ return { item: entry.item, fills: entry.fills, runbook_ids: id === KEY_RUNBOOK ? [...runbookIds, keySetRunbook(probe.harness)] : runbookIds };
254
+ });
255
+ }
256
+
257
+ /**
258
+ * The pages the run treated as deliberately not indexed (no index checks, no blocker), with the catalog key and values of the sentence that says so
259
+ * and the sentence itself; null when there are none. The owner reads it next to the headline: it is the one place a wrongly hidden noindex shows (T22 I-1).
260
+ */
261
+ function notIndexedOf(context) {
262
+ const pages = context.protocol.page_types.flatMap(type => type.urls).filter(url => url.public_indexing === false).map(url => url.normalized);
263
+ if (!pages.length) return null;
264
+ const note = messageOf('headline.not-indexed', { count: pages.length, pages: pages.join(', ') });
265
+ return { count: pages.length, pages, ...note, text: koText(note.message_key, note.message_values) };
266
+ }
267
+
268
+ /**
269
+ * The report, composed once: the conclusion, what to fix, how complete the audit is, what is blank, and the run conditions.
270
+ * `verified` is the checked report data: run_context, baseline and after ({ input, scores }), comparison, findings,
271
+ * model_judgments, lab_cwv and answered_runbooks. Headline, completeness and the blanks all read the latest phase (the after
272
+ * phase when there is one). Every format renders this and decides nothing.
273
+ */
274
+ export function composeReport(verified) {
275
+ const latest = (verified.after ?? verified.baseline).input;
276
+ const home = homeOf(latest.observations.filter(obs => obs.family_id === 'fetch-response' && obs.state === 'pass').map(obs => obs.unit_key.split('|').at(-1)));
277
+ const fixes = sortedFixes(verified.findings, verified.changes, home);
278
+ const completeness = completenessOf(verified);
279
+ const afterRender = latest.observations.some(obs => obs.family_id === 'render-content' && obs.state === 'pass' && obs.reason.startsWith(CONTENT_AFTER_RENDER));
280
+ const conditions = { probe: latest.run_context.environment_probe, model_judgments: verified.model_judgments, model_not_applicable: modelNotApplicable(verified), empty_pages: verified.empty_pages ?? [], absence_basis: absenceBasisCounts(verified), not_accepted: notAcceptedCounts(latest), question_coverage: questionCoverageList(latest), lab_cwv: verified.lab_cwv, start_redirect: verified.start_redirect ?? null,
281
+ blockers: { before: blockerSummary(verified.baseline.scores), after: verified.after ? blockerSummary(verified.after.scores) : null },
282
+ notes: afterRender ? [JAVASCRIPT_NOTE] : [] };
283
+ conditions.lines = conditionLines(conditions, verified);
284
+ return {
285
+ headline: { ...conclusionOf(fixes, completeness), not_indexed: notIndexedOf(latest.run_context) },
286
+ fixes,
287
+ completeness,
288
+ remaining: remainingBlanks(verified, latest),
289
+ conditions,
290
+ follow_ups: verified.follow_ups.map(row => ({ ...row, text_ko: followUpText(row, fixes) })),
291
+ };
292
+ }
293
+
294
+ /** Why a phase's single number is shown or withheld, in one sentence: the line under the summary, and the one the HTML report folds away. */
295
+ export const headlineLine = h => (h.status === 'sufficient' ? `${h.profile}: ${h.headline_score}점 (확인 범위 ${percent(coverageOf(h))})` :
296
+ h.status === 'unaudited' ? `${h.profile}: 감사 안 됨 — 확인한 범위 안의 통과율은 ${h.measured_subset_score ?? UNKNOWN}이지만, 이를 뒷받침하는 감사가 없어 점수로 내지 않습니다 (확인 범위 ${percent(coverageOf(h))})` :
297
+ h.status === 'insufficient_core' ? `${h.profile}: 핵심 검사 부족 (접근·색인 검사 ${count(h.unresolved_core)}에 결과가 없습니다. 확인 범위는 ${percent(coverageOf(h))}입니다); 확인한 범위의 통과율 ${h.measured_subset_score ?? UNKNOWN}, 점수 범위 ${h.missing_bounds?.join('–') ?? UNKNOWN}` :
298
+ `${h.profile}: 확인 범위 부족 (적용 가중치의 ${percent(coverageOf(h))}만 확인했습니다. 점수를 내려면 ${h.coverage_threshold}% 이상 필요합니다); 확인한 범위의 통과율 ${h.measured_subset_score ?? UNKNOWN}, 점수 범위 ${h.missing_bounds?.join('–') ?? UNKNOWN}`) +
299
+ (h.audited || h.status === 'unaudited' ? '' : '; 이 단계는 감사되지 않아 판정을 다시 계산할 수 없었습니다');
300
+
301
+ const headlineCell = h => (h.status === 'sufficient' ? `${h.headline_score}점 (확인 범위 ${percent(coverageOf(h))})` :
302
+ h.status === 'unaudited' ? `감사 안 됨 (확인한 범위의 통과율 ${h.measured_subset_score ?? UNKNOWN}; 확인 범위 ${percent(coverageOf(h))})` :
303
+ h.status === 'insufficient_core' ? `핵심 검사 부족 (${count(h.unresolved_core)} 결과 없음; 확인한 범위의 통과율 ${h.measured_subset_score ?? UNKNOWN}; 확인 범위 ${percent(coverageOf(h))}; 점수 범위 ${h.missing_bounds?.join('–') ?? UNKNOWN})` :
304
+ `확인 범위 부족 (확인한 범위의 통과율 ${h.measured_subset_score ?? UNKNOWN}; 확인 범위 ${percent(coverageOf(h))}; 점수 범위 ${h.missing_bounds?.join('–') ?? UNKNOWN})`) +
305
+ (h.audited || h.status === 'unaudited' ? '' : ' (감사 안 됨)');
306
+
307
+ /** The rows of every table, from the composed report and the checked data around it. */
308
+ function tableRows(composed, verified) {
309
+ const score = composed.completeness.map(({ profile, before, after, difference, between_measured_subsets: between, coverage_without_cwv: withoutField }) => [
310
+ profile, headlineCell(before.headline), after ? headlineCell(after.headline) : UNKNOWN,
311
+ `${difference ?? UNKNOWN}${between ? ' (확인한 범위끼리의 비교)' : ''}`,
312
+ `${percent(coverageOf(before.headline))} → ${after ? percent(coverageOf(after.headline)) : UNKNOWN}`,
313
+ `${before.missing_bounds?.join('–') ?? UNKNOWN} → ${after?.missing_bounds?.join('–') ?? UNKNOWN}`,
314
+ `${wordFor('readiness', before.readiness_status)} → ${after ? wordFor('readiness', after.readiness_status) : UNKNOWN}`, percent(withoutField)]);
315
+ const remediation = composed.fixes.map(f => [f.priority_label, fixProblem(f), f.sources.map(x => `${x.source_ref} ${x.evidence_ref}`).join('; '),
316
+ fixMethod(f), f.where, fixEffort(f), f.text_ko.remediation?.validation ?? UNKNOWN,
317
+ f.resolution ? `${f.status_label} (${f.resolution.source_ref}의 ${f.resolution.evaluator_type} 관찰로 해결 확인)` : f.status_label]);
318
+ return { score, remediation,
319
+ actual_outcomes: verified.outcome_rows.map(cells => cells.map(outcomeCell)),
320
+ remaining_work: composed.follow_ups.map(({ text_ko: x }) => [x.problem, x.reason, x.required_input_or_authority, x.next_action, x.reobserve_when]),
321
+ model_judgments: composed.conditions.model_judgments.map(x => [x.phase, `${x.family_id} ${x.unit_key}`, wordFor('state', x.state) + (x.identity_basis ? ` (${wordFor('identity-basis', x.identity_basis)})` : ''),
322
+ `${x.evidence_ref} 바이트 ${x.evidence_span.map(span => `${span.start}–${span.end}`).join(', ')}`, x.label]) };
323
+ }
324
+
325
+ /** The run conditions as label and value pairs, the one list both formats print. */
326
+ function conditionLines(conditions, verified) {
327
+ const { probe, lab_cwv: lab } = conditions;
328
+ const { before, after } = conditions.blockers;
329
+ const listed = summary => `${count(summary.count)} (${summary.items.map(item => item.replace(/ [^ |]+\|/, ' ').replace(/ site$/, ' 사이트 전체')).join('; ') || '없음'})`;
330
+ return [
331
+ { label: '실행 환경', value: `${HARNESS_NAMES[probe.harness] ?? probe.harness}; 전송 방식 ${TRANSPORT_NAMES[probe.transport] ?? probe.transport ?? UNKNOWN}` },
332
+ { label: '크롬', value: probe.chrome.found ? probe.chrome.kind : `찾지 못함 (${CHROME_REASONS[probe.chrome.reason] ?? probe.chrome.reason ?? UNKNOWN})` },
333
+ { label: 'Google 키', value: probe.google_key ? '있음' : '없음' },
334
+ { label: '분석 도구', value: probe.source_tools.join(', ') || '지정 없음' },
335
+ { label: '확인한 모델 판단', value: count(conditions.model_judgments.length) },
336
+ { label: '모델이 해당 없음으로 판정한 단위', value: notApplicableText(conditions.model_not_applicable) },
337
+ ...(conditions.empty_pages.length ? [{ label: '본문이 빈 페이지', value: `${conditions.empty_pages.length}페이지 (${conditions.empty_pages.join(', ')})` }] : []),
338
+ ...(conditions.absence_basis.absence + conditions.absence_basis.shown ? [{ label: '날짜·운영 주체 판정(fail)의 근거',
339
+ value: `페이지에 없다는 근거 ${conditions.absence_basis.absence}건, 인용한 글에서 직접 확인 ${conditions.absence_basis.shown}건` }] : []),
340
+ ...(Object.keys(conditions.not_accepted).length ? [{ label: '페이지에 없다는 판정 중 받아들이지 않은 것',
341
+ value: Object.entries(conditions.not_accepted).map(([code, n]) => `${NOT_ACCEPTED_REASONS[code]} ${n}건`).join(', ') }] : []),
342
+ { label: '이전 막는 문제', value: listed(before) },
343
+ { label: '이후 막는 문제', value: after ? listed(after) : `${UNKNOWN} (이후 단계를 보지 않음)` },
344
+ { label: '이후 결과 없는 핵심 검사', value: count(after?.core_unknown ?? null) },
345
+ { label: '실제 결과', value: verified.metrics.length ? "아래 '실제 결과'의 측정·빈칸 상태 참고" : '연결 안 됨 / 알 수 없음' },
346
+ { label: '비용·기능 제한', value: costIssuesKo(verified).join('; ') || '기록 없음' },
347
+ { label: '지표 출처 제한', value: verified.metric_origin_limitations.map((text, index) => koText(verified.metric_origin_limitation_messages?.[index]?.message_key, {}, text)).join('; ') || '측정된 제공자 입력 없음' },
348
+ { label: '실험실 Core Web Vitals (참고용, 점수에 넣지 않음)', value: lab ? `LCP ${lab.lcp ?? UNKNOWN} ms; CLS ${lab.cls ?? UNKNOWN}` : '측정하지 않음' },
349
+ ...(conditions.start_redirect ? [{ label: '시작 주소', value: `${conditions.start_redirect.from} → ${conditions.start_redirect.to} (이 주소로 연결되므로 감사는 ${conditions.start_redirect.to}에서 실행했습니다)` }] : []),
350
+ ];
351
+ }
352
+
353
+ /** The questions of the fixed panel and the pages that answer each, as a table for the model-judgment section; empty when there is no coverage verdict. */
354
+ const questionLines = list => list ? `${table(['질문', '답하는 페이지'], list.map(item => [item.question, item.pages.join(', ') || koText('question-coverage.not-found')]))}\n\n` +
355
+ `${list.some(item => !item.pages.length) ? `${html(koText('question-coverage.note'))}\n\n` : ''}` : '';
356
+
357
+ /** `report.md` and the table rows `report.json` carries, both rendered from the composed report. */
358
+ export function renderReport(composed, verified) {
359
+ const tables = tableRows(composed, verified);
360
+ const { run_context: context } = verified;
361
+ const blanks = composed.remaining.map(x => [x.item, x.runbook_ids.join(', ')]);
362
+ const latest = composed.completeness.map(row => row.after ?? row.before);
363
+ const markdown = `# optbrew 보고서\n\n${verified.data_class === 'synthetic' ? `**${SYNTHETIC_BANNER}**\n\n` : ''}` +
364
+ `대상: ${html(context.target)} \n환경: ${html(context.environment)} \n목표: ${html(context.goals.join(', '))} \n` +
365
+ `비교: ${html(verified.comparison.status)} (${html(verified.comparison.reasons.join(', ') || '없음')}) \n` +
366
+ `결론: ${html(composed.headline.text)}${composed.headline.not_indexed ? ` \n색인 제외: ${html(composed.headline.not_indexed.text)}` : ''}\n\n` +
367
+ `## ${SECTION_TITLES.fixes}\n\n${table(['우선순위', '문제 / 주소', '근거', '고르는 해결 방법', '고칠 위치 또는 사이트 소유자', '비용 / 노력', '확인 방법', '상태'], tables.remediation)}\n\n` +
368
+ `## ${SECTION_TITLES.completeness} (확인한 범위 안의 통과율)\n\n요약: \n${latest.map(phase => `- ${html(headlineLine(phase.headline))} \n`).join('')}\n` +
369
+ `${table(['영역', '이전', '이후', '차이', '확인 범위 이전 → 이후', '점수 범위', '상태', '방문자 실측(CWV)을 뺀 확인 범위(최신)'], tables.score)}\n\n` +
370
+ `## ${SECTION_TITLES.blank}\n\n${table(['비어 있는 것과 채우면 알 수 있는 것', '런북'], blanks)}\n\n` +
371
+ `## ${SECTION_TITLES.conditions}\n\n${[...composed.conditions.lines.map(({ label, value }) => `${label}: ${value}`), ...composed.conditions.notes].map(line => `- ${html(line)}\n`).join('')}\n` +
372
+ `## ${SECTION_TITLES.remaining}\n\n${table(['문제', '이유', '필요한 입력 / 권한', '다음 행동', '다시 볼 때'], tables.remaining_work)}\n\n` +
373
+ `## ${SECTION_TITLES.outcomes}\n\n${table(['검색 엔진 / 속성', '지표', '기간 / 표본', '이전 원값', '이후 원값', '차이', '완료 여부', '한계'], tables.actual_outcomes)}\n\n` +
374
+ (tables.model_judgments.length ? `## ${SECTION_TITLES.judgments}\n\n${html(`모델이 해당 없음으로 판정한 단위: ${notApplicableText(composed.conditions.model_not_applicable)}`)}\n\n${questionLines(composed.conditions.question_coverage)}${table(['단계', '검사 / 대상', '상태', '근거 구간 (UTF-8 바이트, 끝 제외)', '근거'], tables.model_judgments)}\n\n` : '') +
375
+ `증거 폴더: ${html(verified.evidence_root)}; 실행 ID: ${html(context.run_id)}. 점수 계산에 쓴 원본 값은 report.json에서 다시 계산할 수 있습니다.\n`;
376
+ return { markdown, tables };
377
+ }