@iwonz/ywtc 3.4.1 → 3.6.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.
package/dist/report.js CHANGED
@@ -1,21 +1,11 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdir, rename, writeFile } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { IssueCollector } from './issues.js';
5
- const pageStatuses = [
6
- 'discovered',
7
- 'planned',
8
- 'skeleton-created',
9
- 'not-started',
10
- 'complete',
11
- 'partial',
12
- 'conflict',
13
- 'failed',
14
- 'blocked',
15
- ];
16
- const resourceStatuses = ['planned', 'not-started', 'blocked', 'complete', 'partial', 'failed'];
17
- const edgeStatuses = ['resolved', 'external', 'inaccessible', 'broken', 'cycle', 'unsupported'];
18
- const commentStatuses = ['planned', 'not-started', 'blocked', 'complete', 'partial', 'failed', 'conflict', 'stale', 'skipped'];
4
+ import { fileURLToPath } from 'node:url';
5
+ import { redact } from './logger.js';
6
+ import { IssueCollector, } from './issues.js';
7
+ import { commentStatuses, edgeStatuses, pageStatuses, resourceStatuses, } from './report-contract.js';
8
+ export const REPORT_DATA_MARKER = '{"__ywtc_report_payload__":true}';
19
9
  function countRecord(values) {
20
10
  return Object.fromEntries(values.map((value) => [value, 0]));
21
11
  }
@@ -76,191 +66,174 @@ function emptySummary() {
76
66
  traversalStatuses: countRecord(edgeStatuses),
77
67
  };
78
68
  }
79
- function escapeHtml(value) {
80
- return String(value ?? '')
81
- .replaceAll('&', '&')
82
- .replaceAll('<', '&lt;')
83
- .replaceAll('>', '&gt;')
84
- .replaceAll('"', '&quot;')
85
- .replaceAll("'", '&#39;');
69
+ function issueCategory(issue) {
70
+ if (issue.category)
71
+ return issue.category;
72
+ const value = `${issue.code} ${issue.message}`.toLocaleLowerCase();
73
+ if (issue.severity === 'blocked' || issue.blockedByPageId)
74
+ return 'dependency';
75
+ if (issue.phase === 'comment' || /comment/.test(value))
76
+ return 'comment';
77
+ if (issue.phase === 'resource' || /attachment|resource|image|upload|download/.test(value))
78
+ return 'attachment-image';
79
+ if (issue.phase === 'verification' || /verif|fingerprint|differs|mismatch/.test(value))
80
+ return 'verification';
81
+ if (/rate.?limit|\b429\b|retry-after/.test(value))
82
+ return 'rate-limit';
83
+ if (/\b401\b|auth|token|credential/.test(value))
84
+ return 'authorization';
85
+ if (/\b403\b|forbidden|permission|access denied/.test(value))
86
+ return 'permissions';
87
+ if (/timeout|timed out|dns|tls|socket|network|econn|enotfound/.test(value))
88
+ return 'network';
89
+ if (/parse|schema|invalid (?:html|xml|yfm|storage)|malformed/.test(value))
90
+ return 'parsing';
91
+ if (/render|markup|layout|format|fallback/.test(value))
92
+ return 'rendering-layout';
93
+ if (/hierarchy|parent|ancestor|reparent|subtree/.test(value))
94
+ return 'hierarchy';
95
+ if (/conflict|duplicate|title|occupied/.test(value))
96
+ return 'conflict';
97
+ if (/source-status|excluded|filter/.test(value))
98
+ return 'filtering';
99
+ if (/http|api|\b4\d\d\b|\b5\d\d\b|not found/.test(value))
100
+ return 'provider-api';
101
+ return 'unknown';
86
102
  }
87
- function safeHttpUrl(value) {
88
- if (!value)
89
- return undefined;
90
- try {
91
- const url = new URL(value);
92
- return ['http:', 'https:'].includes(url.protocol) ? url.toString() : undefined;
93
- }
94
- catch {
95
- return undefined;
96
- }
97
- }
98
- function statusClass(value) {
99
- return value === 'complete' || value === 'completed' ? 'ok'
100
- : /partial|warning|conflict|planned|not-started|discovered|skeleton|interrupted|cancelled/.test(value) ? 'warn'
101
- : /failed|blocked|error/.test(value) ? 'bad'
102
- : 'muted';
103
+ function issueObjectType(issue) {
104
+ if (issue.objectType)
105
+ return issue.objectType;
106
+ if (issue.resourceId)
107
+ return 'resource';
108
+ if (issue.commentId || issue.phase === 'comment')
109
+ return 'comment';
110
+ if (issue.pageId)
111
+ return 'page';
112
+ return 'run';
103
113
  }
104
- function pageAnchor(sourceCanonicalId) {
105
- return `page-${createHash('sha256').update(sourceCanonicalId).digest('hex').slice(0, 12)}`;
106
- }
107
- function formatBytes(value) {
108
- if (value < 1024)
109
- return `${value} B`;
110
- const units = ['KiB', 'MiB', 'GiB', 'TiB'];
111
- let amount = value;
112
- let unit = -1;
113
- do {
114
- amount /= 1024;
115
- unit += 1;
116
- } while (amount >= 1024 && unit < units.length - 1);
117
- return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${units[unit]}`;
118
- }
119
- function formatDuration(durationMs) {
120
- if (durationMs === undefined)
121
- return 'in progress';
122
- if (durationMs < 1000)
123
- return `${durationMs} ms`;
124
- const seconds = Math.round(durationMs / 1000);
125
- if (seconds < 60)
126
- return `${seconds} s`;
127
- const minutes = Math.floor(seconds / 60);
128
- return `${minutes} min ${seconds % 60} s`;
129
- }
130
- function issueAction(issue) {
114
+ function issueAction(issue, category) {
131
115
  if (issue.severity === 'blocked')
132
116
  return 'Resolve the upstream page error, then repeat the same migration; this object will resume automatically.';
133
117
  if (issue.retryable)
134
118
  return 'Repeat the same migration to retry this object.';
135
119
  if (issue.severity === 'degradation')
136
120
  return 'Data remains represented; compare the source and destination previews.';
137
- if (issue.severity === 'error')
138
- return 'Review the reason and affected object before resuming the migration.';
139
121
  if (issue.severity === 'normalization')
140
122
  return 'No action is required.';
141
- return 'Review the affected object and the page details below.';
142
- }
143
- function statCard(label, value, detail) {
144
- return `<div class="stat"><small>${escapeHtml(label)}</small><strong>${escapeHtml(value)}</strong>${detail ? `<span>${escapeHtml(detail)}</span>` : ''}</div>`;
123
+ if (category === 'authorization')
124
+ return 'Check the selected connection token, then run doctor and repeat the migration.';
125
+ if (category === 'permissions')
126
+ return 'Grant the token user access to the affected object, then repeat the migration.';
127
+ if (category === 'parsing' || category === 'rendering-layout')
128
+ return 'Inspect the comparison and fallback, then report the unsupported construct if the source data is not represented.';
129
+ if (category === 'conflict')
130
+ return 'Review the destination change or title conflict before repeating the migration.';
131
+ if (issue.severity === 'error')
132
+ return 'Review the reason and affected object before resuming the migration.';
133
+ return 'Review the affected object and its page details.';
145
134
  }
146
- function statusCells(counts, statuses) {
147
- return statuses.map((status) => `<td><span class="status ${statusClass(status)}">${escapeHtml(status)}</span><strong>${escapeHtml(counts[status] ?? 0)}</strong></td>`).join('');
135
+ function serializePayload(payload) {
136
+ return JSON.stringify(payload)
137
+ .replaceAll('&', '\\u0026')
138
+ .replaceAll('<', '\\u003c')
139
+ .replaceAll('>', '\\u003e')
140
+ .replaceAll('\u2028', '\\u2028')
141
+ .replaceAll('\u2029', '\\u2029');
148
142
  }
149
- function renderReport(data, previews) {
150
- const sourceUrl = safeHttpUrl(data.source.url);
151
- const destinationUrl = safeHttpUrl(data.destination.url);
152
- const inventoryState = data.summary.inventoryComplete
153
- ? `${data.summary.inventoriedPages}/${data.summary.pages} pages inspected`
154
- : `${data.summary.inventoriedPages}/${data.summary.pages} pages inspected · ${data.summary.inventoryFailedPages} failed`;
155
- const sourceCards = [
156
- statCard('Selected pages', data.summary.pages, 'accessible in selected scope'),
157
- statCard('Returned descendants', data.summary.returnedDescendants, `${data.summary.excludedPages} excluded by status`),
158
- statCard('Tree depth', data.summary.maximumDepth, `${data.summary.hierarchyGaps} hierarchy gaps · ${data.summary.hierarchyRepairs} repaired`),
159
- statCard('Files', data.summary.resources, `${data.summary.attachments} attachments · ${data.summary.resourceImages} images`),
160
- statCard('Source bytes', formatBytes(data.summary.sourceBytes), data.summary.bytesUnknown ? `${data.summary.bytesUnknown} sizes unknown` : 'all sizes known'),
161
- statCard('Transferred bytes', formatBytes(data.summary.bytes), 'confirmed complete files'),
162
- statCard('Links', data.summary.links, `${data.summary.images} embedded images`),
163
- statCard('Structured content', data.summary.tables + data.summary.codeBlocks, `${data.summary.tables} tables · ${data.summary.grids} grids · ${data.summary.codeBlocks} code blocks`),
164
- statCard('Comments', data.summary.comments, `${data.summary.commentThreads} threads · ${data.summary.commentParticipants} participants`),
165
- statCard('Comment files', data.summary.commentAttachments, formatBytes(data.summary.commentBytes)),
166
- ].join('');
167
- const excludedRows = data.selection.excluded.map((page) => `<tr><td>${escapeHtml(page.title ?? page.locator)}</td><td><code>${escapeHtml(page.id)}</code></td><td>${escapeHtml(page.status ?? 'non-current')}</td><td>${escapeHtml(page.reason)}</td></tr>`).join('');
168
- const gapRows = data.selection.hierarchyGaps.map((gap) => `<tr><td><code>${escapeHtml(gap.pageId)}</code></td><td><code>${escapeHtml(gap.missingParentId ?? 'unknown')}</code></td><td><code>${escapeHtml(gap.selectedParentId)}</code></td><td>${escapeHtml(gap.reason)}</td></tr>`).join('');
169
- const repairRows = data.hierarchyRepairs.map((repair) => `<tr><td><code>${escapeHtml(repair.sourceCanonicalId)}</code></td><td>${escapeHtml(repair.mode)}</td><td><code>${escapeHtml(repair.oldParentId ?? 'none')}</code></td><td><code>${escapeHtml(repair.newParentId)}</code></td><td><code>${escapeHtml(repair.oldDestinationId)} → ${escapeHtml(repair.newDestinationId)}</code></td><td>${repair.verified ? 'verified' : 'planned'}</td></tr>`).join('');
170
- const selectionDetails = excludedRows || gapRows || repairRows || data.selection.notes.length
171
- ? `<section class="panel" aria-labelledby="selection-title"><div class="section-head"><div><h2 id="selection-title">Selection and hierarchy</h2><p>Every source-status exclusion, projected hierarchy gap, and destination repair recorded for this run.</p></div></div>${excludedRows ? `<div class="table-wrap"><h3>Excluded pages</h3><table><thead><tr><th>Page</th><th>Source ID</th><th>Status</th><th>Reason</th></tr></thead><tbody>${excludedRows}</tbody></table></div>` : ''}${gapRows ? `<div class="table-wrap"><h3>Hierarchy gaps</h3><table><thead><tr><th>Page</th><th>Missing parent</th><th>Projected parent</th><th>Reason</th></tr></thead><tbody>${gapRows}</tbody></table></div>` : ''}${repairRows ? `<div class="table-wrap"><h3>Destination repairs</h3><table><thead><tr><th>Source page</th><th>Mode</th><th>Old parent</th><th>New parent</th><th>Destination mapping</th><th>Outcome</th></tr></thead><tbody>${repairRows}</tbody></table></div>` : ''}${data.selection.notes.length ? `<div class="callout"><h3>Provider notes</h3><ul>${data.selection.notes.map((note) => `<li>${escapeHtml(note)}</li>`).join('')}</ul></div>` : ''}</section>`
172
- : '';
173
- const pageOutcomeStatuses = ['complete', 'partial', 'failed', 'blocked', 'conflict', 'not-started', 'planned', 'discovered', 'skeleton-created'];
174
- const resourceOutcomeStatuses = ['complete', 'partial', 'failed', 'blocked', 'not-started', 'planned'];
175
- const commentOutcomeStatuses = ['complete', 'partial', 'failed', 'blocked', 'not-started', 'conflict', 'stale', 'skipped', 'planned'];
176
- const attentionIssues = data.issues.filter((issue) => issue.severity !== 'normalization');
177
- const normalizationIssues = data.issues.filter((issue) => issue.severity === 'normalization');
178
- const attentionSummary = [
179
- `${data.summary.errors} errors`,
180
- `${data.summary.dependentPages + data.summary.dependentFiles + data.summary.dependentComments} dependent blocks`,
181
- `${data.summary.warnings} warnings`,
182
- `${data.summary.degradations} degradations`,
183
- `${data.summary.retryableFailures} retryable`,
184
- ].map((item) => `<span>${escapeHtml(item)}</span>`).join('');
185
- const issueRow = (issue) => {
186
- const target = issue.pageAnchor ? `<a href="#${escapeHtml(issue.pageAnchor)}">${escapeHtml(issue.pageTitle ?? issue.pageId)}</a>` : '<span>run-level</span>';
187
- const upstream = issue.blockedByPageId ? ` · blocked by <a href="#${escapeHtml(pageAnchor(issue.blockedByPageId))}">${escapeHtml(issue.blockedByPageId)}</a>${issue.blockedByCode ? ` (${escapeHtml(issue.blockedByCode)})` : ''}` : '';
188
- return `<li class="issue ${escapeHtml(issue.severity)}"><div><span class="status ${statusClass(issue.severity)}">${escapeHtml(issue.severity)}</span><strong>${escapeHtml(issue.code)}</strong><span>${escapeHtml(issue.phase)}</span>${issue.retryable ? '<span class="retry">retryable</span>' : ''}${issue.occurrences > 1 ? `<span>${issue.occurrences} occurrences</span>` : ''}</div><p>${escapeHtml(issue.message)}</p><p class="issue-context">Affected: ${target}${issue.resourceName ? ` · resource <strong>${escapeHtml(issue.resourceName)}</strong>` : ''}${upstream}</p><p class="action">${escapeHtml(issue.action)}</p></li>`;
189
- };
190
- const attention = `<section class="panel attention" aria-labelledby="attention-title"><div class="section-head"><div><h2 id="attention-title">Needs attention</h2><p>Problems and fidelity changes that may require review or a rerun.</p></div><span class="count ${attentionIssues.length ? 'warn' : 'ok'}">${attentionIssues.length}</span></div><div class="mini-stats">${attentionSummary}</div>${attentionIssues.length ? `<ul class="issue-list">${attentionIssues.map(issueRow).join('')}</ul>` : '<p class="empty ok">No errors, warnings, conflicts, or degradations were recorded.</p>'}</section>`;
191
- const dependencyGroups = new Map();
192
- const dependencyGroup = (rootId, code) => {
193
- const group = dependencyGroups.get(rootId) ?? { pages: [], files: [], comments: [], ...(code ? { code } : {}) };
194
- dependencyGroups.set(rootId, group);
195
- return group;
196
- };
197
- for (const page of data.pages) {
198
- if (page.status === 'blocked' && page.blockedByPageId)
199
- dependencyGroup(page.blockedByPageId, page.blockedByCode).pages.push(page.sourceTitle);
200
- for (const resource of page.resourceItems) {
201
- if (resource.status === 'blocked' && resource.blockedByPageId)
202
- dependencyGroup(resource.blockedByPageId, resource.blockedByCode).files.push(`${page.sourceTitle}: ${resource.sourceName}`);
143
+ function sanitizedString(value, secrets) {
144
+ const redacted = redact(value, [...secrets]);
145
+ try {
146
+ const url = new URL(redacted);
147
+ if (!['http:', 'https:', 'file:'].includes(url.protocol))
148
+ return redacted;
149
+ url.username = '';
150
+ url.password = '';
151
+ for (const key of [...url.searchParams.keys()]) {
152
+ if (/token|secret|auth|credential|signature|(^|[-_])key$|^x-amz-/i.test(key))
153
+ url.searchParams.set(key, '[REDACTED]');
203
154
  }
204
- if (page.comments.status === 'blocked' && page.comments.blockedByPageId)
205
- dependencyGroup(page.comments.blockedByPageId, page.comments.blockedByCode).comments.push(page.sourceTitle);
155
+ return url.toString();
156
+ }
157
+ catch {
158
+ return redacted;
206
159
  }
207
- const dependencySection = dependencyGroups.size > 0
208
- ? `<section class="panel" aria-labelledby="dependencies-title"><div class="section-head"><div><h2 id="dependencies-title">Blocked dependencies</h2><p>Dependent work was not attempted. Resolve the root error and repeat the same migration.</p></div><span class="count">${dependencyGroups.size}</span></div>${[...dependencyGroups.entries()].map(([rootId, group]) => `<div class="callout warn"><h3><a href="#${escapeHtml(pageAnchor(rootId))}">${escapeHtml(data.pages.find((page) => page.sourceCanonicalId === rootId)?.sourceTitle ?? rootId)}</a>${group.code ? ` · <code>${escapeHtml(group.code)}</code>` : ''}</h3><p>${group.pages.length} pages · ${group.files.length} files · ${group.comments.length} comment archives</p>${group.pages.length ? `<p><strong>Pages:</strong> ${group.pages.map(escapeHtml).join(', ')}</p>` : ''}${group.files.length ? `<p><strong>Files:</strong> ${group.files.map(escapeHtml).join(', ')}</p>` : ''}</div>`).join('')}</section>`
209
- : '';
210
- const pageRows = data.pages.map((page) => {
211
- const source = safeHttpUrl(page.sourceUrl);
212
- const destination = safeHttpUrl(page.destinationUrl);
213
- const preview = previews.get(page.sourceCanonicalId);
214
- const pageIssues = data.issues.filter((issue) => issue.pageId === page.sourceCanonicalId);
215
- const resourceRows = page.resourceItems.map((resource) => `<tr><td><strong>${escapeHtml(resource.sourceName)}</strong><small>${escapeHtml(resource.kind)}</small></td><td><span class="status ${statusClass(resource.status)}">${escapeHtml(resource.status)}</span></td><td>${resource.sourceBytes === undefined ? 'unknown' : escapeHtml(formatBytes(resource.sourceBytes))}</td><td>${resource.transferredBytes === undefined ? '—' : escapeHtml(formatBytes(resource.transferredBytes))}</td><td>${escapeHtml(resource.destinationName ?? '—')}</td><td>${resource.error ? `<span class="bad">${escapeHtml(resource.error)}</span>` : '—'}</td></tr>`).join('');
216
- const metrics = `${page.resources} files · ${page.links} links · ${page.images} images · ${page.tables} tables · ${page.codeBlocks} code blocks`;
217
- return `<details id="${escapeHtml(pageAnchor(page.sourceCanonicalId))}" class="page" style="--depth:${Math.min(page.depth, 8)}"${page.status === 'complete' ? '' : ' open'}><summary><span class="status ${statusClass(page.status)}">${escapeHtml(page.status)}</span><strong>${escapeHtml(page.sourceTitle)}</strong><span>${escapeHtml(metrics)}</span></summary>
218
- <div class="meta"><span>inventory: <strong>${escapeHtml(page.inventoryStatus)}</strong></span><span>depth ${page.depth}</span>${page.parentId ? `<span>parent ${escapeHtml(page.parentId)}</span>` : ''}${source ? `<a href="${escapeHtml(source)}" target="_blank" rel="noreferrer">source</a>` : ''}${destination ? `<a href="${escapeHtml(destination)}" target="_blank" rel="noreferrer">destination</a>` : ''}<span>${escapeHtml(page.sourceCanonicalId)}</span><span>source ${page.sourceBytes === undefined ? 'size unknown' : escapeHtml(formatBytes(page.sourceBytes))} · transferred ${escapeHtml(formatBytes(page.transferredBytes))}${page.bytesUnknown ? ` · ${page.bytesUnknown} unknown` : ''}</span></div>
219
- ${page.error ? `<div class="callout bad"><strong>Page error</strong><p>${escapeHtml(page.error)}</p></div>` : ''}
220
- <div class="callout ${page.comments.status === 'complete' || page.comments.status === 'skipped' ? '' : 'warn'}"><h3>Comments · <span class="status ${statusClass(page.comments.status)}">${escapeHtml(page.comments.status)}</span></h3><p>${page.comments.threads} threads · ${page.comments.comments} entries · ${page.comments.participants} participants · ${page.comments.attachments} archived files · ${escapeHtml(formatBytes(page.comments.bytes))}${page.comments.archiveName ? ` · <strong>${escapeHtml(page.comments.archiveName)}</strong>` : ''}</p>${page.comments.error ? `<p class="bad">${escapeHtml(page.comments.error)}</p>` : ''}${page.comments.preview ? `<details><summary>Destination archive preview</summary><pre>${escapeHtml(page.comments.preview)}</pre></details>` : ''}</div>
221
- ${pageIssues.length ? `<div><h3>Page issues</h3><ul class="issue-list compact">${pageIssues.map(issueRow).join('')}</ul></div>` : ''}
222
- ${page.resourceItems.length ? `<div class="table-wrap"><h3>Resources</h3><table><thead><tr><th>Source resource</th><th>Outcome</th><th>Source size</th><th>Transferred</th><th>Destination name</th><th>Problem</th></tr></thead><tbody>${resourceRows}</tbody></table></div>` : '<p class="muted">No transferable resources on this page.</p>'}
223
- ${preview ? `<div class="compare"><section><h3>Source preview</h3><p class="muted">Normalized source content</p><iframe title="Source preview: ${escapeHtml(page.sourceTitle)}" sandbox srcdoc="${escapeHtml(preview.source)}"></iframe></section><section><h3>Destination preview</h3><p class="muted">Body read back from destination</p><iframe title="Destination preview: ${escapeHtml(page.sourceTitle)}" sandbox srcdoc="${escapeHtml(preview.destination)}"></iframe></section></div>` : '<p class="muted">Destination preview is unavailable because no body was read back.</p>'}
224
- ${page.resourceFallbacks.length ? `<div class="callout warn"><h3>Resource fallbacks</h3><ul>${page.resourceFallbacks.map((item) => `<li><strong>${escapeHtml(item.name)}</strong> — ${escapeHtml(item.error)}</li>`).join('')}</ul></div>` : ''}
225
- ${(page.normalizations ?? []).length ? `<div><h3>Safe normalizations</h3><ul>${(page.normalizations ?? []).map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul></div>` : ''}
226
- </details>`;
227
- }).join('');
228
- const edgeRows = data.edges.map((edge) => {
229
- const from = data.pages.find((page) => page.sourceCanonicalId === edge.from)?.sourceTitle ?? edge.from;
230
- const to = edge.to ? data.pages.find((page) => page.sourceCanonicalId === edge.to)?.sourceTitle ?? edge.to : '—';
231
- return `<tr><td><span class="status ${statusClass(edge.status)}">${escapeHtml(edge.status)}</span></td><td>${escapeHtml(from)}</td><td>${escapeHtml(to)}</td><td><code>${escapeHtml(edge.raw)}</code></td><td>${escapeHtml(edge.message ?? '—')}</td></tr>`;
232
- }).join('');
233
- const traversalSummary = edgeStatuses.filter((status) => data.summary.traversalStatuses[status] > 0).map((status) => `<span><strong>${data.summary.traversalStatuses[status]}</strong> ${escapeHtml(status)}</span>`).join('');
234
- const traversalBody = data.edges.length
235
- ? `<div class="mini-stats">${traversalSummary}</div><div class="table-wrap"><table><thead><tr><th>Status</th><th>From</th><th>To</th><th>Reference</th><th>Reason</th></tr></thead><tbody>${edgeRows}</tbody></table></div>`
236
- : data.scope === 'linked'
237
- ? '<p class="empty">No traversal edges were discovered in linked scope.</p>'
238
- : `<p class="empty">Traversal edges are collected only for <code>linked</code> scope. This ${escapeHtml(data.scope)} run still inventoried ${data.summary.links} content links and verified them as part of each page body.</p>`;
239
- const outputRows = data.outputs.map((output) => `<li><strong>${escapeHtml(output.label)}</strong> <code>${escapeHtml(output.value)}</code></li>`).join('');
240
- const normalizations = normalizationIssues.length
241
- ? `<section class="panel"><div class="section-head"><div><h2>Safe normalizations</h2><p>Expected provider changes that did not lose meaningful data.</p></div><span class="count">${normalizationIssues.length}</span></div><ul class="issue-list compact">${normalizationIssues.map(issueRow).join('')}</ul></section>`
242
- : '';
243
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ywtc transfer ${escapeHtml(data.runId)}</title><style>
244
- :root{font:15px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f4f6fa;color-scheme:light}*{box-sizing:border-box}body{margin:0}a{color:#175cd3}main{max-width:1360px;margin:auto;padding:32px}.head,.section-head{display:flex;justify-content:space-between;gap:20px;align-items:flex-start}.head h1{margin:0}.head p,.section-head p{margin:.25rem 0}.muted{color:#667085}.run-meta,.meta,.mini-stats{display:flex;flex-wrap:wrap;gap:10px 18px}.run-meta{margin-top:18px;padding:14px 0;border-top:1px solid #dfe4ed}.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin:18px 0}.stat,.page,.panel{background:#fff;border:1px solid #dfe4ed;border-radius:12px;box-shadow:0 1px 2px rgba(16,24,40,.04)}.stat{padding:14px}.stat small,.stat strong,.stat span{display:block}.stat strong{font-size:24px}.stat span{font-size:12px;color:#667085;margin-top:2px}.panel{padding:20px;margin-top:20px;overflow:hidden}.panel h2{margin:0}.count{display:grid;place-items:center;min-width:38px;height:38px;border-radius:20px;background:#eef2f8;font-weight:800}.page{margin:10px 0 10px calc(var(--depth) * 12px);padding:14px;scroll-margin-top:18px}.page:target{outline:3px solid #84adff}.page summary{display:grid;grid-template-columns:120px minmax(180px,1fr) auto;gap:12px;cursor:pointer;align-items:center}.status{display:inline-block;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.03em}.ok{color:#087443}.warn{color:#9a6500}.bad{color:#b42318}.meta{margin:14px 0;color:#667085;font-size:13px}.compare{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:16px}.compare section{min-width:0}.compare h3{margin-bottom:0}.compare iframe{width:100%;height:420px;border:1px solid #dfe4ed;border-radius:8px;background:#fff}.table-wrap{overflow:auto;margin-top:14px}table{border-collapse:collapse;width:100%;min-width:680px}th,td{padding:9px;border-bottom:1px solid #e6e9ef;text-align:left;vertical-align:top}th{font-size:12px;text-transform:uppercase;color:#667085}td strong,td small{display:block}.outcomes td strong{font-size:18px;margin-top:3px}.issue-list{list-style:none;padding:0;margin:14px 0 0}.issue{border-left:4px solid #f79009;background:#fffaeb;padding:12px 14px;margin:8px 0;border-radius:6px}.issue.error{border-color:#f04438;background:#fef3f2}.issue.degradation{border-color:#f79009}.issue.normalization{border-color:#98a2b3;background:#f9fafb}.issue>div{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.issue p{margin:.35rem 0}.issue-context,.action{font-size:13px}.retry{font-size:11px;background:#fff0c2;border-radius:12px;padding:2px 7px}.compact .issue{padding:9px 11px}.callout{padding:12px 14px;border-radius:8px;background:#f9fafb;margin:12px 0}.callout.warn{background:#fffaeb}.callout.bad{background:#fef3f2}.empty{padding:14px;border-radius:8px;background:#f9fafb}.attention{border-top:4px solid #f79009}.attention:has(.empty.ok){border-top-color:#12b76a}.context-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}.context-grid dl{margin:0}.context-grid dt{font-size:12px;text-transform:uppercase;color:#667085}.context-grid dd{margin:2px 0 12px;overflow-wrap:anywhere}code{overflow-wrap:anywhere}@media(max-width:760px){main{padding:16px}.page{margin-left:0}.page summary{grid-template-columns:1fr}.compare{grid-template-columns:1fr}.head,.section-head{display:block}.count{margin-top:10px}.stats{grid-template-columns:1fr 1fr}.panel{padding:14px}}@media(max-width:420px){.stats{grid-template-columns:1fr}}
245
- </style></head><body><main><header class="head"><div><h1>ywtc transfer report</h1><p>${escapeHtml(data.direction)} · ${escapeHtml(data.scope)} · source status ${escapeHtml(data.sourceStatus)} · ${data.dryRun ? 'dry-run' : 'live transfer'}</p></div><div><span class="status ${statusClass(data.status)}">${escapeHtml(data.status)}</span><p class="muted">${escapeHtml(data.runId)}</p></div></header>
246
- <div class="run-meta"><span>Started <strong>${escapeHtml(data.startedAt)}</strong></span>${data.finishedAt ? `<span>Finished <strong>${escapeHtml(data.finishedAt)}</strong></span>` : ''}<span>Duration <strong>${escapeHtml(formatDuration(data.summary.durationMs))}</strong></span><span>Inventory <strong class="${data.summary.inventoryComplete ? 'ok' : 'warn'}">${escapeHtml(inventoryState)}</strong></span></div>
247
- <section class="panel" aria-labelledby="source-title"><div class="section-head"><div><h2 id="source-title">Source scope inventory</h2><p>Root plus ${escapeHtml(data.summary.returnedDescendants)} accessible descendants; ${escapeHtml(data.summary.selectedDescendants)} selected and ${escapeHtml(data.summary.excludedPages)} excluded with source status <code>${escapeHtml(data.sourceStatus)}</code>.</p></div><span class="status ${data.summary.inventoryComplete ? 'ok' : 'warn'}">${data.summary.inventoryComplete ? 'complete' : 'incomplete'}</span></div><div class="stats">${sourceCards}</div></section>
248
- ${selectionDetails}
249
- <section class="panel" aria-labelledby="outcome-title"><div class="section-head"><div><h2 id="outcome-title">Transfer outcomes</h2><p>Every selected page, discovered file, and page comment archive is counted in exactly one status.</p></div><span class="count">${data.summary.pages}</span></div><div class="callout"><strong>Causes</strong><p>${data.summary.rootErrors} root errors · ${data.summary.dependentPages} dependent pages · ${data.summary.dependentFiles} dependent files · ${data.summary.dependentComments} dependent comment archives</p></div><div class="table-wrap"><table class="outcomes"><thead><tr><th>Object</th>${pageOutcomeStatuses.map((status) => `<th>${escapeHtml(status)}</th>`).join('')}<th>Total</th></tr></thead><tbody><tr><th>Pages</th>${statusCells(data.summary.pageStatuses, pageOutcomeStatuses)}<td><strong>${data.summary.pages}</strong></td></tr></tbody></table></div><div class="table-wrap"><table class="outcomes"><thead><tr><th>Object</th>${resourceOutcomeStatuses.map((status) => `<th>${escapeHtml(status)}</th>`).join('')}<th>Total</th></tr></thead><tbody><tr><th>Files</th>${statusCells(data.summary.resourceStatuses, resourceOutcomeStatuses)}<td><strong>${data.summary.resources}</strong></td></tr></tbody></table></div><div class="table-wrap"><table class="outcomes"><thead><tr><th>Object</th>${commentOutcomeStatuses.map((status) => `<th>${escapeHtml(status)}</th>`).join('')}<th>Total</th></tr></thead><tbody><tr><th>Page comment archives</th>${statusCells(data.summary.commentStatuses, commentOutcomeStatuses)}<td><strong>${data.summary.pages}</strong></td></tr></tbody></table></div></section>
250
- ${attention}
251
- ${dependencySection}
252
- <section aria-labelledby="pages-title"><div class="section-head"><div><h2 id="pages-title">Pages</h2><p>Projected hierarchy with exact outcomes, source metrics, resources, issues, and verified previews.</p></div></div>${pageRows || '<p class="empty">No source page was resolved before the run stopped.</p>'}</section>
253
- <section class="panel" aria-labelledby="links-title"><div class="section-head"><div><h2 id="links-title">Link traversal</h2><p>Traversal evidence used to select pages in linked scope; content link totals are shown in source inventory.</p></div><span class="count">${data.edges.length}</span></div>${traversalBody}</section>
254
- ${normalizations}
255
- <section class="panel" aria-labelledby="context-title"><h2 id="context-title">Run context and outputs</h2><div class="context-grid"><dl><dt>Source</dt><dd>${sourceUrl ? `<a href="${escapeHtml(sourceUrl)}">${escapeHtml(data.source.title)}</a>` : escapeHtml(data.source.title)}<br>${escapeHtml(data.source.provider)} · profile ${escapeHtml(data.source.profile)} · root ${escapeHtml(data.source.rootId)}</dd><dt>Destination</dt><dd>${destinationUrl ? `<a href="${escapeHtml(destinationUrl)}">${escapeHtml(data.destination.provider)}</a>` : escapeHtml(data.destination.provider)}${data.destination.profile ? ` · profile ${escapeHtml(data.destination.profile)}` : ''}<br>${escapeHtml(data.destination.id)}</dd></dl><dl><dt>State</dt><dd>${escapeHtml(data.state.id)}<br><code>${escapeHtml(data.state.path)}</code><br>${escapeHtml(data.state.mode)} · ${escapeHtml(data.state.source)}</dd>${data.state.backupPath ? `<dt>State backup</dt><dd><code>${escapeHtml(data.state.backupPath)}</code></dd>` : ''}<dt>Outputs</dt><dd>${outputRows ? `<ul>${outputRows}</ul>` : 'No result output was created.'}</dd></dl></div><p><a class="download" download="report.json" href="report.json">Download report JSON</a></p></section>
256
- </main></body></html>`;
160
+ }
161
+ function sanitizedValue(value, secrets) {
162
+ if (typeof value === 'string')
163
+ return sanitizedString(value, secrets);
164
+ if (Array.isArray(value))
165
+ return value.map((item) => sanitizedValue(item, secrets));
166
+ if (value && typeof value === 'object') {
167
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizedValue(item, secrets)]));
168
+ }
169
+ return value;
170
+ }
171
+ export function injectReportPayload(shell, payload) {
172
+ const occurrences = shell.split(REPORT_DATA_MARKER).length - 1;
173
+ if (occurrences !== 1)
174
+ throw new Error(`Compiled report shell must contain exactly one data marker; found ${occurrences}`);
175
+ return shell.replace(REPORT_DATA_MARKER, serializePayload(payload));
176
+ }
177
+ let shellPromise;
178
+ async function loadReportShell() {
179
+ shellPromise ??= (async () => {
180
+ const candidates = [
181
+ fileURLToPath(new URL('./report-ui/index.html', import.meta.url)),
182
+ fileURLToPath(new URL('../dist/report-ui/index.html', import.meta.url)),
183
+ ];
184
+ const errors = [];
185
+ for (const filename of [...new Set(candidates)]) {
186
+ try {
187
+ return await readFile(filename, 'utf8');
188
+ }
189
+ catch (error) {
190
+ errors.push(`${filename}: ${error instanceof Error ? error.message : String(error)}`);
191
+ }
192
+ }
193
+ throw new Error(`Compiled report shell is unavailable. Run "npm run build:report". ${errors.join('; ')}`);
194
+ })();
195
+ return shellPromise;
196
+ }
197
+ function reportExcludedPages(selection, data) {
198
+ const selectedByProviderId = new Map(data.pages.map((page) => [page.sourceId, page]));
199
+ const excludedCanonicalByProviderId = new Map(selection.excluded.map((page) => [
200
+ page.id,
201
+ page.canonicalId ?? `${data.source.profile}:${data.source.provider}:${page.id}`,
202
+ ]));
203
+ return selection.excluded.map((page) => {
204
+ const sourceCanonicalId = excludedCanonicalByProviderId.get(page.id);
205
+ const ancestors = page.ancestorIds ?? [];
206
+ const projectedParentId = [page.parentId, ...ancestors.slice().reverse(), page.discoveredFromId]
207
+ .find((candidate) => candidate && (selectedByProviderId.has(candidate) || excludedCanonicalByProviderId.has(candidate)));
208
+ const canonicalParent = projectedParentId
209
+ ? selectedByProviderId.get(projectedParentId)?.sourceCanonicalId ?? excludedCanonicalByProviderId.get(projectedParentId)
210
+ : undefined;
211
+ return {
212
+ sourceCanonicalId,
213
+ sourceId: page.id,
214
+ sourceTitle: page.title ?? page.locator,
215
+ ...(page.url ? { sourceUrl: page.url } : {}),
216
+ ...(page.parentId ? { sourceParentId: page.parentId } : {}),
217
+ ...(ancestors.length > 0 ? { sourceAncestorIds: ancestors } : {}),
218
+ ...(canonicalParent ? { projectedParentId: canonicalParent } : {}),
219
+ ...(page.discoveredFromId ? { discoveredFromId: page.discoveredFromId } : {}),
220
+ ...(page.depth !== undefined ? { depth: page.depth } : {}),
221
+ status: 'excluded',
222
+ ...(page.status ? { sourceStatus: page.status } : {}),
223
+ reason: page.reason,
224
+ metadataComplete: page.metadataComplete ?? Boolean(page.title && page.url && (page.parentId || ancestors.length > 0)),
225
+ };
226
+ });
257
227
  }
258
228
  export class TransferReport {
259
229
  data;
260
230
  previews = new Map();
261
231
  issueCollector = new IssueCollector();
232
+ secrets;
262
233
  constructor(input) {
263
- const sourceStatus = input.sourceStatus ?? 'all';
234
+ const { secrets = [], ...reportInput } = input;
235
+ this.secrets = [...secrets];
236
+ const sourceStatus = reportInput.sourceStatus ?? 'all';
264
237
  this.data = {
265
238
  runId: randomUUID(),
266
239
  startedAt: new Date().toISOString(),
@@ -277,6 +250,7 @@ export class TransferReport {
277
250
  notes: [],
278
251
  },
279
252
  pages: [],
253
+ excludedPages: [],
280
254
  edges: [],
281
255
  hierarchyRepairs: [],
282
256
  warnings: [],
@@ -284,8 +258,8 @@ export class TransferReport {
284
258
  issues: [],
285
259
  outputs: [],
286
260
  summary: emptySummary(),
287
- ...input,
288
- commentsEnabled: input.commentsEnabled ?? true,
261
+ ...reportInput,
262
+ commentsEnabled: reportInput.commentsEnabled ?? true,
289
263
  };
290
264
  }
291
265
  registerPages(pages) {
@@ -297,7 +271,9 @@ export class TransferReport {
297
271
  sourceId: page.id,
298
272
  sourceTitle: page.title,
299
273
  sourceUrl: page.url,
300
- ...(page.projectedParentId ? { parentId: page.projectedParentId } : {}),
274
+ ...(page.parentId ? { sourceParentId: page.parentId } : {}),
275
+ ...(page.ancestorIds?.length ? { sourceAncestorIds: page.ancestorIds } : {}),
276
+ ...(page.projectedParentId ? { parentId: page.projectedParentId, projectedParentId: page.projectedParentId } : {}),
301
277
  depth: page.depth,
302
278
  status: 'discovered',
303
279
  inventoryStatus: 'pending',
@@ -317,6 +293,8 @@ export class TransferReport {
317
293
  comments: { status: this.data.commentsEnabled ? 'planned' : 'skipped', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0 },
318
294
  });
319
295
  }
296
+ if (this.data.selection.excluded.length > 0)
297
+ this.data.excludedPages = reportExcludedPages(this.data.selection, this.data);
320
298
  }
321
299
  updatePage(sourceCanonicalId, update) {
322
300
  const index = this.data.pages.findIndex((page) => page.sourceCanonicalId === sourceCanonicalId);
@@ -334,25 +312,43 @@ export class TransferReport {
334
312
  this.data.pages.push(page);
335
313
  if (previews)
336
314
  this.previews.set(page.sourceCanonicalId, previews);
315
+ if (this.data.selection.excluded.length > 0)
316
+ this.data.excludedPages = reportExcludedPages(this.data.selection, this.data);
337
317
  if (!collectIssues)
338
318
  return;
339
- for (const message of page.normalizations)
319
+ for (const message of page.normalizations) {
340
320
  this.issue({ code: `normalization:${message}`, severity: 'normalization', phase: 'page', pageId: page.sourceCanonicalId, retryable: false, message });
341
- for (const message of page.warnings)
321
+ }
322
+ for (const message of page.warnings) {
342
323
  this.issue({ code: `warning:${message}`, severity: page.degraded ? 'degradation' : 'warning', phase: 'page', pageId: page.sourceCanonicalId, retryable: false, message });
324
+ }
325
+ }
326
+ setEdges(edges) {
327
+ this.data.edges = edges;
343
328
  }
344
- setEdges(edges) { this.data.edges = edges; }
345
329
  setSelection(selection) {
346
330
  this.data.selection = selection;
347
331
  this.data.sourceStatus = selection.effectiveStatus;
332
+ this.data.excludedPages = reportExcludedPages(selection, this.data);
333
+ }
334
+ setDestinationUrl(url) {
335
+ this.data.destination.url = url;
348
336
  }
349
337
  addHierarchyRepair(repair) {
350
338
  this.data.hierarchyRepairs.push(repair);
351
339
  }
352
- issue(input) { this.issueCollector.add(input); }
353
- warning(message) { this.issue({ code: `warning:${message}`, severity: 'warning', phase: 'report', retryable: false, message }); }
354
- error(message) { this.issue({ code: `error:${message}`, severity: 'error', phase: 'report', retryable: false, message }); }
355
- output(label, value) { this.data.outputs.push({ label, value }); }
340
+ issue(input) {
341
+ this.issueCollector.add({ ...input, message: sanitizedString(input.message, this.secrets) });
342
+ }
343
+ warning(message) {
344
+ this.issue({ code: `warning:${message}`, severity: 'warning', phase: 'report', retryable: false, message });
345
+ }
346
+ error(message) {
347
+ this.issue({ code: `error:${message}`, severity: 'error', phase: 'report', retryable: false, message });
348
+ }
349
+ output(label, value) {
350
+ this.data.outputs.push({ label, value });
351
+ }
356
352
  finalizeOutcomes(status) {
357
353
  if (this.data.dryRun)
358
354
  return;
@@ -401,11 +397,23 @@ export class TransferReport {
401
397
  this.data.issues = this.issueCollector.list().map((issue) => {
402
398
  const page = issue.pageId ? pageById.get(issue.pageId) : undefined;
403
399
  const resource = issue.resourceId ? page?.resourceItems.find((item) => item.sourceId === issue.resourceId) : undefined;
400
+ const category = issueCategory(issue);
401
+ const objectType = issueObjectType(issue);
402
+ const objectId = issue.objectId ?? issue.resourceId ?? issue.commentId ?? issue.pageId;
404
403
  return {
405
404
  ...issue,
406
- ...(page ? { pageTitle: page.sourceTitle, pageAnchor: pageAnchor(page.sourceCanonicalId) } : {}),
405
+ category,
406
+ objectType,
407
+ ...(objectId ? { objectId } : {}),
408
+ ...(!issue.rootCauseId && issue.severity === 'error'
409
+ ? { rootCauseId: `${issue.pageId ?? 'run'}:${issue.code}` }
410
+ : issue.blockedByPageId && !issue.rootCauseId
411
+ ? { rootCauseId: `${issue.blockedByPageId}:${issue.blockedByCode ?? 'dependency'}` }
412
+ : {}),
413
+ ...(page ? { pageTitle: page.sourceTitle, pageAnchor: page.sourceCanonicalId } : {}),
407
414
  ...(resource ? { resourceName: resource.sourceName } : {}),
408
- action: issueAction(issue),
415
+ ...(issue.commentId ? { commentLabel: issue.commentId } : {}),
416
+ action: issueAction(issue, category),
409
417
  };
410
418
  });
411
419
  this.data.warnings = this.data.issues.filter((issue) => issue.severity === 'warning').map((issue) => issue.message);
@@ -433,7 +441,7 @@ export class TransferReport {
433
441
  pages: this.data.pages.length,
434
442
  returnedDescendants: this.data.selection.returnedDescendants,
435
443
  selectedDescendants: this.data.selection.selectedDescendants,
436
- excludedPages: this.data.selection.excluded.length,
444
+ excludedPages: this.data.excludedPages.length,
437
445
  maximumDepth: this.data.selection.maximumDepth,
438
446
  hierarchyGaps: this.data.selection.hierarchyGaps.length,
439
447
  hierarchyRepairs: this.data.hierarchyRepairs.length,
@@ -485,6 +493,11 @@ export class TransferReport {
485
493
  resourceStatuses: resourceStatusCounts,
486
494
  traversalStatuses: traversalStatusCounts,
487
495
  };
496
+ const pageOutcomes = Object.values(pageStatusCounts).reduce((total, value) => total + value, 0);
497
+ const resourceOutcomes = Object.values(resourceStatusCounts).reduce((total, value) => total + value, 0);
498
+ if (pageOutcomes !== this.data.summary.pages || resourceOutcomes !== this.data.summary.resources) {
499
+ throw new Error('Report outcome invariant failed: source inventory does not reconcile with exclusive outcomes');
500
+ }
488
501
  }
489
502
  async write(reportsDirectory) {
490
503
  const directory = path.join(reportsDirectory, this.data.runId);
@@ -493,8 +506,12 @@ export class TransferReport {
493
506
  const html = path.join(directory, 'index.html');
494
507
  const jsonTemporary = `${json}.${process.pid}.tmp`;
495
508
  const htmlTemporary = `${html}.${process.pid}.tmp`;
496
- await writeFile(jsonTemporary, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
497
- await writeFile(htmlTemporary, renderReport(this.data, this.previews), { mode: 0o600 });
509
+ const shell = await loadReportShell();
510
+ const safeData = sanitizedValue(this.data, this.secrets);
511
+ const safePreviews = sanitizedValue(Object.fromEntries(this.previews), this.secrets);
512
+ const payload = { report: safeData, previews: safePreviews };
513
+ await writeFile(jsonTemporary, `${JSON.stringify(safeData, null, 2)}\n`, { mode: 0o600 });
514
+ await writeFile(htmlTemporary, injectReportPayload(shell, payload), { mode: 0o600 });
498
515
  await rename(jsonTemporary, json);
499
516
  await rename(htmlTemporary, html);
500
517
  return { runId: this.data.runId, directory, html, json };