1'use strict';23// Operator-facing URL-preservation projection, derived only from durable rows: the plan,4// redirect-execution.ndjson and url-verification.ndjson. Nothing is recounted by hand, an5// acknowledgment never upgrades a status, and only a public-stage check can verify a link.67const fs = require('node:fs/promises');8const path = require('node:path');9const { executionDigestFor } = require('./url-verification.js'
17 destination_not_found: 'Find the page this old address should reach, or record an explicit decision that it has none.',
18 final_route_missing: 'The website build has no final page for this entity; build or map it, then re-run plan.',
19 ambiguous_final_route: 'Several final pages claim this entity; choose one in the route manifest.',
20 ownership_conflict: 'Another page already owns this address; decide which page should keep it. Nothing was redirected.',
21 origin_intent_conflict: 'Declared origins need different behaviour at this path; resolve the host-specific rule by hand.',
22 existing_rule_conflict: 'An existing redirect on the site points this path elsewhere; review it by hand. It was not replaced.',
23 destination_is_existing_source: 'Creating this rule could delete an existing redirect; resolve the chain by hand.',
24 group_overlap: 'A group redirect overlaps this path; confirm the scopes by hand.',
25 language_overlap: 'A language-scoped redirect overlaps this path; confirm the scopes by hand.',
26 creates_chain_with_existing_rule: 'An existing redirect already points to this path; resolve the chain by hand.',
27 unsupported_match_type: 'Pattern redirect: convert it to exact rules or configure it manually.',
28 non_permanent_status: 'The source rule is not a permanent redirect; decide whether to recreate it manually.',
29 status_308_not_writable: 'The source rule is a 308 redirect; only exact 301 rules are written automatically. Recreate it manually if 301 behaviour is acceptable.',
30 import_manual_review: 'The import recorded this address for manual review; resolve that decision before any redirect is written.',
31 import_redirect_source_rule_shape: 'This import row is a source-authored rule without a stable entity key. Confirm its destination and enabled state in the source-rule inventory before creating a redirect.',
32 import_redirect_invalid_row: 'This import redirect row lacks required fields. Correct or classify the row, then re-run plan; no redirect was written for it.',
33 base_path_manual_review: 'Resolve the listed non-routing restriction; exact destination evidence alone does not clear it.',
34 base_path_hold_reason_missing: 'Explain the original base-path hold in base-paths.json reasonCodes; an unexplained flag is not proof of an active restriction or of resolution. Routing-only uncertainty can be settled by an exact entity/final-route match.',
35 base_path_entity_type_unknown: 'Resolve the source entity type from its stable ledger identity before evaluating this base-path restriction; do not infer type from a path prefix.',
36 base_path_not_public: 'The import recorded this base path as not public; confirm whether the old address should be preserved.',
37 base_path_unparseable: 'A recorded base path cannot be parsed; correct base-paths.json, then re-run plan.',
38 query_semantics_conflict: 'The old address and its final page carry different query strings; decide which query the redirect must keep.',
39 final_query_mismatch: 'The old address reached the right page but with a different query string; fix the redirect so the query is kept.',
40 conditional_rule: 'The source rule has conditions the migration cannot reproduce; configure it manually.',
41 query_conditional_rule: 'The source rule matches on a query string; configure it manually.',
42 language_scoped_rule: 'Language-scoped source rule; configure it manually.',
43 external_destination: 'The source rule sends visitors to another site; recreate it manually if still wanted.',
44 external_setup_pending: 'Copy the original external destination through the supported Redirect Manager UI and record the stored rule before public verification.',
45 external_location_mismatch: 'The public source redirect points somewhere other than its captured external destination; correct the stored rule.',
46 external_source_status_mismatch: 'The public source returned a status other than the captured 301 redirect.',
47 external_redirect_unsupported_on_wix: 'No Wix resolution is known for this exact rule; retain the launch gap.',
48 external_redirect_capability_unverified: 'Confirm whether this exact rule is supported before setup; keep its original destination.',
49 removed_source_rule_requires_reconciliation: 'The source rule disappeared from a complete capture; reconcile and retire any old local or destination redirect before launch.',
50 inactive_import_redirect_plan_requires_reconciliation: 'An old imported plan remains for a disabled or missing source rule; reconcile it before any redirect write.',
51 source_root: 'The site root cannot be redirected; confirm the home page serves the intended content.',
52 source_rule_cycle: 'The source redirect rules form a loop; fix the source map.',
53 redirect_not_installed: 'Apply the redirect plan (url-redirects.js apply), then verify.',
54 public_check_pending: 'After the DNS switch, run url-redirects.js verify --stage verified_public.',
55 website_not_built: 'Build the destination website and supply its final route manifest.',
56 origin_not_cut_over: 'Switch the website DNS for this origin, then re-run the public check.',
57};
58
59function nextActionFor(reason) {
60 if (!reason) return 'None.';
61 if (NEXT_ACTION[reason]) return NEXT_ACTION[reason];
62 if (reason.startsWith('import_unresolved_')) return 'The import left this address unresolved; resolve the recorded issue, then re-run plan.';
63 if (/malformed|credentials|scheme|not_site_relative|undeclared/.test(reason)) return 'The recorded address cannot be used safely; correct it in the source inventory.';
64 return 'Review the evidence and resolve by hand before launch.';
65}
66
67function redactUrl(url) {
68 return TOKEN_LINK.test(url || '') ? '[redacted token/action link — see state/url-preservation/]' : url;
197 action: `${plan.obligations.length} known old addresses wait for the destination website's final routes before redirects can be planned and tested. ${reportRef}`,
198 deadlineReason: 'redirects must exist before the website DNS switch',
199 entity: 'url-preservation',
200 affectedCount: plan.obligations.length,
201 });
202 }
203 for (const gap of coverageGaps) {
204 operatorActions.push({
205 code: 'redirect_source_discovery_gap',
206 severity: 'high',
207 action: `Redirect rules held by ${gap.provider} could not be read (${gap.status}${gap.reason ? `: ${gap.reason}` : ''}). Export them from the source site so they can be preserved. ${reportRef}`,
208 deadlineReason: 'historical addresses kept alive only by those rules would break at launch',
209 entity: 'url-preservation',
210 affectedCount: 1,
211 });
212 }
213 if (!coverageComplete && !coverageGaps.length) {
214 operatorActions.push({
215 code: 'redirect_source_discovery_gap',
216 severity: 'high',
217 action: `No redirect-provider coverage was recorded, so the source redirect map is not known to be complete. ${reportRef}`,
218 deadlineReason: 'historical addresses kept alive only by source rules would break at launch',
219 entity: 'url-preservation',
220 affectedCount: 1,
221 });
222 }
223 if (launchPlan) {
224 for (const field of ['domainAssociation', 'https']) {
225 if (launchPlan[field] !== 'ready') {
226 operatorActions.push({
227 code: field === 'https' ? 'url_launch_https_not_ready' : 'url_launch_domain_not_associated',
228 severity: 'high',
229 action: `${field === 'https' ? 'HTTPS for the production hostname' : 'The production domain association'} on the destination site is ${launchPlan[field]}; prepare it before requesting the website DNS switch.`,
230 deadlineReason: 'the DNS switch sends visitors to the new site immediately',
231 entity: 'url-preservation',
232 affectedCount: 1,
233 });
234 }
235 }
236 }
237 for (const act of (applyStatus && applyStatus.operatorActions) || []) {
238 if (!operatorActions.some((existing) => existing.code === act.code)) operatorActions.push(act);
278 lines.push('', '## Links that need a functional check', '', 'A page response cannot prove that password-reset, checkout, order or download links still work.', '');
279 for (const gap of report.functionalGaps) lines.push(`- ${gap.obligationKey}`);
280 }
281 lines.push('', 'Acknowledging a lost link does not make it work. No customer notifications were sent.');
282 return `${lines.join('\n')}\n`;
283}
284
285async function writeUrlPreservationReport(projectDir, report) {
286 for (const [rel, text] of [[REPORT_JSON, `${JSON.stringify(report, null, 2)}\n`], [REPORT_MD, renderUrlPreservationMarkdown(report)]]) {