@iwonz/ywtc 3.4.0 → 3.5.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 (42) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +31 -2
  3. package/dist/adapters/confluence.d.ts +8 -2
  4. package/dist/adapters/confluence.js +44 -6
  5. package/dist/adapters/confluence.js.map +1 -1
  6. package/dist/adapters/yandex.d.ts +18 -4
  7. package/dist/adapters/yandex.js +34 -6
  8. package/dist/adapters/yandex.js.map +1 -1
  9. package/dist/cli.js +6 -4
  10. package/dist/cli.js.map +1 -1
  11. package/dist/content-model.d.ts +1 -0
  12. package/dist/content-model.js +13 -0
  13. package/dist/content-model.js.map +1 -1
  14. package/dist/domain.d.ts +12 -0
  15. package/dist/domain.js +10 -0
  16. package/dist/domain.js.map +1 -1
  17. package/dist/graph.js +11 -2
  18. package/dist/graph.js.map +1 -1
  19. package/dist/issues.d.ts +10 -1
  20. package/dist/issues.js +9 -1
  21. package/dist/issues.js.map +1 -1
  22. package/dist/migrator.d.ts +1 -1
  23. package/dist/migrator.js +344 -155
  24. package/dist/migrator.js.map +1 -1
  25. package/dist/report-catalog.d.ts +14 -0
  26. package/dist/report-catalog.js +14 -0
  27. package/dist/report-catalog.js.map +1 -1
  28. package/dist/report-contract.d.ts +259 -0
  29. package/dist/report-contract.js +15 -0
  30. package/dist/report-contract.js.map +1 -0
  31. package/dist/report-ui/index.html +92 -0
  32. package/dist/report.d.ts +13 -192
  33. package/dist/report.js +273 -176
  34. package/dist/report.js.map +1 -1
  35. package/dist/state.d.ts +24 -6
  36. package/dist/state.js +54 -3
  37. package/dist/state.js.map +1 -1
  38. package/dist/title-allocation.d.ts +2 -0
  39. package/dist/title-allocation.js +43 -0
  40. package/dist/title-allocation.js.map +1 -0
  41. package/docs/assets/report-header-transparent.png +0 -0
  42. package/package.json +24 -3
package/dist/report.js CHANGED
@@ -1,20 +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
- 'complete',
10
- 'partial',
11
- 'conflict',
12
- 'failed',
13
- 'blocked',
14
- ];
15
- const resourceStatuses = ['planned', 'complete', 'partial', 'failed'];
16
- const edgeStatuses = ['resolved', 'external', 'inaccessible', 'broken', 'cycle', 'unsupported'];
17
- const commentStatuses = ['planned', '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}';
18
9
  function countRecord(values) {
19
10
  return Object.fromEntries(values.map((value) => [value, 0]));
20
11
  }
@@ -32,6 +23,7 @@ function emptySummary() {
32
23
  failed: 0,
33
24
  blocked: 0,
34
25
  conflicts: 0,
26
+ notStartedPages: 0,
35
27
  unprocessedPages: 0,
36
28
  resources: 0,
37
29
  attachments: 0,
@@ -39,6 +31,8 @@ function emptySummary() {
39
31
  resourcesComplete: 0,
40
32
  resourcesPartial: 0,
41
33
  resourcesFailed: 0,
34
+ resourcesBlocked: 0,
35
+ resourcesNotStarted: 0,
42
36
  resourcesPlanned: 0,
43
37
  bytes: 0,
44
38
  sourceBytes: 0,
@@ -53,6 +47,10 @@ function emptySummary() {
53
47
  degradations: 0,
54
48
  warnings: 0,
55
49
  errors: 0,
50
+ rootErrors: 0,
51
+ dependentPages: 0,
52
+ dependentFiles: 0,
53
+ dependentComments: 0,
56
54
  retryableFailures: 0,
57
55
  inventoryComplete: false,
58
56
  inventoriedPages: 0,
@@ -68,167 +66,174 @@ function emptySummary() {
68
66
  traversalStatuses: countRecord(edgeStatuses),
69
67
  };
70
68
  }
71
- function escapeHtml(value) {
72
- return String(value ?? '')
73
- .replaceAll('&', '&')
74
- .replaceAll('<', '&lt;')
75
- .replaceAll('>', '&gt;')
76
- .replaceAll('"', '&quot;')
77
- .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';
78
102
  }
79
- function safeHttpUrl(value) {
80
- if (!value)
81
- return undefined;
82
- try {
83
- const url = new URL(value);
84
- return ['http:', 'https:'].includes(url.protocol) ? url.toString() : undefined;
85
- }
86
- catch {
87
- return undefined;
88
- }
89
- }
90
- function statusClass(value) {
91
- return value === 'complete' || value === 'completed' ? 'ok'
92
- : /partial|warning|conflict|planned|discovered|skeleton|interrupted|cancelled/.test(value) ? 'warn'
93
- : /failed|blocked|error/.test(value) ? 'bad'
94
- : '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';
95
113
  }
96
- function pageAnchor(sourceCanonicalId) {
97
- return `page-${createHash('sha256').update(sourceCanonicalId).digest('hex').slice(0, 12)}`;
98
- }
99
- function formatBytes(value) {
100
- if (value < 1024)
101
- return `${value} B`;
102
- const units = ['KiB', 'MiB', 'GiB', 'TiB'];
103
- let amount = value;
104
- let unit = -1;
105
- do {
106
- amount /= 1024;
107
- unit += 1;
108
- } while (amount >= 1024 && unit < units.length - 1);
109
- return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${units[unit]}`;
110
- }
111
- function formatDuration(durationMs) {
112
- if (durationMs === undefined)
113
- return 'in progress';
114
- if (durationMs < 1000)
115
- return `${durationMs} ms`;
116
- const seconds = Math.round(durationMs / 1000);
117
- if (seconds < 60)
118
- return `${seconds} s`;
119
- const minutes = Math.floor(seconds / 60);
120
- return `${minutes} min ${seconds % 60} s`;
121
- }
122
- function issueAction(issue) {
114
+ function issueAction(issue, category) {
115
+ if (issue.severity === 'blocked')
116
+ return 'Resolve the upstream page error, then repeat the same migration; this object will resume automatically.';
123
117
  if (issue.retryable)
124
118
  return 'Repeat the same migration to retry this object.';
125
119
  if (issue.severity === 'degradation')
126
120
  return 'Data remains represented; compare the source and destination previews.';
127
- if (issue.severity === 'error')
128
- return 'Review the reason and affected object before resuming the migration.';
129
121
  if (issue.severity === 'normalization')
130
122
  return 'No action is required.';
131
- return 'Review the affected object and the page details below.';
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.';
132
134
  }
133
- function statCard(label, value, detail) {
134
- return `<div class="stat"><small>${escapeHtml(label)}</small><strong>${escapeHtml(value)}</strong>${detail ? `<span>${escapeHtml(detail)}</span>` : ''}</div>`;
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');
135
142
  }
136
- function statusCells(counts, statuses) {
137
- return statuses.map((status) => `<td><span class="status ${statusClass(status)}">${escapeHtml(status)}</span><strong>${escapeHtml(counts[status] ?? 0)}</strong></td>`).join('');
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]');
154
+ }
155
+ return url.toString();
156
+ }
157
+ catch {
158
+ return redacted;
159
+ }
138
160
  }
139
- function renderReport(data, previews) {
140
- const sourceUrl = safeHttpUrl(data.source.url);
141
- const destinationUrl = safeHttpUrl(data.destination.url);
142
- const inventoryState = data.summary.inventoryComplete
143
- ? `${data.summary.inventoriedPages}/${data.summary.pages} pages inspected`
144
- : `${data.summary.inventoriedPages}/${data.summary.pages} pages inspected · ${data.summary.inventoryFailedPages} failed`;
145
- const sourceCards = [
146
- statCard('Selected pages', data.summary.pages, 'accessible in selected scope'),
147
- statCard('Returned descendants', data.summary.returnedDescendants, `${data.summary.excludedPages} excluded by status`),
148
- statCard('Tree depth', data.summary.maximumDepth, `${data.summary.hierarchyGaps} hierarchy gaps · ${data.summary.hierarchyRepairs} repaired`),
149
- statCard('Files', data.summary.resources, `${data.summary.attachments} attachments · ${data.summary.resourceImages} images`),
150
- statCard('Source bytes', formatBytes(data.summary.sourceBytes), data.summary.bytesUnknown ? `${data.summary.bytesUnknown} sizes unknown` : 'all sizes known'),
151
- statCard('Transferred bytes', formatBytes(data.summary.bytes), 'confirmed complete files'),
152
- statCard('Links', data.summary.links, `${data.summary.images} embedded images`),
153
- statCard('Structured content', data.summary.tables + data.summary.codeBlocks, `${data.summary.tables} tables · ${data.summary.grids} grids · ${data.summary.codeBlocks} code blocks`),
154
- statCard('Comments', data.summary.comments, `${data.summary.commentThreads} threads · ${data.summary.commentParticipants} participants`),
155
- statCard('Comment files', data.summary.commentAttachments, formatBytes(data.summary.commentBytes)),
156
- ].join('');
157
- 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('');
158
- 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('');
159
- 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('');
160
- const selectionDetails = excludedRows || gapRows || repairRows || data.selection.notes.length
161
- ? `<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>`
162
- : '';
163
- const pageOutcomeStatuses = ['complete', 'partial', 'failed', 'blocked', 'conflict', 'planned', 'discovered', 'skeleton-created'];
164
- const resourceOutcomeStatuses = ['complete', 'partial', 'failed', 'planned'];
165
- const commentOutcomeStatuses = ['complete', 'partial', 'failed', 'conflict', 'stale', 'skipped', 'planned'];
166
- const attentionIssues = data.issues.filter((issue) => issue.severity !== 'normalization');
167
- const normalizationIssues = data.issues.filter((issue) => issue.severity === 'normalization');
168
- const attentionSummary = [
169
- `${data.summary.errors} errors`,
170
- `${data.summary.warnings} warnings`,
171
- `${data.summary.degradations} degradations`,
172
- `${data.summary.retryableFailures} retryable`,
173
- ].map((item) => `<span>${escapeHtml(item)}</span>`).join('');
174
- const issueRow = (issue) => {
175
- const target = issue.pageAnchor ? `<a href="#${escapeHtml(issue.pageAnchor)}">${escapeHtml(issue.pageTitle ?? issue.pageId)}</a>` : '<span>run-level</span>';
176
- 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>` : ''}</p><p class="action">${escapeHtml(issue.action)}</p></li>`;
177
- };
178
- 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>`;
179
- const pageRows = data.pages.map((page) => {
180
- const source = safeHttpUrl(page.sourceUrl);
181
- const destination = safeHttpUrl(page.destinationUrl);
182
- const preview = previews.get(page.sourceCanonicalId);
183
- const pageIssues = data.issues.filter((issue) => issue.pageId === page.sourceCanonicalId);
184
- 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('');
185
- const metrics = `${page.resources} files · ${page.links} links · ${page.images} images · ${page.tables} tables · ${page.codeBlocks} code blocks`;
186
- 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>
187
- <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>
188
- ${page.error ? `<div class="callout bad"><strong>Page error</strong><p>${escapeHtml(page.error)}</p></div>` : ''}
189
- <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>
190
- ${pageIssues.length ? `<div><h3>Page issues</h3><ul class="issue-list compact">${pageIssues.map(issueRow).join('')}</ul></div>` : ''}
191
- ${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>'}
192
- ${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>'}
193
- ${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>` : ''}
194
- ${(page.normalizations ?? []).length ? `<div><h3>Safe normalizations</h3><ul>${(page.normalizations ?? []).map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul></div>` : ''}
195
- </details>`;
196
- }).join('');
197
- const edgeRows = data.edges.map((edge) => {
198
- const from = data.pages.find((page) => page.sourceCanonicalId === edge.from)?.sourceTitle ?? edge.from;
199
- const to = edge.to ? data.pages.find((page) => page.sourceCanonicalId === edge.to)?.sourceTitle ?? edge.to : '—';
200
- 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>`;
201
- }).join('');
202
- const traversalSummary = edgeStatuses.filter((status) => data.summary.traversalStatuses[status] > 0).map((status) => `<span><strong>${data.summary.traversalStatuses[status]}</strong> ${escapeHtml(status)}</span>`).join('');
203
- const traversalBody = data.edges.length
204
- ? `<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>`
205
- : data.scope === 'linked'
206
- ? '<p class="empty">No traversal edges were discovered in linked scope.</p>'
207
- : `<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>`;
208
- const outputRows = data.outputs.map((output) => `<li><strong>${escapeHtml(output.label)}</strong> <code>${escapeHtml(output.value)}</code></li>`).join('');
209
- const normalizations = normalizationIssues.length
210
- ? `<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>`
211
- : '';
212
- 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>
213
- :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}}
214
- </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>
215
- <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>
216
- <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>
217
- ${selectionDetails}
218
- <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="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>
219
- ${attention}
220
- <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>
221
- <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>
222
- ${normalizations}
223
- <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>
224
- </main></body></html>`;
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
+ });
225
227
  }
226
228
  export class TransferReport {
227
229
  data;
228
230
  previews = new Map();
229
231
  issueCollector = new IssueCollector();
232
+ secrets;
230
233
  constructor(input) {
231
- const sourceStatus = input.sourceStatus ?? 'all';
234
+ const { secrets = [], ...reportInput } = input;
235
+ this.secrets = [...secrets];
236
+ const sourceStatus = reportInput.sourceStatus ?? 'all';
232
237
  this.data = {
233
238
  runId: randomUUID(),
234
239
  startedAt: new Date().toISOString(),
@@ -245,6 +250,7 @@ export class TransferReport {
245
250
  notes: [],
246
251
  },
247
252
  pages: [],
253
+ excludedPages: [],
248
254
  edges: [],
249
255
  hierarchyRepairs: [],
250
256
  warnings: [],
@@ -252,8 +258,8 @@ export class TransferReport {
252
258
  issues: [],
253
259
  outputs: [],
254
260
  summary: emptySummary(),
255
- ...input,
256
- commentsEnabled: input.commentsEnabled ?? true,
261
+ ...reportInput,
262
+ commentsEnabled: reportInput.commentsEnabled ?? true,
257
263
  };
258
264
  }
259
265
  registerPages(pages) {
@@ -265,7 +271,9 @@ export class TransferReport {
265
271
  sourceId: page.id,
266
272
  sourceTitle: page.title,
267
273
  sourceUrl: page.url,
268
- ...(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 } : {}),
269
277
  depth: page.depth,
270
278
  status: 'discovered',
271
279
  inventoryStatus: 'pending',
@@ -285,6 +293,8 @@ export class TransferReport {
285
293
  comments: { status: this.data.commentsEnabled ? 'planned' : 'skipped', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0 },
286
294
  });
287
295
  }
296
+ if (this.data.selection.excluded.length > 0)
297
+ this.data.excludedPages = reportExcludedPages(this.data.selection, this.data);
288
298
  }
289
299
  updatePage(sourceCanonicalId, update) {
290
300
  const index = this.data.pages.findIndex((page) => page.sourceCanonicalId === sourceCanonicalId);
@@ -302,37 +312,108 @@ export class TransferReport {
302
312
  this.data.pages.push(page);
303
313
  if (previews)
304
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);
305
317
  if (!collectIssues)
306
318
  return;
307
- for (const message of page.normalizations)
319
+ for (const message of page.normalizations) {
308
320
  this.issue({ code: `normalization:${message}`, severity: 'normalization', phase: 'page', pageId: page.sourceCanonicalId, retryable: false, message });
309
- for (const message of page.warnings)
321
+ }
322
+ for (const message of page.warnings) {
310
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;
311
328
  }
312
- setEdges(edges) { this.data.edges = edges; }
313
329
  setSelection(selection) {
314
330
  this.data.selection = selection;
315
331
  this.data.sourceStatus = selection.effectiveStatus;
332
+ this.data.excludedPages = reportExcludedPages(selection, this.data);
333
+ }
334
+ setDestinationUrl(url) {
335
+ this.data.destination.url = url;
316
336
  }
317
337
  addHierarchyRepair(repair) {
318
338
  this.data.hierarchyRepairs.push(repair);
319
339
  }
320
- issue(input) { this.issueCollector.add(input); }
321
- warning(message) { this.issue({ code: `warning:${message}`, severity: 'warning', phase: 'report', retryable: false, message }); }
322
- error(message) { this.issue({ code: `error:${message}`, severity: 'error', phase: 'report', retryable: false, message }); }
323
- 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
+ }
352
+ finalizeOutcomes(status) {
353
+ if (this.data.dryRun)
354
+ return;
355
+ const interrupted = status === 'cancelled' || status === 'interrupted';
356
+ for (const page of this.data.pages) {
357
+ if (['discovered', 'planned', 'skeleton-created'].includes(page.status))
358
+ page.status = 'not-started';
359
+ const dependencyBlocked = page.status === 'blocked' || page.status === 'failed' || page.status === 'conflict';
360
+ const blockedByPageId = page.blockedByPageId ?? page.sourceCanonicalId;
361
+ const blockedByCode = page.blockedByCode ?? (page.status === 'conflict' ? 'destination-conflict' : 'destination-page-unavailable');
362
+ for (const resource of page.resourceItems) {
363
+ if (resource.status !== 'planned')
364
+ continue;
365
+ if (dependencyBlocked) {
366
+ resource.status = 'blocked';
367
+ resource.blockedByPageId = blockedByPageId;
368
+ resource.blockedByCode = blockedByCode;
369
+ resource.error ??= `Not attempted because destination page ${blockedByPageId} is unavailable`;
370
+ }
371
+ else {
372
+ resource.status = 'not-started';
373
+ if (!interrupted)
374
+ resource.error ??= 'Live transfer ended before this file operation started';
375
+ }
376
+ }
377
+ if (page.comments.status !== 'planned')
378
+ continue;
379
+ if (dependencyBlocked) {
380
+ page.comments.status = 'blocked';
381
+ page.comments.blockedByPageId = blockedByPageId;
382
+ page.comments.blockedByCode = blockedByCode;
383
+ page.comments.error ??= `Not attempted because destination page ${blockedByPageId} is unavailable`;
384
+ }
385
+ else {
386
+ page.comments.status = 'not-started';
387
+ if (!interrupted)
388
+ page.comments.error ??= 'Live transfer ended before comment archival started';
389
+ }
390
+ }
391
+ }
324
392
  finish(status) {
325
393
  this.data.status = status;
326
394
  this.data.finishedAt = new Date().toISOString();
395
+ this.finalizeOutcomes(status);
327
396
  const pageById = new Map(this.data.pages.map((page) => [page.sourceCanonicalId, page]));
328
397
  this.data.issues = this.issueCollector.list().map((issue) => {
329
398
  const page = issue.pageId ? pageById.get(issue.pageId) : undefined;
330
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;
331
403
  return {
332
404
  ...issue,
333
- ...(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 } : {}),
334
414
  ...(resource ? { resourceName: resource.sourceName } : {}),
335
- action: issueAction(issue),
415
+ ...(issue.commentId ? { commentLabel: issue.commentId } : {}),
416
+ action: issueAction(issue, category),
336
417
  };
337
418
  });
338
419
  this.data.warnings = this.data.issues.filter((issue) => issue.severity === 'warning').map((issue) => issue.message);
@@ -360,7 +441,7 @@ export class TransferReport {
360
441
  pages: this.data.pages.length,
361
442
  returnedDescendants: this.data.selection.returnedDescendants,
362
443
  selectedDescendants: this.data.selection.selectedDescendants,
363
- excludedPages: this.data.selection.excluded.length,
444
+ excludedPages: this.data.excludedPages.length,
364
445
  maximumDepth: this.data.selection.maximumDepth,
365
446
  hierarchyGaps: this.data.selection.hierarchyGaps.length,
366
447
  hierarchyRepairs: this.data.hierarchyRepairs.length,
@@ -369,13 +450,16 @@ export class TransferReport {
369
450
  failed: pageStatusCounts.failed,
370
451
  blocked: pageStatusCounts.blocked,
371
452
  conflicts: pageStatusCounts.conflict,
372
- unprocessedPages: pageStatusCounts.discovered + pageStatusCounts.planned + pageStatusCounts['skeleton-created'],
453
+ notStartedPages: pageStatusCounts['not-started'],
454
+ unprocessedPages: pageStatusCounts.discovered + pageStatusCounts.planned + pageStatusCounts['skeleton-created'] + pageStatusCounts['not-started'],
373
455
  resources: resources.length,
374
456
  attachments: resources.filter((resource) => resource.kind === 'attachment').length,
375
457
  resourceImages: resources.filter((resource) => resource.kind === 'image').length,
376
458
  resourcesComplete: resourceStatusCounts.complete,
377
459
  resourcesPartial: resourceStatusCounts.partial,
378
460
  resourcesFailed: resourceStatusCounts.failed,
461
+ resourcesBlocked: resourceStatusCounts.blocked,
462
+ resourcesNotStarted: resourceStatusCounts['not-started'],
379
463
  resourcesPlanned: resourceStatusCounts.planned,
380
464
  bytes: transferredBytes,
381
465
  sourceBytes,
@@ -390,6 +474,10 @@ export class TransferReport {
390
474
  degradations: this.issueCollector.count('degradation'),
391
475
  warnings: this.issueCollector.count('warning'),
392
476
  errors: this.issueCollector.count('error'),
477
+ rootErrors: this.issueCollector.count('error'),
478
+ dependentPages: pageStatusCounts.blocked,
479
+ dependentFiles: resourceStatusCounts.blocked,
480
+ dependentComments: commentStatusCounts.blocked,
393
481
  retryableFailures: this.issueCollector.countRetryableFailures(),
394
482
  inventoryComplete: this.data.pages.length > 0 && inventoriedPages === this.data.pages.length,
395
483
  inventoriedPages,
@@ -405,6 +493,11 @@ export class TransferReport {
405
493
  resourceStatuses: resourceStatusCounts,
406
494
  traversalStatuses: traversalStatusCounts,
407
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
+ }
408
501
  }
409
502
  async write(reportsDirectory) {
410
503
  const directory = path.join(reportsDirectory, this.data.runId);
@@ -413,8 +506,12 @@ export class TransferReport {
413
506
  const html = path.join(directory, 'index.html');
414
507
  const jsonTemporary = `${json}.${process.pid}.tmp`;
415
508
  const htmlTemporary = `${html}.${process.pid}.tmp`;
416
- await writeFile(jsonTemporary, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
417
- 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 });
418
515
  await rename(jsonTemporary, json);
419
516
  await rename(htmlTemporary, html);
420
517
  return { runId: this.data.runId, directory, html, json };