Setting the file. One moment.
Website Handoff · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page 124
function normalizeScopeValue
— line 124
This file
Number 45.164
Position 164 of 182
Type JavaScript
Size 60 KB
Lines 1,266 lib/ website-handoff.js
JavaScript · 1,266 lines · 60 KB
getDecisionValue
,
getEffectiveDeliveryMode
,
isExplicitUserOneClick
}
=
require
(
'./orchestration-decisions.js'
);
8 const { hashArtifact } = require ( './artifact-freshness.js' );
9 const { appendExecutionLog } = require ( './execution-log.js' );
10 const { emptyPermanentLossesLine , handoffVoucherSignal } = require ( './completion-report.js' );
11 const { coverageDisagreements } = require ( './import-recovery.js' );
12 const { readLinkAudit , linkOperatorActions , RELATIVE_PATH : LINK_AUDIT_PATH } = require ( './link-audit.js' );
13
14 const SCHEMA_VERSION = 2 ;
15 // Bumped to 2 when `liveImportSummary` and `recoveryLog` joined HANDOFF_ARTIFACTS, so a
16 // fingerprint recorded under the old shape covers fewer inputs than this one does. A stored
17 // fingerprint from version 1 must resolve to "regenerate", never to a silent pass.
18 const FRESHNESS_SCHEMA_VERSION = 3 ;
19 const HANDOFF_ARTIFACTS = {
20 decisions: 'orchestration/decisions.json' ,
21 sourceSchema: 'source-schema.json' ,
22 mappingPlan: 'mapping/mapping-plan.json' ,
23 mappingGaps: 'mapping/review/mapping-gaps.json' ,
24 setupPlan: 'setup/setup-plan.json' ,
25 setupRequirements: 'setup/setup-requirements.json' ,
26 setupBlockers: 'setup/setup-blockers.json' ,
27 wixEnv: 'config/wix.env' ,
28 frontendConfig: 'frontend/wix.config.json' ,
29 completionReport: 'execution/completion-report.json' ,
30 linkAudit: LINK_AUDIT_PATH ,
31 // A late recovery pass must re-stale the handoff. Without these two, a recovery
32 // run could change what actually landed and leave a handoff generated before it looking fresh.
33 liveImportSummary: 'execution/live-import-summary.json' ,
34 recoveryLog: 'execution/recovery-log.json' ,
35 };
36
37 const POST_EXECUTION_HANDOFF_FILE = 'execution/review/migration-handoff.md' ;
38 const POST_EXECUTION_HANDOFF_JSON = 'execution/review/migration-handoff.json' ;
39
40 async function pathExists ( filePath ) {
41 try {
42 await fs. access (filePath);
43 return true ;
44 } catch {
45 return false ;
46 }
47 }
48
49 async function readJsonIfExists ( filePath ) {
50 try {
51 return JSON . parse ( await fs. readFile (filePath, 'utf8' ));
52 } catch (error) {
53 if (error && error.code === 'ENOENT' ) {
54 return null ;
55 }
56 throw error;
57 }
58 }
59
60 async function readEnvIfExists ( filePath ) {
61 try {
62 return await readEnvFile (filePath);
63 } catch (error) {
64 if (error && error.code === 'ENOENT' ) {
65 return {};
66 }
67 throw error;
68 }
69 }
70
71 function rel ( projectDir , targetPath ) {
72 return path. relative (projectDir, targetPath). replace ( / \\ / g , '/' );
73 }
74
75 function inferMigrationProjectRoot ( projectDir ) {
76 const normalized = path. resolve (projectDir);
77 const parent = path. dirname (normalized);
78 if (path. basename (parent) === 'migrations' ) {
79 return `migrations/${ path . basename ( normalized ) }` ;
80 }
81 return path. basename (normalized);
82 }
83
84 function ensureArray ( value ) {
85 if (Array. isArray (value)) return value;
86 return [];
87 }
88
89 function firstNonEmptyString ( ... values ) {
90 for ( const value of values) {
91 if ( typeof value === 'string' && value. trim ()) {
92 return value. trim ();
93 }
94 }
95 return null ;
96 }
97
98 function stableJson ( value ) {
99 return JSON . stringify (value, null , 2 );
100 }
101
102 function handoffFingerprint ( inputFreshness ) {
103 return `sha256:${ crypto . createHash ( 'sha256' ). update ( JSON . stringify ( inputFreshness )). digest ( 'hex' ) }` ;
104 }
105
106 function uniqueBy ( items , keyFn ) {
107 const out = [];
108 const seen = new Set ();
109 for ( const item of items) {
110 const key = keyFn (item);
111 if ( ! key || seen. has (key)) continue ;
112 seen. add (key);
113 out. push (item);
114 }
115 return out;
116 }
117
118 function normalizeSiteStrategy ( value ) {
119 if (value === 'new_site' || value === 'new' ) return 'new' ;
120 if (value === 'existing_site' || value === 'existing' ) return 'existing' ;
121 return value || null ;
122 }
123
124 function normalizeScopeValue ( value ) {
125 if ( typeof value === 'string' && value. trim ()) {
126 return { scope: value. trim (), explicitUrls: [] };
127 }
128 if (value && typeof value === 'object' ) {
129 return {
130 scope: firstNonEmptyString (value.scope, value.selectedScope),
131 explicitUrls: ensureArray (value.explicitUrls). filter (( item ) => typeof item === 'string' && item. trim ()),
132 };
133 }
134 return { scope: null , explicitUrls: [] };
135 }
136
137 function chooseAutoScope ( scopeSuggestions ) {
138 const items = ensureArray (scopeSuggestions);
139 const available = new Set (
140 items
141 . map (( item ) => (item && typeof item.scope === 'string' ? item.scope. trim () : '' ))
142 . filter (Boolean),
143 );
144 for ( const scope of [ 'full' , 'ecommerce' , 'blog' , 'home' ]) {
145 if (available. has (scope)) {
146 return scope;
147 }
148 }
149 return 'home' ;
150 }
151
152 // Accepts every key a mapping plan may carry its per-entity rows under. `entityDecisions` is
153 // the vocabulary rp-mapper uses for its own `mapping/entity-decisions/<entity>.json` artifacts,
154 // so a plan written in that vocabulary must resolve here too — omitting it produced a handoff
155 // that reported zero dynamic routes, zero collections and zero apps while still returning ok.
156 //
157 // BUG FIX: `mappingPlan.entities` is itself an ARRAY of per-entity objects in the real, shipped
158 // rp-mapper output -- never a plain object keyed by entity name. `typeof [] === 'object'` in
159 // JS, so the object-shaped branch below used to match an array too, and `Object.entries()` on
160 // an array yields index-keyed pairs (`['0', firstRow]`, `['1', secondRow]`, ...) -- every real
161 // entity silently became `sourceEntity: '0'`, `'1'`, `'2'`, etc. instead of its real name. This
162 // made the entity-QA gate (`entity-qa-attestation.js`) permanently unsatisfiable for
163 // every real full-mode migration: an honest, fully-covering attestation still failed with every
164 // real entity flagged `entity_qa_unexpected_entity` and every fake numeric-index row flagged
165 // `entity_qa_missing_entity`. The array case must be handled on its own, pulling each row's own
166 // identifying field -- `entityKey` is the canonical, normalized name already used everywhere
167 // else (`completion-report.json`'s own `entity` field matches it exactly); `sourceEntityRef` is
168 // a *different*, more verbose plugin-sourcing reference on some mapping-plan rows
169 // (e.g. `entityKey: 'seo-meta'` vs. `sourceEntityRef: 'plugin.wordpress-seo.seo-meta'`) and must
170 // never be preferred over `entityKey` when both are present.
171 function readEntityMappings ( mappingPlan ) {
172 if ( ! mappingPlan || typeof mappingPlan !== 'object' ) return [];
173 if (Array. isArray (mappingPlan.entityMappings)) return mappingPlan.entityMappings;
174 if (Array. isArray (mappingPlan.entityDecisions)) return mappingPlan.entityDecisions;
175 if (Array. isArray (mappingPlan.mappings)) return mappingPlan.mappings;
176 if (Array. isArray (mappingPlan.entities)) {
177 return mappingPlan.entities. map (( row ) => ({
178 ... (row || {}),
179 sourceEntity: firstNonEmptyString ((row || {}).entityKey, (row || {}).sourceEntityRef, (row || {}).sourceEntity, (row || {}).targetEntity),
180 }));
181 }
182 if (mappingPlan.entities && typeof mappingPlan.entities === 'object' ) {
183 return Object. entries (mappingPlan.entities). map (([ sourceEntity , value ]) => ({
184 sourceEntity,
185 ... (value || {}),
186 }));
187 }
188 return [];
189 }
190
191 function readSetupRequirements ( setupRequirements ) {
192 if (Array. isArray (setupRequirements)) return setupRequirements;
193 if ( ! setupRequirements || typeof setupRequirements !== 'object' ) return [];
194 if (Array. isArray (setupRequirements.requirements)) return setupRequirements.requirements;
195 if (Array. isArray (setupRequirements.items)) return setupRequirements.items;
196 return [];
197 }
198
199 function readSetupBlockers ( setupBlockers ) {
200 if (Array. isArray (setupBlockers)) return setupBlockers;
201 if ( ! setupBlockers || typeof setupBlockers !== 'object' ) return [];
202 if (Array. isArray (setupBlockers.blockers)) return setupBlockers.blockers;
203 if (Array. isArray (setupBlockers.items)) return setupBlockers.items;
204 return [];
205 }
206
207 function readMappingGaps ( mappingPlan , mappingGaps ) {
208 if (Array. isArray (mappingGaps)) return mappingGaps;
209 if (mappingGaps && Array. isArray (mappingGaps.gaps)) return mappingGaps.gaps;
210 if (mappingPlan && Array. isArray (mappingPlan.faithfulnessLedger)) return mappingPlan.faithfulnessLedger;
211 return [];
212 }
213
214 function normalizeAppRequirement ( item ) {
215 if ( ! item || typeof item !== 'object' ) return null ;
216 const appName = firstNonEmptyString (item.appName, item.name, item.app, item.displayName);
217 if ( ! appName) return null ;
218 return {
219 appName,
220 appDefId: firstNonEmptyString (item.appDefId, item.appId, item.id),
221 purpose: firstNonEmptyString (item.purpose, item.reason, item.description),
222 requiredByEntities: ensureArray (item.requiredByEntities || item.requiredBy || item.entities),
223 status: firstNonEmptyString (item.status, item.requirementStatus),
224 automation: firstNonEmptyString (item.automation, item.automationMode),
225 verificationSource: firstNonEmptyString (item.verificationSource, item.source),
226 };
227 }
228
229 function normalizeCollectionRequirement ( item ) {
230 if ( ! item || typeof item !== 'object' ) return null ;
231 const collectionName = firstNonEmptyString (item.collectionName, item.name, item.collectionId);
232 if ( ! collectionName) return null ;
233 return {
234 collectionName,
235 collectionId: firstNonEmptyString (item.collectionId, item.dataCollectionId),
236 collectionPurpose: firstNonEmptyString (item.collectionPurpose, item.purpose, item.description),
237 requiredByEntities: ensureArray (item.requiredByEntities || item.requiredBy || item.entities),
238 fields: ensureArray (item.fields),
239 references: ensureArray (item.references),
240 automation: firstNonEmptyString (item.automation, item.automationMode),
241 verificationSource: firstNonEmptyString (item.verificationSource, item.source),
242 };
243 }
244
245 function routeKindForEntity ( mapping ) {
246 const explicit = firstNonEmptyString (mapping.routeKind, mapping.templateKind);
247 if (explicit) return explicit;
248 if (mapping.targetStrategy === 'static_page' ) return 'static' ;
249 if (mapping.targetDomain === 'cms' || mapping.targetDomain === 'stores' || mapping.targetDomain === 'blog' ) return 'dynamic' ;
250 return 'dynamic' ;
251 }
252
253 function normalizeBoolean ( value ) {
254 return typeof value === 'boolean' ? value : null ;
255 }
256
257 function normalizeRouteIntent ( entityMappings ) {
258 const staticRoutes = [];
259 const dynamicRoutes = [];
260 const redirectIntents = [];
261 const slugPolicies = [];
262 const nativeDataSources = [];
263
264 for ( const mapping of entityMappings) {
265 const urlPolicy = mapping && typeof mapping.urlPolicy === 'object'
266 ? mapping.urlPolicy
267 : (mapping && typeof mapping.urlPreservation === 'object' ? mapping.urlPreservation : null );
268 if ( ! urlPolicy || urlPolicy.public !== true ) {
269 continue ;
270 }
271
272 const route = {
273 sourceEntity: firstNonEmptyString (mapping.sourceEntity, mapping.entityName),
274 sourceSemantics: firstNonEmptyString (mapping.sourceSemantics),
275 targetRef: firstNonEmptyString (mapping.targetRef),
276 targetDomain: firstNonEmptyString (mapping.targetDomain),
277 targetEntity: firstNonEmptyString (mapping.targetEntity),
278 targetClassification: firstNonEmptyString (mapping.targetClassification),
279 sourceBasePath: firstNonEmptyString (urlPolicy.sourceBasePath),
280 sourceSlugField: firstNonEmptyString (urlPolicy.sourceSlugField),
281 sourceUrlField: firstNonEmptyString (urlPolicy.sourceUrlField),
282 targetBasePath: firstNonEmptyString (urlPolicy.targetBasePath),
283 targetSlugField: firstNonEmptyString (urlPolicy.targetSlugField),
284 preserveBasePath: normalizeBoolean (urlPolicy.preserveBasePath),
285 preserveSlug: normalizeBoolean (urlPolicy.preserveSlug),
286 redirectMode: firstNonEmptyString (urlPolicy.redirectMode),
287 routeKind: routeKindForEntity (mapping),
288 };
289
290 if (route.routeKind === 'static' ) {
291 staticRoutes. push (route);
292 } else {
293 dynamicRoutes. push (route);
294 nativeDataSources. push ({
295 sourceEntity: route.sourceEntity,
296 targetRef: route.targetRef,
297 targetDomain: route.targetDomain,
298 targetEntity: route.targetEntity,
299 targetClassification: route.targetClassification,
300 routeKind: route.routeKind,
301 sourceBasePath: route.sourceBasePath,
302 targetBasePath: route.targetBasePath,
303 preserveSlug: route.preserveSlug,
304 });
305 }
306
307 slugPolicies. push ({
308 sourceEntity: route.sourceEntity,
309 sourceBasePath: route.sourceBasePath,
310 sourceSlugField: route.sourceSlugField,
311 targetBasePath: route.targetBasePath,
312 targetSlugField: route.targetSlugField,
313 preserveBasePath: route.preserveBasePath,
314 preserveSlug: route.preserveSlug,
315 });
316
317 if (route.redirectMode && route.redirectMode !== 'none' ) {
318 redirectIntents. push ({
319 sourceEntity: route.sourceEntity,
320 sourceBasePath: route.sourceBasePath,
321 targetBasePath: route.targetBasePath,
322 redirectMode: route.redirectMode,
323 });
324 }
325 }
326
327 return {
328 routeIntent: {
329 staticRoutes,
330 dynamicRoutes,
331 redirectIntents,
332 slugPolicies,
333 },
334 nativeDataSources: uniqueBy (nativeDataSources, ( item ) =>
335 [item.targetRef, item.sourceEntity, item.sourceBasePath]. filter (Boolean). join ( '|' )),
336 };
337 }
338
339 function normalizeRequirementBinding ( requirement ) {
340 if ( ! requirement || typeof requirement !== 'object' ) return null ;
341 return {
342 requirementId: firstNonEmptyString (requirement.requirementId, requirement.id),
343 requirementClass: firstNonEmptyString (requirement.requirementClass, requirement.class),
344 name: firstNonEmptyString (requirement.name),
345 requiredBy: ensureArray (requirement.requiredBy),
346 expectedState: requirement.expectedState || null ,
347 automation: firstNonEmptyString (requirement.automation, requirement.automationMode),
348 verificationStatus: firstNonEmptyString (requirement.verificationStatus, requirement.status),
349 dependencyIds: ensureArray (requirement.dependencyIds),
350 notes: firstNonEmptyString (requirement.notes),
351 };
352 }
353
354 function extractCmsCollections ( setupPlan , setupRequirements ) {
355 const fromPlan = ensureArray (setupPlan && setupPlan.requiredCollections)
356 . map (normalizeCollectionRequirement)
357 . filter (Boolean);
358 const fromRequirements = readSetupRequirements (setupRequirements)
359 . filter (( item ) => {
360 const klass = firstNonEmptyString (item.requirementClass, item.class, item.type);
361 return klass && /collection/ i . test (klass);
362 })
363 . map (( item ) => normalizeCollectionRequirement ({
364 collectionName: firstNonEmptyString (
365 item.collectionName,
366 item.name,
367 item.expectedState && (item.expectedState.collectionName || item.expectedState.collectionId),
368 ),
369 collectionId: item.expectedState && firstNonEmptyString (item.expectedState.collectionId),
370 collectionPurpose: firstNonEmptyString (item.notes),
371 requiredByEntities: ensureArray (item.requiredBy),
372 fields: ensureArray (item.expectedState && item.expectedState.fields),
373 references: ensureArray (item.expectedState && item.expectedState.references),
374 automation: firstNonEmptyString (item.automation, item.automationMode),
375 verificationSource: firstNonEmptyString (item.verificationStatus, item.source),
376 }))
377 . filter (Boolean);
378 return uniqueBy ([ ... fromPlan, ... fromRequirements], ( item ) => item.collectionId || item.collectionName);
379 }
380
381 function extractManualBlockers ( setupRequirements , setupBlockers ) {
382 const blockers = readSetupBlockers (setupBlockers). map (( item ) => ({
383 code: firstNonEmptyString (item.code),
384 severity: firstNonEmptyString (item.severity),
385 requirementId: firstNonEmptyString (item.requirementId),
386 description: firstNonEmptyString (item.description),
387 whyBlocked: firstNonEmptyString (item.whyBlocked),
388 recommendedAction: firstNonEmptyString (item.recommendedAction),
389 }));
390 const manualRequirements = readSetupRequirements (setupRequirements)
391 . filter (( item ) => {
392 const automation = firstNonEmptyString (item.automation, item.automationMode);
393 const verificationStatus = firstNonEmptyString (item.verificationStatus, item.status);
394 return automation === 'manual' || automation === 'blocked' || verificationStatus === 'blocked' ;
395 })
396 . map (( item ) => ({
397 code: firstNonEmptyString (item.requirementClass, item.class, item.type),
398 severity: firstNonEmptyString (item.severity, 'blocker' ),
399 requirementId: firstNonEmptyString (item.requirementId, item.id),
400 description: firstNonEmptyString (item.name, item.notes),
401 whyBlocked: firstNonEmptyString (item.notes),
402 recommendedAction: firstNonEmptyString (item.recommendedAction),
403 }));
404 return uniqueBy ([ ... blockers, ... manualRequirements], ( item ) =>
405 item.requirementId || `${ item . code || 'blocker'}|${ item . description || ''}` );
406 }
407
408 function extractMappingSummary ( entityMappings , mappingPlan , mappingGaps ) {
409 const entities = entityMappings. map (( mapping ) => ({
410 sourceEntity: firstNonEmptyString (mapping.sourceEntity, mapping.entityName),
411 sourceSemantics: firstNonEmptyString (mapping.sourceSemantics),
412 targetRef: firstNonEmptyString (mapping.targetRef),
413 targetDomain: firstNonEmptyString (mapping.targetDomain),
414 targetEntity: firstNonEmptyString (mapping.targetEntity),
415 targetClassification: firstNonEmptyString (mapping.targetClassification),
416 status: firstNonEmptyString (mapping.status),
417 urlPolicy: mapping.urlPolicy || null ,
418 }));
419 const gaps = readMappingGaps (mappingPlan, mappingGaps). map (( gap ) => ({
420 code: firstNonEmptyString (gap.code),
421 severity: firstNonEmptyString (gap.severity),
422 entityName: firstNonEmptyString (gap.entityName, gap.sourceEntity),
423 gapType: firstNonEmptyString (gap.gapType),
424 description: firstNonEmptyString (gap.description),
425 recommendedHandling: firstNonEmptyString (gap.recommendedHandling),
426 ... Object. fromEntries ( require ( './mapping-field-contract.js' ). GAP_KEYS
427 . filter ( key => Object. hasOwn (gap,key)). map ( key => [key,gap[key]])),
428 }));
429 const reviewPublication = require ( './mapping-field-contract.js' ). mappingReviewFallback (mappingPlan,mappingGaps);
430 return { entities, gaps, ... (reviewPublication ? {reviewPublication} : {}), ... (mappingPlan?.fieldContractVersion === 1 ? {
431 mappingPlanDigest: require ( './mapping-field-contract.js' ). digest (mappingPlan),executionScope:mappingPlan.executionScope,
432 } : {}) };
433 }
434
435 function extractLocale ( sourceSchema ) {
436 return firstNonEmptyString (
437 sourceSchema && sourceSchema.locale,
438 sourceSchema && sourceSchema.language,
439 sourceSchema && sourceSchema.sourceMeta && sourceSchema.sourceMeta.locale,
440 sourceSchema && sourceSchema.sourceMeta && sourceSchema.sourceMeta.language,
441 );
442 }
443
444 function extractDirection ( sourceSchema ) {
445 return firstNonEmptyString (
446 sourceSchema && sourceSchema.direction,
447 sourceSchema && sourceSchema.sourceMeta && sourceSchema.sourceMeta.direction,
448 );
449 }
450
451 function inferWebsiteScopeOptions ( routeIntent , mappingSummary ) {
452 const suggestions = [{ scope: 'home' , reason: 'Always available as the minimum storefront continuation scope.' }];
453 const dynamicRoutes = ensureArray (routeIntent && routeIntent.dynamicRoutes);
454 const sourceEntities = new Set (dynamicRoutes. map (( route ) => route.sourceEntity). filter (Boolean));
455 const mappedEntities = new Set ( ensureArray (mappingSummary && mappingSummary.entities). map (( entity ) => entity.sourceEntity). filter (Boolean));
456
457 const hasEcommerce = sourceEntities. has ( 'product' ) || sourceEntities. has ( 'product_category' );
458 const hasBlog = sourceEntities. has ( 'post' ) || sourceEntities. has ( 'blog_category' ) || sourceEntities. has ( 'blog_tag' );
459 const hasCms = Array. from (mappedEntities). some (( name ) => / ^ page $| cms | resource | guide/ i . test ( String (name)));
460
461 if (hasEcommerce) {
462 suggestions. push ({ scope: 'ecommerce' , reason: 'Stores product and/or category routes were discovered in the migration mapping.' });
463 }
464 if (hasBlog) {
465 suggestions. push ({ scope: 'blog' , reason: 'Blog or knowledge-base post routes were discovered in the migration mapping.' });
466 }
467 if (hasEcommerce || hasBlog || hasCms) {
468 suggestions. push ({ scope: 'full' , reason: 'Multiple public route families were discovered, so a fuller storefront clone may be appropriate.' });
469 }
470 suggestions. push ({ scope: 'specific' , reason: 'Use explicit URLs when the user wants a curated subset of pages instead of a broad area.' });
471 return uniqueBy (suggestions, ( item ) => item.scope);
472 }
473
474 function buildFrontendProjectDir ( projectDir , frontendConfigPath ) {
475 if (frontendConfigPath) {
476 return rel (projectDir, path. dirname (frontendConfigPath));
477 }
478 return 'frontend' ;
479 }
480
481 function createInputFreshness ( projectDir ) {
482 const artifacts = {};
483 for ( const [ name , relativePath ] of Object. entries ( HANDOFF_ARTIFACTS )) {
484 artifacts[name] = {
485 path: relativePath,
486 sha256: hashArtifact (projectDir, relativePath),
487 };
488 }
489 return {
490 schemaVersion: FRESHNESS_SCHEMA_VERSION ,
491 generatedAt: new Date (). toISOString (),
492 artifacts,
493 };
494 }
495
496 function compareInputFreshness ( current , recorded ) {
497 const changes = [];
498 if ( ! recorded || typeof recorded !== 'object' ) {
499 return { ok: false , stale: true , changes: [{ field: 'inputFreshness' , reason: 'missing' }] };
500 }
501 if (recorded.schemaVersion !== FRESHNESS_SCHEMA_VERSION ) {
502 changes. push ({
503 field: 'inputFreshness.schemaVersion' ,
504 expected: recorded.schemaVersion,
505 actual: FRESHNESS_SCHEMA_VERSION ,
506 });
507 }
508 for ( const [ name , currentEntry ] of Object. entries (current.artifacts || {})) {
509 const recordedEntry = (recorded.artifacts && recorded.artifacts[name]) || {};
510 if (currentEntry.sha256 !== recordedEntry.sha256) {
511 changes. push ({
512 field: `inputFreshness.artifacts.${ name }.sha256` ,
513 path: currentEntry.path,
514 expected: recordedEntry.sha256 || null ,
515 actual: currentEntry.sha256 || null ,
516 });
517 }
518 }
519 return {
520 ok: changes. length === 0 ,
521 stale: changes. length > 0 ,
522 changes,
523 };
524 }
525
526 function renderSummary ( handoff ) {
527 const lines = [
528 '# Website Handoff Summary' ,
529 '' ,
530 `Generated: ${ handoff . generatedAt }` ,
531 '' ,
532 `- Delivery mode: ${ handoff . deliveryMode }` ,
533 `- Website scope: ${ handoff . websiteScope . selectedScope || 'not yet selected'}` ,
534 `- Destination strategy: ${ handoff . destination . siteStrategy || 'unknown'}` ,
535 `- Destination site id: ${ handoff . destination . siteId || 'not yet resolved'}` ,
536 `- Frontend phase allowed now: ${ handoff . frontendPhase . allowedNow }` ,
537 '' ,
538 '## Scope Suggestions' ,
539 '' ,
540 ];
541
542 if (handoff.websiteScope.scopeSuggestions. length === 0 ) {
543 lines. push ( '- None.' );
544 } else {
545 for ( const suggestion of handoff.websiteScope.scopeSuggestions) {
546 lines. push ( `- ${ suggestion . scope }: ${ suggestion . reason }` );
547 }
548 }
549
550 lines. push (
551 '' ,
552 '## Dynamic Route Families' ,
553 '' ,
554 );
555
556 if (handoff.routeIntent.dynamicRoutes. length === 0 ) {
557 lines. push ( '- None declared yet.' );
558 } else {
559 for ( const route of handoff.routeIntent.dynamicRoutes) {
560 lines. push (
561 `- ${ route . sourceEntity || 'unknown'} -> ${ route . targetRef || [ route . targetDomain , route . targetEntity ]. filter ( Boolean ). join ( '/' ) } ` +
562 `(source base path: ${ route . sourceBasePath || 'n/a'}, target base path: ${ route . targetBasePath || 'deferred'})` ,
563 );
564 }
565 }
566
567 lines. push ( '' , '## Wix Bindings' , '' );
568 if (handoff.bindings.wixApps. length === 0 ) {
569 lines. push ( '- No Wix app bindings declared.' );
570 } else {
571 for ( const app of handoff.bindings.wixApps) {
572 lines. push ( `- ${ app . appName }${ app . appDefId ? ` (${ app . appDefId })` : ''}` );
573 }
574 }
575
576 lines. push ( '' , '## CMS Collections' , '' );
577 if (handoff.bindings.cmsCollections. length === 0 ) {
578 lines. push ( '- No CMS collections declared.' );
579 } else {
580 for ( const collection of handoff.bindings.cmsCollections) {
581 lines. push ( `- ${ collection . collectionName }` );
582 }
583 }
584
585 if (handoff.mappingSummary?.reviewPublication) lines. push ( '' , '- Current-run Migration review publication is unavailable (no importer execution).' , `- ${ handoff . mappingSummary . reviewPublication . requiredInput }` );
586 lines. push ( '' , '## Manual Blockers' , '' );
587 if (handoff.setupContract.manualBlockers. length === 0 ) {
588 lines. push ( '- None.' );
589 } else {
590 for ( const blocker of handoff.setupContract.manualBlockers) {
591 lines. push ( `- ${ blocker . requirementId || blocker . code || 'blocker'}: ${ blocker . description || blocker . whyBlocked || 'see setup artifacts'}` );
592 }
593 }
594
595 return `${ lines . join ( ' \n ' ) } \n ` ;
596 }
597
598 async function writeFileAtomic ( filePath , text ) {
599 await fs. mkdir (path. dirname (filePath), { recursive: true });
600 const tempPath = `${ filePath }.${ process . pid }.${ Date . now () }.tmp` ;
601 await fs. writeFile (tempPath, text, 'utf8' );
602 await fs. rename (tempPath, filePath);
603 }
604
605 // Read from the contract, not retyped. This file declared two paths of its own and therefore did
606 // not know the third field existed -- so it reported "provisioned" on a receipt that verified two
607 // of three, which is precisely the drift the contract adapter exists to prevent.
608 const invoiceContract = require ( '../resources/rp-target-wix/lib/order-invoice-contract.js' );
609 const dataExtensionSchema = require ( '../resources/rp-target-wix/lib/data-extension-schema.js' );
610 const INVOICE_FILE_ID_PATH = `extendedFields.namespaces._user_fields.${ invoiceContract . ROLES . fileId }` ;
611 const INVOICE_NUMBER_PATH = `extendedFields.namespaces._user_fields.${ invoiceContract . ROLES . number }` ;
612 const INVOICE_URL_PATH = `extendedFields.namespaces._user_fields.${ invoiceContract . ROLES . url }` ;
613
614 // The member-facing document contract.
615 //
616 // The handoff is generated BEFORE the import runs, so it cannot carry per-order file ids -- they
617 // do not exist yet, and there could be thousands of them. What it carries is the CONTRACT: where
618 // the values live on an order once written, that a preserved document is a private file rather
619 // than a URL, and that the only way to hand one to a member is a backend that takes an ORDER id
620 // and resolves the file server-side.
621 //
622 // That last part is the load-bearing bit. A frontend that accepts a file id from the client lets
623 // any member pull any other member's tax invoice, so the contract states the rule rather than
624 // leaving the frontend to infer it.
625 function buildMemberDocuments ({ extendedFieldVerification , invoiceOutcome }) {
626 // The SAME judgement the writer makes, made by the same function. Two readings of one receipt
627 // is a contradiction waiting to happen, and it happened: this file filtered failed entries out
628 // and unioned the rest, while the writer requires every entry on the current contract to have
629 // passed. A receipt with one passing and one failing entry told the frontend the fields were
630 // there while the writer refused every write against them.
631 const writePaths = invoiceContract. writePaths ( '_user_fields' );
632 const fieldsProvisioned = dataExtensionSchema. validateExtendedFieldWriterReferences ({
633 writePaths,
634 setupVerification: extendedFieldVerification,
635 contractVersion: invoiceContract. version (),
636 }).valid;
637
638 return {
639 invoices: {
640 // null, not false, when the import has not reported yet: "we do not know" and "there are
641 // none" are different answers, and a frontend must not render "no invoices" for the first.
642 preserved: invoiceOutcome ? Boolean (invoiceOutcome.preserved) : null ,
643 strategy: (invoiceOutcome && invoiceOutcome.strategy) || null ,
644 fieldsProvisioned,
645 fileIdPath: INVOICE_FILE_ID_PATH ,
646 numberPath: INVOICE_NUMBER_PATH ,
647 // The original link: clickable now, and it rots. The frontend should prefer the authorized
648 // download and treat this as a fallback that may already be dead.
649 originalLinkPath: INVOICE_URL_PATH ,
650 download: {
651 generator: 'skills/wix-headless-replatform/scripts/generate-invoice-download-backend.mjs' ,
652 // What the generator emits, so a frontend has an address to POST to rather than a rule to
653 // reimplement. The route is real: it resolves the member, the order and the file through
654 // the Wix SDK, and it needs no `locals` wiring from the app.
655 endpoint: '/api/invoice-download' ,
656 method: 'POST' ,
657 acceptsOnly: [ 'orderId' ],
658 rejects: [ 'fileId' ],
659 expiryMinutes: 10 ,
660 rule: 'The backend takes an order id, checks the current member may read that order, and resolves the file server-side. It must never accept a file id from the client.' ,
661 },
662 notes: [
663 'A preserved document is a PRIVATE Media file. It has no public URL; a short-lived download URL is minted on demand.' ,
664 'An order may carry a number with no document. Render the number as a reference rather than a broken link.' ,
665 fieldsProvisioned
666 ? null
667 : 'The order fields are not provisioned yet, so no order carries these values. Run setup before building against them.' ,
668 ]. filter (Boolean),
669 },
670 };
671 }
672
673 async function generateWebsiteHandoff ( projectDir ) {
674 const handoffDir = path. join (projectDir, 'website' );
675 const decisionsPath = path. join (projectDir, HANDOFF_ARTIFACTS .decisions);
676 const sourceSchemaPath = path. join (projectDir, HANDOFF_ARTIFACTS .sourceSchema);
677 const mappingPlanPath = path. join (projectDir, HANDOFF_ARTIFACTS .mappingPlan);
678 const mappingGapsPath = path. join (projectDir, HANDOFF_ARTIFACTS .mappingGaps);
679 const setupPlanPath = path. join (projectDir, HANDOFF_ARTIFACTS .setupPlan);
680 const setupRequirementsPath = path. join (projectDir, HANDOFF_ARTIFACTS .setupRequirements);
681 const setupBlockersPath = path. join (projectDir, HANDOFF_ARTIFACTS .setupBlockers);
682 const wixEnvPath = path. join (projectDir, HANDOFF_ARTIFACTS .wixEnv);
683 const frontendConfigPath = path. join (projectDir, HANDOFF_ARTIFACTS .frontendConfig);
684 const completionReportPath = path. join (projectDir, HANDOFF_ARTIFACTS .completionReport);
685
686 const decisions = await readJsonIfExists (decisionsPath);
687 let sourceSchema = await readJsonIfExists (sourceSchemaPath);
688 let mappingPlan = await readJsonIfExists (mappingPlanPath);
689 const quickPlan = await readJsonIfExists (path. join (projectDir, 'quick-mode' , 'plan.json' ));
690 const mappingGaps = await readJsonIfExists (mappingGapsPath);
691 const setupPlan = await readJsonIfExists (setupPlanPath);
692 const setupRequirements = await readJsonIfExists (setupRequirementsPath);
693 const setupBlockers = await readJsonIfExists (setupBlockersPath);
694 const extendedFieldVerification = await readJsonIfExists (path. join (projectDir, 'setup' , 'extended-field-verification.json' ));
695 const invoiceOutcome = await readJsonIfExists (path. join (projectDir, 'import' , 'invoice-outcome.json' ));
696 const wixEnv = await readEnvIfExists (wixEnvPath);
697 const frontendConfig = await readJsonIfExists (frontendConfigPath);
698 // A stopped run's aborted report is not the backend import completing.
699 const completionReportExists = await pathExists (completionReportPath)
700 && ! ( await readJsonIfExists (completionReportPath))?.interruption;
701 const linkAudit = await readLinkAudit (projectDir);
702
703 const deliveryMode = firstNonEmptyString (
704 getEffectiveDeliveryMode (decisions || {}),
705 wixEnv. WIX_DELIVERY_MODE ,
706 );
707 if ( ! deliveryMode) {
708 throw new Error ( 'website handoff requires deliveryMode to be decided' );
709 }
710 if ( ! sourceSchema && quickPlan) sourceSchema = { sourceUrl: quickPlan.sourceUrl, platform: quickPlan.adapter?.id?. replace ( / ^ quick-/ , '' ) };
711 if ( ! mappingPlan && quickPlan) mappingPlan = { entities: (quickPlan.entities || []). map (( entity ) => ({ sourceEntity: entity.id, target: entity.target })) };
712 if ( ! sourceSchema) {
713 throw new Error ( 'website handoff requires source-schema.json' );
714 }
715 if ( ! mappingPlan) {
716 throw new Error ( 'website handoff requires mapping/mapping-plan.json' );
717 }
718 if ( ! setupPlan) {
719 throw new Error ( 'website handoff requires setup/setup-plan.json' );
720 }
721 if ( ! setupRequirements) {
722 throw new Error ( 'website handoff requires setup/setup-requirements.json' );
723 }
724
725 const entityMappings = readEntityMappings (mappingPlan);
726 /*
727 * A mapping plan that exists but yields no per-entity rows means its rows are under a key
728 * this reader does not know — not that the migration has no entities. Reporting that as a
729 * clean handoff with zero routes is the failure mode this guard exists to prevent: the
730 * frontend build would be handed no bindings at all with nothing signalling the loss.
731 */
732 const handoffWarnings = [];
733 if (mappingPlan && typeof mappingPlan === 'object' && entityMappings. length === 0 ) {
734 handoffWarnings. push ({
735 code: 'mapping_plan_yielded_no_entities' ,
736 severity: 'blocker' ,
737 message: 'mapping/mapping-plan.json was read but produced no per-entity rows. Its rows are '
738 + 'probably under an unrecognized key; route intent, CMS bindings and app requirements '
739 + 'will all be empty until that is corrected.' ,
740 keysSeen: Object. keys (mappingPlan). slice ( 0 , 20 ),
741 acceptedKeys: [ 'entityMappings' , 'entityDecisions' , 'mappings' , 'entities' ],
742 });
743 }
744 const apps = uniqueBy (
745 ensureArray (setupPlan.requiredApps). map (normalizeAppRequirement). filter (Boolean),
746 ( item ) => item.appDefId || item.appName,
747 );
748 const { routeIntent , nativeDataSources } = normalizeRouteIntent (entityMappings);
749 const cmsCollections = extractCmsCollections (setupPlan, setupRequirements);
750 const mappingSummary = extractMappingSummary (entityMappings, mappingPlan, mappingGaps);
751 const manualBlockers = extractManualBlockers (setupRequirements, setupBlockers);
752 const frontendConfigAbsolute = frontendConfig ? frontendConfigPath : null ;
753 const inputFreshness = createInputFreshness (projectDir);
754 const automationMode = isExplicitUserOneClick (decisions || {}) ? 'one_click' : 'manual' ;
755 const faceliftMode = getDecisionValue (decisions || {}, 'faceliftMode' );
756 const scopeDecision = normalizeScopeValue ( getDecisionValue (decisions || {}, 'websiteScope' ));
757 const scopeSuggestions = inferWebsiteScopeOptions (routeIntent, mappingSummary);
758 const autoSelectedScope = ! scopeDecision.scope && deliveryMode === 'management_and_website' && automationMode === 'one_click'
759 ? chooseAutoScope (scopeSuggestions)
760 : null ;
761 const selectedScope = scopeDecision.scope || autoSelectedScope;
762
763 const handoff = {
764 schemaVersion: SCHEMA_VERSION ,
765 version: 1 ,
766 generatedAt: new Date (). toISOString (),
767 migrationProject: {
768 name: path. basename (projectDir),
769 root: inferMigrationProjectRoot (projectDir),
770 },
771 source: {
772 url: firstNonEmptyString (
773 sourceSchema.sourceUrl,
774 getDecisionValue (decisions || {}, 'sourceUrl' ),
775 ),
776 platform: firstNonEmptyString (
777 sourceSchema.platform,
778 getDecisionValue (decisions || {}, 'sourcePlatform' ),
779 ),
780 locale: extractLocale (sourceSchema),
781 direction: extractDirection (sourceSchema),
782 },
783 deliveryMode,
784 automationMode: automationMode || 'manual' ,
785 facelift: {
786 requested: faceliftMode === 'requested' ,
787 requestedBy: faceliftMode === 'requested' ? 'user' : null ,
788 constraints: [ 'preserve_brand_identity' , 'preserve_site_structure' , 'preserve_content' ],
789 },
790 memberDocuments: buildMemberDocuments ({ extendedFieldVerification, invoiceOutcome }),
791 websiteScope: {
792 selectedScope,
793 explicitUrls: scopeDecision.explicitUrls,
794 scopeSuggestions,
795 selectedFromDecision: Boolean (scopeDecision.scope),
796 autoSelectedInOneClickMode: Boolean (autoSelectedScope),
797 defaultStandaloneScope: 'home' ,
798 },
799 destination: {
800 siteStrategy: normalizeSiteStrategy (
801 firstNonEmptyString (
802 getDecisionValue (decisions || {}, 'targetSiteStrategy' ),
803 wixEnv. WIX_SITE_STRATEGY ,
804 ),
805 ),
806 siteId: firstNonEmptyString (wixEnv. WIX_SITE_ID ),
807 appId: firstNonEmptyString (
808 frontendConfig && (frontendConfig.appId || frontendConfig.applicationId),
809 wixEnv. WIX_APP_ID ,
810 ),
811 frontendProjectDir: buildFrontendProjectDir (projectDir, frontendConfigAbsolute),
812 },
813 frontendPhase: {
814 allowedNow: completionReportExists ? 'build' : 'plan' ,
815 buildAllowedAfter: 'backend-import-complete' ,
816 parallelPlanAllowed: true ,
817 recommendedParallelExecution: 'subagent_after_handoff' ,
818 },
819 routeIntent,
820 bindings: {
821 wixApps: apps,
822 cmsCollections,
823 nativeDataSources,
824 },
825 setupContract: {
826 requiredApps: apps,
827 collections: cmsCollections,
828 manualBlockers,
829 },
830 mappingSummary,
831 artifacts: {
832 mappingPlan: HANDOFF_ARTIFACTS .mappingPlan,
833 setupRequirements: HANDOFF_ARTIFACTS .setupRequirements,
834 setupPlan: HANDOFF_ARTIFACTS .setupPlan,
835 urlPreservationState: 'state/url-preservation/' ,
836 ... (linkAudit ? { linkAudit: LINK_AUDIT_PATH } : {}),
837 },
838 ... (linkAudit ? { linkAudit: { stage: linkAudit.stage, counts: linkAudit.counts, missingCutoverUiIntegration: true , pendingDestinations: linkAudit.pendingDestinations. map (( row ) => ({ id: row.id, pageFamily: row.pageFamily, occurrenceIds: row.occurrenceIds })), unresolved: linkAudit.unresolved. map (( row ) => ({ id: row.id, reason: row.reason, occurrenceIds: row.occurrenceIds })) }, operatorActions: linkOperatorActions (linkAudit) } : {}),
839 inputFreshness,
840 warnings: handoffWarnings,
841 handoffFingerprint: handoffFingerprint (inputFreshness),
842 };
843
844 const summary = renderSummary (handoff);
845 const handoffPath = path. join (handoffDir, 'handoff.json' );
846 const summaryPath = path. join (handoffDir, 'handoff-summary.md' );
847 await writeFileAtomic (handoffPath, `${ stableJson ( handoff ) } \n ` );
848 await writeFileAtomic (summaryPath, summary);
849
850 return {
851 handoffPath,
852 summaryPath,
853 handoff,
854 };
855 }
856
857 async function validateWebsiteHandoff ( projectDir ) {
858 const handoffPath = path. join (projectDir, 'website' , 'handoff.json' );
859 const handoff = await readJsonIfExists (handoffPath);
860 if ( ! handoff) {
861 return {
862 ok: false ,
863 present: false ,
864 stale: true ,
865 changes: [{ field: 'website/handoff.json' , reason: 'missing' }],
866 handoff: null ,
867 };
868 }
869 const current = createInputFreshness (projectDir);
870 const comparison = compareInputFreshness (current, handoff.inputFreshness);
871 const blockingWarnings = ensureArray (handoff.warnings). filter (( warning ) => warning && warning.severity === 'blocker' );
872 return {
873 ok: comparison.ok && blockingWarnings. length === 0 ,
874 present: true ,
875 stale: comparison.stale,
876 changes: comparison.changes,
877 blockingWarnings,
878 handoff,
879 current,
880 };
881 }
882
883 // ---------------------------------------------------------------------------------------------
884 // Post-execution handoff.
885 //
886 // `generateWebsiteHandoff` above is the BACKEND-TO-FRONTEND package: a website build needs its
887 // bindings contract before it starts, so that document keeps being produced when it is, from the
888 // plan, because at that point the plan is all there is. What it must never be is the run's
889 // account of the migration -- that is what produced a confident document saying "No CMS
890 // collections declared" about a run that landed three.
891 //
892 // This generator is the account of the migration. It runs after the last write, refuses without
893 // a final outcome record, and reads what LANDED, not what was planned.
894 // ---------------------------------------------------------------------------------------------
895
896 const SETUP_VERIFICATION_FILE = 'setup/setup-verification.json' ;
897
898 function verificationRequirements ( setupVerification ) {
899 if ( ! setupVerification || typeof setupVerification !== 'object' ) return [];
900 return ensureArray (setupVerification.requirements);
901 }
902
903 function requirementKind ( requirement ) {
904 return firstNonEmptyString (requirement.kind, requirement.requirementClass, requirement.class, requirement.type) || '' ;
905 }
906
907 // What actually got provisioned, read off the setup verification receipt rather than the plan.
908 function extractLandedBindings ( setupVerification ) {
909 const rows = verificationRequirements (setupVerification);
910 const cmsCollections = uniqueBy (
911 rows
912 . filter (( row ) => /collection/ i . test ( requirementKind (row)) && row.status === 'passed' )
913 . map (( row ) => ({
914 collectionName: firstNonEmptyString (row.collectionName, row.name, row.id),
915 collectionId: firstNonEmptyString (row.collectionId, row.id),
916 }))
917 . filter (( row ) => row.collectionName || row.collectionId),
918 ( row ) => row.collectionId || row.collectionName,
919 );
920 const wixApps = uniqueBy (
921 rows
922 . filter (( row ) => /app/ i . test ( requirementKind (row)) && row.status === 'passed' )
923 . map (( row ) => ({
924 appName: firstNonEmptyString (row.appName, row.name, row.id),
925 appDefId: firstNonEmptyString (row.appDefId, row.id),
926 }))
927 . filter (( row ) => row.appName || row.appDefId),
928 ( row ) => row.appDefId || row.appName,
929 );
930 return { cmsCollections, wixApps };
931 }
932
933 // An "open blocker" is a setup requirement the run never got past, plus anything the completion
934 // report recorded as still needing a human. Both are execution facts; neither is in the plan.
935 function extractOpenBlockers ( setupVerification , completionReport ) {
936 const fromVerification = verificationRequirements (setupVerification)
937 . filter (( row ) => row.status && row.status !== 'passed' )
938 . map (( row ) => ({
939 requirementId: firstNonEmptyString (row.id, row.requirementId) || 'requirement' ,
940 status: row.status,
941 description: firstNonEmptyString (row.description, row.name, row.notes) || 'see setup verification receipt' ,
942 }));
943 const fromReport = ensureArray (completionReport && completionReport.blockedDataRequests)
944 . filter (( request ) => request && request.status !== 'fulfilled' )
945 . map (( request ) => ({
946 requirementId: request.sourceEntityRef,
947 status: request.status,
948 description: request.whatUserCanDoNext || 'blocked source data' ,
949 }));
950 // A stopped run's report is not final: its interruption is the first blocker.
951 const interruption = completionReport && completionReport.interruption;
952 const fromInterruption = interruption ? [{
953 requirementId: 'import-interrupted' ,
954 status: 'interrupted' ,
955 description: `Requires Import Operator: the import stopped (${ interruption . kind }${ interruption . signal ? ` ${ interruption . signal }` : ''}${ interruption . code ? `, ${ interruption . code }` : ''}) at ${ interruption . at } and has not finished; resume it before any cutover step.` ,
956 }] : [];
957 return uniqueBy ([ ... fromInterruption, ... fromVerification, ... fromReport], ( row ) => row.requirementId);
958 }
959
960 function section ( lines , title , rows , renderRow , emptyMessage = '- None.' ) {
961 lines. push ( '' , `## ${ title }` , '' );
962 if ( ! rows. length ) {
963 lines. push (emptyMessage);
964 return ;
965 }
966 for ( const row of rows) lines. push ( renderRow (row));
967 }
968
969 function handoffCauseLine ( row ) {
970 // The whole point: an unverified cause renders as those two words and nothing
971 // more. No cause text, because a hypothesis printed next to "unverified" is read as a finding.
972 if (row.causeConfidence !== 'verified-source' && row.causeConfidence !== 'verified-target' ) {
973 return 'cause unverified' ;
974 }
975 return `${ row . cause } (${ row . causeConfidence }; established by ${ row . check . kind } on ${ row . check . target } -> ${ row . check . result })` ;
976 }
977
978 // The handoff never recounts and never re-derives the run-id binding itself -- it
979 // renders the already-bound block the completion report carries.
980 function coverageDisagreementLines ( coverageCheck ) {
981 const lines = coverageDisagreements (coverageCheck)
982 . map (( item ) => `${ item . key }.${ item . bucket } (held ${ item . held }, journal ${ item . journal })` );
983 for ( const key of (coverageCheck && coverageCheck.unmatched) || []) {
984 lines. push ( `${ key } (not found in the journal for this run)` );
985 }
986 for ( const type of (coverageCheck && coverageCheck.unmatchedJournalEntities) || []) {
987 lines. push ( `${ type } (journal rows with no summary key)` );
988 }
989 return lines;
990 }
991
992 function importOperatorPrompt ({ projectName , runId , coverageCheck }) {
993 const disagreeing = coverageDisagreementLines (coverageCheck);
994 const list = disagreeing. length ? disagreeing. join ( ', ' ) : 'none -- the check itself did not confirm a result' ;
995 return '> Requires Import Operator. In the original migration environment for '
996 + ` \` ${ projectName } \` , open Claude Code or Codex and run: "Investigate the live-import count `
997 + `cross-check for run \` ${ runId } \` . Read \` execution/live-import-summary.json \` `
998 + '`coverageCheck`, `execution/execution-log.md`, and the live attempt journal rows for that '
999 + `run. For each disagreeing counter ( \` ${ list } \` ), explain the cause and either fix the `
1000 + 'importer and rerun the affected entity so the new pass \' s completion report shows '
1001 + '`agrees`, or record `unresolved`/`losses` rows that account for the gap. Do not edit the '
1002 + 'held counters to match the journal."' ;
1003 }
1004
1005 // Renders only for a run that declared participation in this contract
1006 // (`coverageCheckExpected`, or an actually-supplied run-matched check) -- a run that never adopted
1007 // it is unaffected, exactly like its completion status and operator actions. A clean, run-matched agreement needs no operator attention here.
1008 function renderCoverageCheckSection ( lines , handoff ) {
1009 const coverageCheck = handoff.coverageCheck || null ;
1010 const participates = Boolean (handoff.coverageCheckExpected) || Boolean (coverageCheck && coverageCheck.supplied === true );
1011 const cleanAgreement = Boolean (coverageCheck && coverageCheck.supplied === true && coverageCheck.status === 'agrees' );
1012 if ( ! participates || cleanAgreement) {
1013 return ;
1014 }
1015 lines. push ( '' , '## Count cross-check' , '' );
1016 // A check is rendered as "supplied" only when `supplied === true`.
1017 // `completionReport.coverageCheck` can still carry another run's raw entities/unmatched/
1018 // unmatchedJournalEntities fields even when `supplied: false` (e.g. a stale/foreign run id
1019 // on disk, which is treated exactly like an omitted check) -- that data must never reach
1020 // the disagreement lines or the operator prompt below. An omitted check renders identically
1021 // here no matter *why* it was omitted (absent vs. a foreign run's block).
1022 const suppliedCheck = coverageCheck && coverageCheck.supplied === true ? coverageCheck : null ;
1023 const status = suppliedCheck ? suppliedCheck.status : 'no run-matched check' ;
1024 lines. push ( `- Status: ${ status }` );
1025 for ( const line of coverageDisagreementLines (suppliedCheck)) {
1026 lines. push ( `- ${ line }` );
1027 }
1028 lines. push (
1029 '' ,
1030 importOperatorPrompt ({ projectName: handoff.projectName, runId: handoff.runId || 'not recorded' , coverageCheck: suppliedCheck }),
1031 );
1032 }
1033
1034 // What the source held that never reached Wix, and what the Cutover Operator does
1035 // about it in Wix. Source-side recovery (routes, credentials, force-includes) is Import Operator
1036 // work before execution and is never asked of the Cutover Operator here. Rendered only from a
1037 // joined (schema v2) completion report; an older report has no such field and no section.
1038 function renderSourceCoverageSection ( lines , handoff ) {
1039 if ( ! Array. isArray (handoff.sourceCoverage)) return ;
1040 lines. push ( '' , '## Not migrated from the source' , '' );
1041 if (handoff.sourceCoverageRecorded === false ) {
1042 lines. push ( '- Source coverage was not recorded for this run, so capabilities the source could not expose are unknown. Treat the run as not verified for source coverage.' );
1043 return ;
1044 }
1045 if ( ! handoff.sourceCoverage. length ) {
1046 lines. push ( '- None.' );
1047 return ;
1048 }
1049 for ( const row of handoff.sourceCoverage) {
1050 const label = row.capability || row.sourceEntity;
1051 const handling = row.recommendedHandling ? ` Recommended handling: ${ row . recommendedHandling }` : '' ;
1052 lines. push ( `- ${ label } (${ row . reason }; ${ row . treatment }): ${ row . cutoverAction }${ handling }` );
1053 }
1054 }
1055
1056 // Section order is the contract, not a preference: urgent operator actions render ABOVE the
1057 // per-entity counts. Buried at position forty in a list of outcomes, a live money exposure the
1058 // merchant can only fix while their old platform is still the system of record is not a warning,
1059 // it is trivia.
1060 function renderPostExecutionHandoff ( handoff ) {
1061 const lines = [ '# Migration Handoff' , '' ];
1062 if (handoff.importInterrupted) {
1063 lines. push ( '> **Import interrupted — not final.** The import stopped before its end; the counts below are what was known at the stop. **Requires Import Operator:** resume the import before any cutover step.' , '' );
1064 }
1065
1066 section (lines, 'Urgent Operator Actions' , handoff.operatorActions, ( row ) =>
1067 `- [${ row . severity }] ${ row . code }: ${ row . action } (${ row . affectedCount === null ? 'affected count unknown' : `${ row . affectedCount } affected`}; act now because ${ row . deadlineReason })` ,
1068 handoff.discoveredSetActions?. length ? '' : '- None.' );
1069
1070 for ( const row of handoff.discoveredSetActions || []) {
1071 const owner = row.action?.owner === 'Import Operator' ? '**Requires Import Operator**' : row.action?.owner;
1072 lines. push ( `- ${ row . censusId }: ${ row . outcome }. ${ owner }: ${ row . action ?. action }. Affected count: ${ row . action ?. affectedCount ?? 'unknown'}. ${ row . action ?. consequence }. Timing: ${ row . action ?. timing }. Resolves when: ${ row . action ?. resolvedWhen }` );
1073 if (row.action?.importOperatorPrompt) lines. push ( '' , '**Copy prompt for Import Operator**' , '' , '```text' , row.action.importOperatorPrompt, '```' , '' );
1074 }
1075
1076 lines. push ( '' , '## Per-Entity Reconciliation' , '' , '| Entity | Extracted | In scope | Landed | Unreconciled |' , '| --- | --- | --- | --- | --- |' );
1077 if ( ! handoff.entityCompleteness. length ) {
1078 lines. push ( '| (none) | 0 | 0 | 0 | 0 |' );
1079 } else {
1080 for ( const row of handoff.entityCompleteness) {
1081 lines. push ( `| ${ row . entity }/${ row . subtype } | ${ row . extracted } | ${ row . inScope } | ${ row . imported + row . alreadyPresentByCrosswalk } | ${ row . expectedReconciled - row . reconciled } |` );
1082 }
1083 }
1084
1085 renderCoverageCheckSection (lines, handoff);
1086
1087 section (lines, 'Permanent Losses' , handoff.losses, ( row ) =>
1088 `- ${ row . entity }: ${ row . count } lost (${ row . reasonCode }); ${ row . whyNoRetry }; ${ handoffCauseLine ( row ) }` ,
1089 emptyPermanentLossesLine (handoff.exclusions));
1090 section (lines, 'Deliberate Exclusions' , handoff.exclusions, ( row ) =>
1091 `- ${ row . entity }: ${ row . count } excluded (${ row . reasonCode }); ${ row . rationale }; operator gate recorded: ${ row . operatorGateRecorded }` );
1092 // Name the records: an unresolved write is read back by its target id, never re-sent.
1093 const named = ( label , ids ) => (Array. isArray (ids) && ids. length ? `; ${ label }: ${ ids . slice ( 0 , 20 ). join ( ', ' ) }${ ids . length > 20 ? `, … (${ ids . length - 20 } more)` : ''}` : '' );
1094 section (lines, 'Unresolved And Orphaned' , handoff.unresolved, ( row ) =>
1095 `- ${ row . entity }: ${ row . count } ${ row . kind }${ named ( 'target ids' , row . targetIds ) }${ named ( 'source' , row . sourceIds ) }; ${ handoffCauseLine ( row ) }` );
1096 // Same split as the completion report: open = a count above zero and not an accepted outcome.
1097 const { SEO_ACCEPTED_OUTCOMES } = require ( './seo-outcome-registry.js' );
1098 const residualRows = (handoff.stageResiduals || []). filter (( row ) => row.count > 0 );
1099 const residualLine = ( row ) => `- ${ row . entity }/${ row . stage }: ${ row . count } ${ row . outcome }${ named ( 'source' , row . sourceKeys ) }` ;
1100 section (lines, 'Open Stage Residuals' , residualRows. filter (( row ) => ! SEO_ACCEPTED_OUTCOMES . includes (row.outcome)), residualLine);
1101 const acceptedRows = residualRows. filter (( row ) => SEO_ACCEPTED_OUTCOMES . includes (row.outcome));
1102 if (acceptedRows. length ) section (lines, 'Accepted Stage Outcomes' , acceptedRows, residualLine);
1103 section (lines, 'Fields Dropped For The Destination' , handoff.droppedFields, ( row ) =>
1104 `- ${ row . entity }.${ row . field }: ${ row . recordCount } records (${ row . reasonCode })` );
1105
1106 if (handoff.mappingSummary) {
1107 lines. push ( '' , '## Field mapping scope and gaps' , '' ,
1108 `- Mapping digest: ${ handoff . mappingSummary . mappingPlanDigest }` ,
1109 `- Deferred entities: ${ ( handoff . mappingSummary . executionScope ?. deferredEntities || []). map ( d => d . entityRef ). join ( ', ' ) || 'none'}` );
1110 if (handoff.mappingSummary.reviewPublication) lines. push ( '- Current-run Migration review publication is unavailable (no importer execution).' , `- ${ handoff . mappingSummary . reviewPublication . requiredInput }` );
1111 for ( const gap of handoff.mappingSummary.gaps) lines. push ( `- ${ gap . id || gap . code }: ${ gap . description }; preservation ${ gap . preservationStatus || 'unknown'}; affected count ${ gap . affectedScope ?. count ?? 'unknown'}` );
1112 }
1113 if (handoff.unexplainedShortfalls. length ) {
1114 lines. push ( '' , '## Unexplained Shortfalls' , '' );
1115 for ( const row of handoff.unexplainedShortfalls) lines. push ( `- ${ row . message }` );
1116 }
1117
1118 renderSourceCoverageSection (lines, handoff);
1119
1120 section (lines, 'Wix Bindings Installed' , handoff.landedBindings.wixApps, ( row ) =>
1121 `- ${ row . appName }${ row . appDefId && row . appDefId !== row . appName ? ` (${ row . appDefId })` : ''}` );
1122 section (lines, 'CMS Collections Created' , handoff.landedBindings.cmsCollections, ( row ) =>
1123 `- ${ row . collectionName }` );
1124 section (lines, 'Open Manual Blockers' , handoff.openBlockers, ( row ) =>
1125 `- ${ row . requirementId } [${ row . status }]: ${ row . description }` );
1126 section (lines, 'Imported Redirect Targets' , handoff.redirectTargetPlanning?.rules || [], ( row ) => {
1127 const safe = ( value ) => String (value || '' ). replace ( / [\r\n] / g , ' ' );
1128 return `- ${ safe ( row . sourceStableKey ) }: ${ safe ( row . source ) } → ${ safe ( row . target ) }; ${ safe ( row . disposition ) } (${ safe ( row . reason ) })` ;
1129 });
1130
1131 lines. push (
1132 '' ,
1133 '## Generation Provenance' ,
1134 '' ,
1135 `- Generated at: ${ handoff . generatedAt }` ,
1136 `- Run id: ${ handoff . runId || 'not recorded'}` ,
1137 `- Run's last write: ${ handoff . lastWriteAt }` ,
1138 `- Final outcome status: ${ handoff . status }` ,
1139 ... (handoff.backendStatus && handoff.status !== handoff.backendStatus ? [ `- Import status (completion report): ${ handoff . backendStatus }; open discovered-set rows: ${ handoff . discoveredSet ?. openCount }` ] : []),
1140 `- Rendered from: ${ HANDOFF_ARTIFACTS . completionReport }, ${ SETUP_VERIFICATION_FILE }` ,
1141 '' ,
1142 );
1143 return `${ lines . join ( ' \n ' ) } \n ` ;
1144 }
1145
1146 // Refuses rather than producing a clean document. A handoff with nothing behind it is exactly
1147 // the artifact this guard exists to stop, so the failure is a typed refusal the router can act on
1148 // -- never a document.
1149 async function generatePostExecutionHandoff ( projectDir , { runId = null , appendLog = true } = {}) {
1150 const completionReport = await readJsonIfExists (path. join (projectDir, HANDOFF_ARTIFACTS .completionReport));
1151 if ( ! completionReport) {
1152 return { ok: false , reason: 'missing_completion_report' , handoffPath: null , summaryPath: null , handoff: null };
1153 }
1154 if ( ! completionReport.lastWriteAt) {
1155 return { ok: false , reason: 'completion_report_missing_last_write' , handoffPath: null , summaryPath: null , handoff: null };
1156 }
1157 const { STATUS_FILE , discoveredSetStatusFreshness , deriveFinalStatus } = require ( './discovered-set-status.js' );
1158 const discoveredSetStatus = completionReport.managementImportMode === 'quick' ? null : await readJsonIfExists (path. join (projectDir, STATUS_FILE ));
1159 if (completionReport.managementImportMode !== 'quick' ) {
1160 if ( ! discoveredSetStatus) return { ok: false , reason: 'discovered_set_status_missing' };
1161 const freshness = await discoveredSetStatusFreshness (projectDir, discoveredSetStatus);
1162 if ( ! freshness.ok) return { ok: false , reason: 'discovered_set_status_stale' , staleInputs: freshness.staleInputs };
1163 }
1164 const setupVerification = await readJsonIfExists (path. join (projectDir, SETUP_VERIFICATION_FILE ));
1165 const redirectReport = await readJsonIfExists (path. join (projectDir, 'execution/url-rewrite-report.json' ));
1166 const redirectTargetPlanning = redirectReport || null ;
1167 const generatedAt = new Date (). toISOString ();
1168 // Compared against the report's own recorded last write, never a file mtime -- an mtime is
1169 // refreshed by a copy and proves nothing about when the last record landed.
1170 if (Date. parse (generatedAt) <= Date. parse (completionReport.lastWriteAt)) {
1171 return { ok: false , reason: 'handoff_predates_last_write' , handoffPath: null , summaryPath: null , handoff: null };
1172 }
1173
1174 const mappingPlan =await readJsonIfExists (path. join (projectDir, 'mapping/mapping-plan.json' ));
1175 const mappingGaps =await readJsonIfExists (path. join (projectDir, 'mapping/review/mapping-gaps.json' ));
1176 const mappingSummary = mappingPlan?.fieldContractVersion === 1 ? extractMappingSummary ( readEntityMappings (mappingPlan),mappingPlan,mappingGaps) : null ;
1177 const handoff = {
1178 schemaVersion: 1 ,
1179 generatedAt,
1180 ... (mappingSummary ? {mappingSummary} : {}),
1181 runId: completionReport.runId || runId || null ,
1182 lastWriteAt: completionReport.lastWriteAt,
1183 status: deriveFinalStatus (completionReport.status, discoveredSetStatus),
1184 backendStatus: completionReport.status,
1185 // Copied through: the report is `aborted` and is overwritten when the import resumes.
1186 ... (completionReport.interruption ? { importInterrupted: completionReport.interruption } : {}),
1187 discoveredSet: discoveredSetStatus ? { path: STATUS_FILE , sha256: hashArtifact (projectDir, STATUS_FILE ), openCount: discoveredSetStatus.openCount } : null ,
1188 discoveredSetActions: discoveredSetStatus ? discoveredSetStatus.rows. filter ( row => row.open && row.kind !== 'source-coverage' ). map (({ censusId , outcome , action }) => ({ censusId, outcome, action })) : [],
1189 // Rendered from the report's durable rows, never recounted.
1190 operatorActions: ensureArray (completionReport.operatorActions),
1191 pendingFields: ensureArray (completionReport.pendingFields),
1192 ... (completionReport.mappingRecovery ? {mappingRecovery: completionReport.mappingRecovery} : {}),
1193 ... (completionReport.linkAudit ? { linkAudit: completionReport.linkAudit } : {}),
1194 // The handoff's own record is not thinner than the report on the voucher urgency
1195 // signal. Scoped to these three fields and narrowed to the fixed voucher messages -- a
1196 // caller's free-text warning can carry record values, which are kept out of here.
1197 ... handoffVoucherSignal (completionReport),
1198 // Copied through verbatim -- the binding to `input.runId` already happened
1199 // on the completion report, and this never recounts or re-derives it.
1200 coverageCheck: completionReport.coverageCheck || null ,
1201 coverageCheckExpected: Boolean (completionReport.coverageCheckExpected),
1202 projectName: path. basename (projectDir),
1203 entityCompleteness: ensureArray (completionReport.entityCompleteness),
1204 losses: ensureArray (completionReport.losses),
1205 exclusions: ensureArray (completionReport.exclusions),
1206 unresolved: ensureArray (completionReport.unresolved),
1207 // Open stage residuals (SEO owner questions and verifications, category membership, payments)
1208 // are per-record work the Cutover owner must see; copied through, never recounted.
1209 stageResiduals: ensureArray (completionReport.stageResiduals),
1210 droppedFields: ensureArray (completionReport.droppedFields),
1211 unexplainedShortfalls: ensureArray (completionReport.unexplainedShortfalls),
1212 // Copied through from the joined report, never re-derived.
1213 ... (Array. isArray (completionReport.sourceCoverage)
1214 ? { sourceCoverage: completionReport.sourceCoverage, sourceCoverageRecorded: completionReport.sourceCoverageRecorded !== false }
1215 : {}),
1216 landedBindings: extractLandedBindings (setupVerification),
1217 openBlockers: extractOpenBlockers (setupVerification, completionReport),
1218 redirectTargetPlanning,
1219 sources: [ HANDOFF_ARTIFACTS .completionReport, SETUP_VERIFICATION_FILE ,
1220 ... (discoveredSetStatus ? [ STATUS_FILE ] : []),
1221 ... (redirectTargetPlanning ? [ 'execution/url-rewrite-report.json' ] : [])],
1222 };
1223
1224 const document = renderPostExecutionHandoff (handoff);
1225 const handoffPath = path. join (projectDir, POST_EXECUTION_HANDOFF_FILE );
1226 const jsonPath = path. join (projectDir, POST_EXECUTION_HANDOFF_JSON );
1227 await writeFileAtomic (jsonPath, `${ stableJson ( handoff ) } \n ` );
1228 await writeFileAtomic (handoffPath, document);
1229 if (appendLog) {
1230 await appendExecutionLog (projectDir, {
1231 runId: handoff.runId || runId || 'unknown-run' ,
1232 phase: 'completion' ,
1233 event: 'handoff_generated' ,
1234 detail: `post-execution handoff generated from the final outcome record (status ${ handoff . status })` ,
1235 artifacts: [ POST_EXECUTION_HANDOFF_FILE , POST_EXECUTION_HANDOFF_JSON ],
1236 });
1237 }
1238 return { ok: true , reason: null , handoffPath, jsonPath, handoff, document };
1239 }
1240
1241 async function loadPostExecutionHandoff ( projectDir ) {
1242 return readJsonIfExists (path. join (projectDir, POST_EXECUTION_HANDOFF_JSON ));
1243 }
1244
1245 module . exports = {
1246 extractMappingSummary,
1247 SCHEMA_VERSION,
1248 HANDOFF_ARTIFACTS,
1249 FRESHNESS_SCHEMA_VERSION,
1250 POST_EXECUTION_HANDOFF_FILE,
1251 POST_EXECUTION_HANDOFF_JSON,
1252 SETUP_VERIFICATION_FILE,
1253 extractLandedBindings,
1254 extractOpenBlockers,
1255 renderPostExecutionHandoff,
1256 generatePostExecutionHandoff,
1257 loadPostExecutionHandoff,
1258 // Exported so the entity-row contract is testable: which keys a mapping plan may carry its
1259 // per-entity rows under is the difference between a real handoff and a silently empty one.
1260 readEntityMappings,
1261 handoffFingerprint,
1262 createInputFreshness,
1263 compareInputFreshness,
1264 generateWebsiteHandoff,
1265 validateWebsiteHandoff,
1266 };