@iwonz/ywtc 3.4.1 → 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.
- package/CHANGELOG.md +23 -0
- package/README.md +4 -2
- package/dist/adapters/confluence.d.ts +8 -2
- package/dist/adapters/confluence.js +16 -3
- package/dist/adapters/confluence.js.map +1 -1
- package/dist/adapters/yandex.d.ts +18 -4
- package/dist/adapters/yandex.js +34 -6
- package/dist/adapters/yandex.js.map +1 -1
- package/dist/domain.d.ts +7 -0
- package/dist/domain.js.map +1 -1
- package/dist/graph.js +11 -2
- package/dist/graph.js.map +1 -1
- package/dist/issues.d.ts +7 -0
- package/dist/issues.js +9 -1
- package/dist/issues.js.map +1 -1
- package/dist/migrator.d.ts +1 -1
- package/dist/migrator.js +75 -3
- package/dist/migrator.js.map +1 -1
- package/dist/report-contract.d.ts +259 -0
- package/dist/report-contract.js +15 -0
- package/dist/report-contract.js.map +1 -0
- package/dist/report-ui/index.html +92 -0
- package/dist/report.d.ts +12 -208
- package/dist/report.js +214 -197
- package/dist/report.js.map +1 -1
- package/dist/state.d.ts +3 -0
- package/dist/state.js +3 -0
- package/dist/state.js.map +1 -1
- package/docs/assets/report-header-transparent.png +0 -0
- package/package.json +24 -3
package/dist/report.js
CHANGED
|
@@ -1,21 +1,11 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
88
|
-
if (
|
|
89
|
-
return
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
147
|
-
return
|
|
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
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
205
|
-
|
|
155
|
+
return url.toString();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return redacted;
|
|
206
159
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
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
|
-
...
|
|
288
|
-
commentsEnabled:
|
|
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.
|
|
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
|
-
|
|
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) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
497
|
-
|
|
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 };
|