Setting the file. One moment.
Wp Plugin Detect · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page 39.41
PLUGIN READ PLANS
425
const consider
— line 425
This file
Number 39.37
Position 37 of 92
Type JavaScript
Size 86 KB
Lines 1,822 lib/ wp-plugin-detect.js
JavaScript · 1,822 lines · 86 KB
patternMatchesRoute
,
blockedEntriesOf
}
=
require
(
'./plugin-knowledge.js'
);
15 const { childRouteAdvertised } = require ( './wp-route-classifier.js' );
16 const {
17 canonicalTargetRefsByEntity ,
18 cmsFallbackProposalDigest ,
19 decisionKey : cmsFallbackDecisionKey ,
20 eligibilityByCapability ,
21 entityKeysForCapability ,
22 evaluateCmsFallbackDecision ,
23 evaluateMappingTargetOverride ,
24 mappingTargetOverrideProposal ,
25 nativeRouteNotices ,
26 unionTargetRefs ,
27 } = require ( '../../../lib/plugin-cms-fallback.js' );
28 const { capabilityPresence , externalDataTasks } = require ( './plugin-data-presence.js' );
29
30 const CONFIDENCE_ORDER = { high: 3 , medium: 2 , low: 1 };
31
32 // Signal strength, strongest first.
33 const SIGNAL_CONFIDENCE = {
34 'wp.v2.plugins' : 'high' ,
35 route: 'high' ,
36 'rest-base' : 'medium' ,
37 'record-property' : 'medium' ,
38 namespace: 'low' ,
39 'asset-path' : 'low' ,
40 };
41
42 // The five statuses of the plugin-classification scheme (+ manual-mapping). Every installed plugin lands
43 // in exactly one, every status is a sentence a merchant can act on, and there is no second
44 // internal vocabulary.
45 //
46 // migration-planned - "this comes across", via `api` (native Wix entity) or via `cms`
47 // (kept as data with its original IDs). `confidence` states whether a
48 // human authored the mapping (confirmed) or we derived it (proposed).
49 // manual-mapping - "Wix can do this — here are the exact steps you take yourself".
50 // A complete, decided mapping with no write for our code to make: no
51 // `preferredWrite` call exists, only a `manualSteps` runbook (row.
52 // manualSteps). Unlike `pending`, nothing here is undecided — it needs
53 // no human sign-off gate, only the merchant's own follow-through.
54 // no-need-to-migrate - "nothing to move": Wix already does it, it was never data, or it is
55 // a setting you reconfigure once in Wix. Two sources: the slug list
56 // (`basis: list`, unprofiled plugins) and the human-signed
57 // per-capability register (`basis: decision`, profiled plugins).
58 // pending - "we do not know how to migrate this yet". OUR open item, decided at
59 // the mapping review; never a statement about the source or about Wix.
60 // requires-development - "Wix has no surface for this; build it first". Reachable ONLY from
61 // the human-signed register — automation may never conclude
62 // impossibility. Two agent-made impossibility claims (gift cards,
63 // events) were already wrong; both had a create API.
64 //
65 // Blocked — recoverable is deliberately NOT here: a failed or unavailable read attaches to the
66 // row as `blocked[]` and never becomes a mapping decision (see CHANNEL_BLOCKERS).
67 const COVERAGE_STATUSES = new Set ([
68 'migration-planned' ,
69 'manual-mapping' ,
70 'no-need-to-migrate' ,
71 'pending' ,
72 'requires-development' ,
73 ]);
74
75 // A channel that cannot be read today attaches a blocker to the row: fix and re-run. This is
76 // what keeps a db-only gift-card balance "Migration planned + blocked", not "pending" —
77 // unreadable today, recoverable by an export, and never reported as a limitation of Wix.
78 const CHANNEL_BLOCKERS = {
79 'needs-export-file' : {
80 kind: 'user-file' ,
81 resolution: 'Provide the plugin export file produced on the source site; only you can produce it (Application Passwords never reach wp-admin).' ,
82 },
83 unavailable: {
84 kind: 'user-file' ,
85 resolution: 'The plugin keeps this data in its own database tables; provide a database export or a source-side bridge.' ,
86 },
87 'needs-user-transcription' : {
88 kind: 'user-file' ,
89 resolution: 'The plugin exposes this configuration only in wp-admin; transcribe it or provide screenshots from an authenticated browser session.' ,
90 },
91 'route-absent' : {
92 kind: 'surface-changed' ,
93 resolution: 'The profile declares a route this site does not expose; the profile may be stale or the plugin version differs.' ,
94 },
95 'property-absent' : {
96 kind: 'surface-changed' ,
97 resolution: 'The profile declares an embedded property the sampled records do not carry; the profile may be stale or the plugin version differs.' ,
98 },
99 'api-below-min-version' : {
100 kind: 'surface-changed' ,
101 resolution: 'The installed plugin version predates the REST surface this profile reads; upgrade the plugin or supply an export file.' ,
102 },
103 };
104
105 function blockersForEntities ( entities ) {
106 const blocked = [];
107 for ( const entity of entities) {
108 // A profile may declare `blocked[]` at entity level (the current convention -- see
109 // woo-discount-rules.json, pw-woocommerce-gift-cards.json) or nested inside a pitfall
110 // (the older convention, still used by a few profiles). Reading only one form silently
111 // drops the other profile's real fulfillment metadata and falls through to a generic,
112 // possibly stale, per-channel template message instead. Call the shared helper (a
113 // hand-duplicated copy of blockedEntriesOf() drifts out of sync with it, and entity-level
114 // blocked[] then gets silently ignored here) so this can never drift from plugin-knowledge.js's own definition again.
115 const declared = blockedEntriesOf (entity);
116 if (declared. length > 0 ) {
117 for ( const blocker of declared) {
118 blocked. push ({
119 ... blocker,
120 entity: entity.entity,
121 channel: entity.channel,
122 declined: blocker.declined === true ,
123 });
124 }
125 continue ;
126 }
127 const spec = CHANNEL_BLOCKERS [entity.channelStatus];
128 if ( ! spec) continue ;
129 blocked. push ({
130 kind: spec.kind,
131 entity: entity.entity,
132 channel: entity.channel,
133 resolution: spec.resolution,
134 declined: false ,
135 });
136 }
137 return blocked;
138 }
139
140 const CHANNEL_AVAILABILITY = {
141 'plugin-rest' : 'available' ,
142 'core-cpt' : 'available' ,
143 'core-embedded' : 'available' ,
144 'core-meta' : 'available' ,
145 'plugin-rest-child' : 'available' ,
146 'export-file' : 'needs-export-file' ,
147 'db-only' : 'unavailable' ,
148 'admin-page-only' : 'needs-user-transcription' ,
149 };
150
151 function bestConfidence ( signals ) {
152 let best = null ;
153 for ( const signal of signals) {
154 const kind = String (signal). split ( ':' )[ 0 ];
155 const confidence = SIGNAL_CONFIDENCE [kind] || 'low' ;
156 if ( ! best || CONFIDENCE_ORDER [confidence] > CONFIDENCE_ORDER [best]) best = confidence;
157 }
158 return best || 'low' ;
159 }
160
161 function compareVersions ( a , b ) {
162 const pa = String (a). split ( '.' ). map (( part ) => Number. parseInt (part, 10 ) || 0 );
163 const pb = String (b). split ( '.' ). map (( part ) => Number. parseInt (part, 10 ) || 0 );
164 for ( let i = 0 ; i < Math. max (pa. length , pb. length ); i += 1 ) {
165 const diff = (pa[i] || 0 ) - (pb[i] || 0 );
166 if (diff !== 0 ) return diff < 0 ? - 1 : 1 ;
167 }
168 return 0 ;
169 }
170
171 function routeSet ( restIndex ) {
172 return new Set (Object. keys (restIndex?.routes || {}));
173 }
174
175 function namespaceSet ( restIndex ) {
176 return new Set (Array. isArray (restIndex?.namespaces) ? restIndex.namespaces : []);
177 }
178
179 function restBaseSet ( types , taxonomies ) {
180 const bases = new Set ();
181 for ( const collection of [types, taxonomies]) {
182 for ( const entry of Object. values (collection || {})) {
183 if (entry && entry.rest_base) bases. add (entry.rest_base);
184 if (entry && entry.slug) bases. add (entry.slug);
185 }
186 }
187 return bases;
188 }
189
190 // Plugin file ids look like "the-events-calendar/the-events-calendar.php". The list endpoint
191 // returns them without the ".php" on some versions, so compare on the directory segment too.
192 function pluginFileVariants ( pluginFileId ) {
193 const variants = new Set ([pluginFileId]);
194 variants. add (pluginFileId. replace ( / \. php $ / , '' ));
195 const dir = pluginFileId. split ( '/' )[ 0 ];
196 if (dir) variants. add (dir);
197 return variants;
198 }
199
200 // VERIFIED LIVE 2026-07-30: a plugin's main file basename frequently differs from its
201 // directory — real ids include `wordpress-seo/wp-seo`, `woo-custom-product-addons/start`,
202 // `chaty/cht-icons`, `print-google-cloud-print-gcp-woocommerce/index`. A profile author
203 // writing the conventional `dir/dir.php` guess must still match, so compare the two id
204 // variant SETS (which both include the bare directory) rather than comparing each declared
205 // string against the other's set — the latter never intersects on the directory token.
206 // Plugin directories are unique per install, so directory-level matching cannot collide.
207 function matchInstalled ( profile , installed ) {
208 const declared = profile.detect?.pluginFileIds || [];
209 if (declared. length === 0 ) return null ;
210 const declaredVariants = declared. map (( declaredId ) => pluginFileVariants (declaredId));
211 for ( const entry of installed) {
212 const candidates = pluginFileVariants ( String (entry.plugin || '' ));
213 for ( const variants of declaredVariants) {
214 for ( const variant of variants) {
215 if (candidates. has (variant)) return entry;
216 }
217 }
218 }
219 return null ;
220 }
221
222 function detectPlugins ({
223 profiles = [],
224 restIndex = null ,
225 pluginList = null ,
226 types = null ,
227 taxonomies = null ,
228 htmlSources = [],
229 sampledRecordProperties = [],
230 // The fingerprint alias map ({ aliases: { token: { displayName, slug } } }).
231 fingerprintAliases = null ,
232 } = {}) {
233 const routes = routeSet (restIndex);
234 const rawRoutes = restIndex?.routes || {};
235 const namespaces = namespaceSet (restIndex);
236 const bases = restBaseSet (types, taxonomies);
237 const properties = new Set (sampledRecordProperties);
238 const html = htmlSources. join ( ' \n ' );
239 const installed = Array. isArray (pluginList) ? pluginList : [];
240 const pluginListAvailable = Array. isArray (pluginList);
241
242 const detected = [];
243 const claimedInstalled = new Set ();
244
245 for ( const profile of profiles) {
246 const signals = [];
247 const detect = profile.detect || {};
248
249 const installedEntry = matchInstalled (profile, installed);
250 if (installedEntry) {
251 signals. push ( 'wp.v2.plugins' );
252 claimedInstalled. add (installedEntry.plugin);
253 }
254 for ( const route of detect.routes || []) {
255 if (routes. has (route)) signals. push ( `route:${ route }` );
256 }
257 for ( const entity of profile.entities || []) {
258 if (entity.route && routes. has (entity.route)) signals. push ( `route:${ entity . route }` );
259 }
260 for ( const base of detect.restBases || []) {
261 if (bases. has (base)) signals. push ( `rest-base:${ base }` );
262 }
263 for ( const property of detect.recordProperties || []) {
264 if (properties. has (property)) signals. push ( `record-property:${ property }` );
265 }
266 for ( const entity of profile.entities || []) {
267 if ((entity.channel === 'core-embedded' || entity.channel === 'core-meta' ) && entity.propertyPath && properties. has (entity.propertyPath)) {
268 signals. push ( `record-property:${ entity . propertyPath }` );
269 }
270 }
271 for ( const namespace of detect.restNamespaces || []) {
272 if (namespaces. has (namespace)) signals. push ( `namespace:${ namespace }` );
273 }
274 for ( const slug of detect.assetPathSlugs || []) {
275 if (html. includes ( `/wp-content/plugins/${ slug }/` )) signals. push ( `asset-path:${ slug }` );
276 }
277
278 if (signals. length === 0 ) continue ;
279
280 // Add-ons (e.g. a PRO companion that stores everything in the parent's own tables) are
281 // claimed by the parent profile once the parent is detected. An add-on is never a signal on
282 // its own: it cannot make the parent match.
283 const addOns = [];
284 for ( const addOnId of detect.addOnPluginFileIds || []) {
285 const addOnEntry = matchInstalled ({ detect: { pluginFileIds: [addOnId] } }, installed);
286 if ( ! addOnEntry || claimedInstalled. has (addOnEntry.plugin)) continue ;
287 claimedInstalled. add (addOnEntry.plugin);
288 addOns. push ({
289 plugin: addOnEntry.plugin,
290 name: addOnEntry.name || null ,
291 version: addOnEntry.version || null ,
292 active: addOnEntry.status === 'active' ,
293 });
294 }
295
296 const uniqueSignals = Array. from ( new Set (signals)). sort ();
297 const version = installedEntry?.version || null ;
298 // A version below the profile's documented REST threshold means the capability is
299 // present but this profile's read contract does not apply. Saying so is more useful
300 // than either claiming a readable entity or dropping the plugin entirely.
301 const apiBelowMinVersion = Boolean (
302 detect.minVersion && version && compareVersions (version, detect.minVersion) < 0 ,
303 );
304
305 detected. push ({
306 plugin: profile.plugin,
307 displayName: profile.displayName,
308 version,
309 active: installedEntry ? installedEntry.status === 'active' : null ,
310 distribution: profile.distribution || 'unknown' ,
311 confidence: bestConfidence (uniqueSignals),
312 signals: uniqueSignals,
313 capabilities: [ ... (profile.capabilities || [])],
314 profileVersion: profile.profileVersion,
315 // Where the plugin keeps its data. Only `wordpress` lets an empty presence check mean
316 // "no data"; missing (an older stored detection) is treated as unknown.
317 dataLocation: profile.dataLocation || null ,
318 apiBelowMinVersion,
319 minVersion: detect.minVersion || null ,
320 addOns,
321 entities: (profile.entities || []). map (( entity ) => describeProfiledEntity (entity, {
322 routes,
323 rawRoutes,
324 properties,
325 apiBelowMinVersion,
326 })),
327 });
328 }
329
330 const profiledNamespaces = new Set ();
331 const profiledRoutePatterns = [];
332 for ( const profile of profiles) {
333 for ( const namespace of profile.detect?.restNamespaces || []) profiledNamespaces. add (namespace);
334 for ( const entity of profile.entities || []) {
335 if (entity.route) profiledRoutePatterns. push (entity.route);
336 }
337 for ( const rule of profile.dataRoutePatterns || []) profiledRoutePatterns. push (rule.pattern);
338 }
339
340 const installedButUnprofiled = installed
341 . filter (( entry ) => ! claimedInstalled. has (entry.plugin))
342 . map (( entry ) => ({
343 plugin: entry.plugin,
344 name: entry.name || null ,
345 version: entry.version || null ,
346 active: entry.status === 'active' ,
347 textdomain: entry.textdomain || null ,
348 }))
349 . sort (( a , b ) => String (a.plugin). localeCompare ( String (b.plugin)));
350
351 const fingerprinted = collectFingerprints ({
352 namespaces,
353 html,
354 profiles,
355 installed,
356 aliases: fingerprintAliases?.aliases || {},
357 });
358
359 return {
360 pluginListAvailable,
361 detected: detected. sort (( a , b ) => a.plugin. localeCompare (b.plugin)),
362 installedButUnprofiled,
363 fingerprinted,
364 profiledNamespaces: Array. from (profiledNamespaces). sort (),
365 profiledRoutePatterns: profiledRoutePatterns. sort (),
366 };
367 }
368
369 // The fingerprinted tier: public evidence of plugins nothing else claimed.
370 // wp-content/plugins/<slug> asset paths and unrecognized REST namespaces are read and named
371 // through the alias map instead of thrown away. Names only: the tier makes no completeness
372 // claim (the incomplete-list warning stands unchanged) and never feeds route selection — it
373 // is inventory, not scope. Verified against a live WordPress installation: unauthenticated,
374 // dozens of plugins publicly self-announced while the function reported none of them.
375 // Platform namespaces that are the host or the commerce platform, not a plugin: WordPress
376 // core, the WordPress.com hosting layer, and WooCommerce's own admin/telemetry surfaces
377 // (VERIFIED LIVE 2026-08-10: a stock WooCommerce store advertises eight wc-* namespaces
378 // that would otherwise read as eight unknown plugins). A row for one of these would tell
379 // the user to worry about their own platform.
380 const CORE_FINGERPRINT_TOKENS = new Set ([
381 'wp' , 'wc' , 'oembed' , 'wp-block-editor' , 'wp-site-health' , 'wp-abilities' , 'mcp' ,
382 'wpcom' , 'wpcomsh' , 'help-center' ,
383 'woocommerce' , 'wc-admin' , 'wc-admin-email' , 'wc-analytics' , 'wc-push-notifications' ,
384 'wc-telemetry' , 'wccom-site' , 'woocommerce-email-editor' ,
385 ]);
386
387 function collectFingerprints ({ namespaces , html , profiles , installed , aliases }) {
388 const claimed = new Set ( CORE_FINGERPRINT_TOKENS );
389 for ( const profile of profiles) {
390 for ( const slug of profile.detect?.assetPathSlugs || []) claimed. add (slug);
391 for ( const namespace of profile.detect?.restNamespaces || []) claimed. add (namespace. split ( '/' )[ 0 ]);
392 claimed. add (profile.plugin);
393 }
394 // A plugin already on the installed list is inventory we hold with certainty; its public
395 // fingerprint adds nothing and must not produce a second row. Match fuzzily, the same way
396 // namespace attribution does — a namespace token is often a shortening of the directory
397 // (acme-shop vs acme-shop-vouchers) — because the tier's claim is precision, and a
398 // duplicate row costs more than a missed name.
399 const installedTokens = new Set ();
400 for ( const entry of installed) {
401 const dir = String (entry.plugin || '' ). split ( '/' )[ 0 ];
402 if (dir) installedTokens. add ( normalizeToken (dir));
403 if (entry.textdomain) installedTokens. add ( normalizeToken (entry.textdomain));
404 }
405 // VERIFIED against live WordPress installations: a fingerprint token is frequently a cryptic internal
406 // namespace (wpjm-internal, fb_api, wc-facebook, mc4wp, zprint, yoast) that fails the fuzzy
407 // token match above even though the alias map already resolves it to an exact installed
408 // slug (wp-job-manager, facebook-for-woocommerce, mailchimp-for-wp,
409 // print-google-cloud-print-gcp-woocommerce, wordpress-seo) — that is the whole reason an
410 // alias exists. Skipping this check produced a second, confusing per-plugin row for several
411 // different already-installed plugins, each showing both "fingerprinted"
412 // and "installed but unprofiled" text glued together.
413 const matchesInstalled = ( token ) => {
414 const normalized = normalizeToken (token);
415 if ( ! normalized) return false ;
416 const aliasSlug = aliases[token]?.slug;
417 if (aliasSlug && installedTokens. has ( normalizeToken (aliasSlug))) return true ;
418 for ( const installedToken of installedTokens) {
419 if (installedToken. includes (normalized) || normalized. includes (installedToken)) return true ;
420 }
421 return false ;
422 };
423
424 const byToken = new Map ();
425 const consider = ( token , evidence ) => {
426 if ( ! token || claimed. has (token) || matchesInstalled (token)) return ;
427 if ( ! byToken. has (token)) byToken. set (token, { token, evidence: new Set () });
428 byToken. get (token).evidence. add (evidence);
429 };
430
431 for ( const namespace of namespaces) consider (namespace. split ( '/' )[ 0 ], `namespace:${ namespace }` );
432 for ( const match of html. matchAll ( / \/ wp-content \/ plugins \/ ( [a-z0-9_-] + ) \/ / g )) {
433 consider (match[ 1 ], `asset-path:${ match [ 1 ] }` );
434 }
435
436 return Array. from (byToken. values ())
437 . map (( entry ) => {
438 const alias = aliases[entry.token] || null ;
439 return {
440 token: entry.token,
441 evidence: Array. from (entry.evidence). sort (),
442 displayName: alias?.displayName || null ,
443 slug: alias?.slug || entry.token,
444 aliasMatched: Boolean (alias),
445 };
446 })
447 . sort (( a , b ) => a.token. localeCompare (b.token));
448 }
449
450 // Channel status describes only whether the SOURCE exposes this entity. Credential state is
451 // deliberately not encoded here: it is resolved in classifyCoverage against the credentials
452 // actually granted for the run, so granting one later cannot leave a stale entity status.
453 function describeProfiledEntity ( entity , { routes , rawRoutes , properties , apiBelowMinVersion }) {
454 let channelStatus = CHANNEL_AVAILABILITY [entity.channel] || 'unavailable' ;
455
456 if (channelStatus === 'available' ) {
457 // plugin-rest-child's route is a {parentId} template, never a literal index route — it is
458 // checked below, against both the parent collection and the templated child route.
459 if (entity.channel !== 'plugin-rest-child' && entity.route && ! routes. has (entity.route) && ! entity.route. includes ( '*' )) {
460 channelStatus = 'route-absent' ;
461 }
462 if ((entity.channel === 'core-embedded' || entity.channel === 'core-meta' ) && entity.propertyPath && ! properties. has (entity.propertyPath)) {
463 channelStatus = 'property-absent' ;
464 }
465 if (entity.channel === 'plugin-rest-child' ) {
466 // Presence only: the parent collection is in scope AND the site's REST index
467 // advertises a sub-resource shaped like the entity's route template. Whether any real
468 // parent record actually has data there is a live, per-parent question answered later
469 // by wp-discovery.js's representative sample — never assumed here.
470 const parentAvailable = Boolean (entity.parentRoute) && routes. has (entity.parentRoute);
471 const childAdvertised = Boolean (entity.route) && childRouteAdvertised (rawRoutes, entity.route);
472 if ( ! parentAvailable || ! childAdvertised) {
473 channelStatus = 'route-absent' ;
474 }
475 }
476 }
477 if (apiBelowMinVersion && channelStatus === 'available' ) channelStatus = 'api-below-min-version' ;
478
479 return {
480 entity: entity.entity,
481 capability: entity.capability || null ,
482 channel: entity.channel,
483 channelStatus,
484 route: entity.route || null ,
485 parentRoute: entity.parentRoute || null ,
486 embeddedIn: entity.embeddedIn || null ,
487 propertyPath: entity.propertyPath || null ,
488 context: entity.context || null ,
489 requiresParent: entity.requiresParent || null ,
490 hierarchical: entity.hierarchical === true ,
491 // Read-mechanics fields that rp-import-codegen's sourceMeta contract
492 // depends on — dropping them here would leave the documented contract with nothing to
493 // copy from, since this is the structured (non-prose) per-entity description that
494 // detection writes to disk.
495 requestMethod: entity.requestMethod || null ,
496 requestBody: entity.requestBody || null ,
497 responseFragmentGroupSize: entity.responseFragmentGroupSize || null ,
498 // Also the $SAMPLED_IDS batching/dedup mechanism (documented alongside the
499 // fields above in rp-import-codegen/SKILL.md) — omitting it here left this function
500 // (documented as the authoritative structured source for this field family) silently
501 // out of sync with the one profile that declares a non-default value
502 // (subscriptions-for-woocommerce.json's `subscription_id`).
503 recordKeyField: entity.recordKeyField || null ,
504 candidateTargetRefs: [ ... (entity.candidateTargetRefs || [])],
505 pitfalls: entity.pitfalls || [],
506 // Entity-level `blocked[]` (the current profile convention -- see
507 // woo-discount-rules.json, pw-woocommerce-gift-cards.json) as well as `pitfalls`: dropping
508 // this here means blockersForEntities() never sees it and falls back to a generic,
509 // possibly stale, per-channel template message instead of the profile's real fulfillment.
510 blocked: entity.blocked || [],
511 };
512 }
513
514 // Unprofiled REST namespaces, so heuristic acceptance is visible rather than
515 // indistinguishable from known-good coverage.
516 function collectUnprofiledRoutes ({ classifications = [], detection }) {
517 const profiledPatterns = detection?.profiledRoutePatterns || [];
518 const byNamespace = new Map ();
519
520 for ( const classification of classifications) {
521 if (classification.effectiveAction !== 'sample' ) continue ;
522 // Core WordPress and WooCommerce families are covered by the classifier's own
523 // allowlist; they are not "unprofiled plugins". Profile-matched routes are filtered
524 // below by pattern, not by rule id — the generic fallback rule id also begins with
525 // "plugin.", so a prefix test would wrongly hide heuristic acceptances.
526 if ( / ^ (wp | wc) \. / . test (classification.ruleId || '' )) continue ;
527 if (profiledPatterns. some (( pattern ) => patternMatchesRoute (pattern, classification.routePath))) continue ;
528
529 const namespace = classification.namespace || classification.routePath. split ( '/' ). slice ( 1 , 3 ). join ( '/' );
530 if ( ! byNamespace. has (namespace)) {
531 byNamespace. set (namespace, { namespace, routes: [], ruleIds: new Set () });
532 }
533 const entry = byNamespace. get (namespace);
534 entry.routes. push (classification.routePath);
535 entry.ruleIds. add (classification.ruleId);
536 }
537
538 return Array. from (byNamespace. values ())
539 . map (( entry ) => ({
540 namespace: entry.namespace,
541 routes: entry.routes. sort (),
542 ruleIds: Array. from (entry.ruleIds). sort (),
543 read: true ,
544 reason: 'no plugin profile matched; accepted by generic collection-shape classification' ,
545 }))
546 . sort (( a , b ) => a.namespace. localeCompare (b.namespace));
547 }
548
549 // Collection-shaped routes in namespaces we do not recognize that were NOT read. Verified
550 // live: the REST index advertises no per-route schema, so these never reach `sample`. They
551 // are reported rather than sampled so the coverage report can say "this plugin looks like it
552 // holds records we did not read" instead of omitting it.
553 const CORE_NAMESPACE_PREFIXES = [ 'wp/v2' , 'wc/v1' , 'wc/v2' , 'wc/v3' , 'wc/store' , 'oembed/1.0' ];
554
555 function collectCandidateNamespaces ({ classifications = [], candidates = [], detection = null }) {
556 const profiledPatterns = detection?.profiledRoutePatterns || [];
557 const shaped = new Map (candidates. map (( candidate ) => [candidate.routePath, candidate]));
558 const byNamespace = new Map ();
559
560 for ( const classification of classifications) {
561 if (classification.effectiveAction !== 'skip' ) continue ;
562 // Only the "we had no rule for it" bucket. Anything a deliberate exclusion family
563 // caught (admin, runtime, diagnostics, integration…) stays out of scope by design.
564 if (classification.ruleId !== 'unsupported.default' ) continue ;
565 const candidate = shaped. get (classification.routePath);
566 const hasShape = Boolean (candidate?.getEndpoint?.args?.page || candidate?.getEndpoint?.args?.per_page);
567 if ( ! hasShape) continue ;
568 if ( CORE_NAMESPACE_PREFIXES . some (( prefix ) => classification.routePath. startsWith ( `/${ prefix }/` ))) continue ;
569 if (profiledPatterns. some (( pattern ) => patternMatchesRoute (pattern, classification.routePath))) continue ;
570
571 const namespace = classification.namespace || classification.routePath. split ( '/' ). slice ( 1 , 3 ). join ( '/' );
572 if ( ! byNamespace. has (namespace)) byNamespace. set (namespace, { namespace, routes: [] });
573 byNamespace. get (namespace).routes. push (classification.routePath);
574 }
575
576 return Array. from (byNamespace. values ())
577 . map (( entry ) => ({
578 namespace: entry.namespace,
579 routes: entry.routes. sort (),
580 read: false ,
581 reason: 'collection-shaped routes in an unrecognized namespace; not read by default' ,
582 }))
583 . sort (( a , b ) => a.namespace. localeCompare (b.namespace));
584 }
585
586 // Tier B: derive entities for unprofiled, REST-visible plugin post types and taxonomies.
587 // Built from /wp/v2/types and /wp/v2/taxonomies, which is what makes broad coverage the
588 // default rather than a per-plugin achievement.
589 const CORE_POST_TYPES = new Set ([ 'post' , 'page' , 'attachment' , 'nav_menu_item' , 'wp_block' , 'wp_template' , 'wp_template_part' , 'wp_navigation' , 'wp_font_family' , 'wp_font_face' , 'wp_global_styles' , 'product' , 'product_variation' , 'shop_order' , 'shop_coupon' ]);
590 const CORE_TAXONOMIES = new Set ([ 'category' , 'post_tag' , 'nav_menu' , 'link_category' , 'post_format' , 'wp_pattern_category' , 'product_cat' , 'product_tag' , 'product_brand' ]);
591
592 const GENERIC_TARGET_REFS = [ 'cms/collection' , 'cms/data-item' ];
593
594 function deriveGenericEntities ({
595 types = null ,
596 taxonomies = null ,
597 classifications = [],
598 detection = null ,
599 sampledByRoute = new Map (),
600 } = {}) {
601 const profiledPatterns = detection?.profiledRoutePatterns || [];
602 const sampledRoutes = new Set (
603 classifications
604 . filter (( classification ) => [ 'sample' , 'metadata' ]. includes (classification.effectiveAction))
605 . map (( classification ) => classification.routePath),
606 );
607 const entities = [];
608
609 const consider = ( kind , key , entry ) => {
610 const restBase = entry?.rest_base || entry?.slug || key;
611 if ( ! restBase) return ;
612 if (entry?.show_in_rest === false ) return ;
613 if (kind === 'post-type' && CORE_POST_TYPES . has (key)) return ;
614 if (kind === 'taxonomy' && CORE_TAXONOMIES . has (key)) return ;
615
616 const namespace = entry?.rest_namespace || 'wp/v2' ;
617 const route = `/${ namespace }/${ restBase }` ;
618 if (profiledPatterns. some (( pattern ) => patternMatchesRoute (pattern, route))) return ;
619 // Scope containment: only routes the 0005 classifier already accepted may be derived.
620 // The generic path must never resurrect an excluded route family.
621 if ( ! sampledRoutes. has (route)) return ;
622
623 const sampled = sampledByRoute. get (route) || null ;
624 entities. push ({
625 entity: restBase,
626 origin: kind === 'taxonomy' ? 'generic-taxonomy' : 'generic-post-type' ,
627 recognized: false ,
628 channel: 'core-cpt' ,
629 route,
630 restBase,
631 sourcePostType: kind === 'post-type' ? key : null ,
632 sourceTaxonomy: kind === 'taxonomy' ? key : null ,
633 displayName: entry?.name || restBase,
634 hierarchical: entry?.hierarchical === true ,
635 attachedToTypes: Array. isArray (entry?.types) ? [ ... entry.types] : [],
636 recordCount: sampled ? sampled.recordCount : null ,
637 inUse: sampled ? sampled.inUse : null ,
638 candidateTargetRefs: [ ... GENERIC_TARGET_REFS ],
639 proposedCapability: proposeCapability (kind, key, restBase, entry),
640 capabilityConfidence: 'proposed' ,
641 notes: buildGenericNotes (kind, entry),
642 });
643 };
644
645 for ( const [ key , entry ] of Object. entries (types || {})) consider ( 'post-type' , key, entry);
646 for ( const [ key , entry ] of Object. entries (taxonomies || {})) consider ( 'taxonomy' , key, entry);
647
648 return entities. sort (( a , b ) => a.route. localeCompare (b.route));
649 }
650
651 // A proposal, never a decision: it is recorded with unverified confidence and reviewed by
652 // the user at the existing mapping-review checkpoint. Native selection still requires a
653 // target-KB match (see classifyCoverage), so a wrong guess here cannot cause a bad write.
654 function proposeCapability ( kind , key , restBase , entry ) {
655 const haystack = [key, restBase, entry?.name, ... (entry?.tags || [])]
656 . filter (Boolean)
657 . join ( ' ' )
658 . toLowerCase ();
659 const rules = [
660 [ /(event | calendar | webinar)/ , 'content.events' ],
661 [ /(course | lesson | lms | quiz)/ , 'content.courses' ],
662 [ /(booking | appointment | reservation)/ , 'commerce.bookings' ],
663 [ /(testimonial | review | rating)/ , 'content.reviews' ],
664 [ /(portfolio | project | gallery | album)/ , 'content.portfolio' ],
665 // Jobs before listings: "jobs_listing" contains both, and the job reading is the
666 // specific one. Rule order is the tie-breaker, so keep specific before generic.
667 [ /(job | vacancy | career | resume)/ , 'content.jobs' ],
668 [ /(listing | directory | property | realestate)/ , 'content.listings' ],
669 [ /(recipe | menu | dish)/ , 'content.recipes' ],
670 [ /(podcast | episode)/ , 'content.podcast' ],
671 [ /(faq | question)/ , 'content.faq' ],
672 [ /(team | staff | person | member-profile)/ , 'content.people' ],
673 [ /(form | entry | submission)/ , 'content.form-submissions' ],
674 [ /(donation | campaign | fundrais)/ , 'commerce.donations' ],
675 [ /(membership | subscriber | plan)/ , 'commerce.memberships' ],
676 [ /(location | store-locator | branch)/ , 'business.locations' ],
677 ];
678 for ( const [ pattern , capability ] of rules) {
679 if (pattern. test (haystack)) return capability;
680 }
681 return kind === 'taxonomy' ? 'content.custom-taxonomy' : 'content.custom-records' ;
682 }
683
684 // Tier C: a detected plugin's OWN tables, read through wix-wp-plugin-v2 under a project-local
685 // read plan (PLUGIN-READ-PLANS.md), when no profile covers the plugin. The canonical
686 // source-schema.json entity (`plugin.<slug>.<entity>`) is the evidence; nothing is read here.
687 // Proposed, never confirmed: native refs come from the same capability guess as a derived post
688 // type, and `cms/data-item` is always offered beside them so the operator can archive instead.
689 const BRIDGE_ENTITY_REF = / ^ plugin \. ( [a-z0-9_-] + ) \. ( [a-z0-9_-] + ) $ / ;
690
691 function proposeBridgeEntity ({ sourceEntityRef , table = null , displayName = null }, capabilityRefs = new Map ()) {
692 const match = BRIDGE_ENTITY_REF . exec ( String (sourceEntityRef || '' ));
693 if ( ! match) return null ;
694 const [, slug , entity ] = match;
695 const proposedCapability = proposeCapability ( 'plugin-table' , entity, table || entity, { name: [slug, displayName]. filter (Boolean). join ( ' ' ) });
696 const nativeRefs = (capabilityRefs. get (proposedCapability) || []). filter (( ref ) => ! ref. startsWith ( 'cms/' ));
697 return {
698 slug,
699 entity,
700 proposedCapability,
701 candidateTargetRefs: Array. from ( new Set ([ ... nativeRefs, 'cms/data-item' ])),
702 };
703 }
704
705 function deriveBridgePlannedEntities ({ sourceEntities = [], detection = null , capabilityRefs = new Map () } = {}) {
706 const profiledSlugs = new Set ((detection?.detected || []). map (( plugin ) => normalizeToken (plugin.plugin)));
707 const rows = Array. isArray (sourceEntities) ? sourceEntities
708 : Object. entries (sourceEntities || {}). map (([ key , row ]) => ({ sourceEntityRef: row?.sourceEntityRef || key, ... row }));
709 const entities = [];
710 for ( const row of rows) {
711 if ( ! row || typeof row !== 'object' ) continue ;
712 if ( ! row.sourceMeta?.extractionPlan && row.bridgeDesignated !== true ) continue ;
713 const proposal = proposeBridgeEntity ({ sourceEntityRef: row.sourceEntityRef, table: row.schema?.table, displayName: row.displayName }, capabilityRefs);
714 // A profiled plugin's bridge entity is recognized through its profile, not here.
715 if ( ! proposal || profiledSlugs. has ( normalizeToken (proposal.slug))) continue ;
716 const declared = row.candidateTargetRefs || row.sourceMeta?.candidateTargetRefs;
717 entities. push ({
718 entity: proposal.entity,
719 sourceEntityRef: row.sourceEntityRef,
720 slug: proposal.slug,
721 origin: 'bridge-plan' ,
722 recognized: false ,
723 channel: 'db-only' ,
724 displayName: row.displayName || row.entityName || proposal.entity,
725 recordCount: typeof row.recordCount === 'number' ? row.recordCount : null ,
726 inUse: row.inUse == null ? null : row.inUse,
727 candidateTargetRefs: Array. isArray (declared) && declared. length ? [ ... declared] : proposal.candidateTargetRefs,
728 proposedCapability: row.sourceMeta?.proposedCapability || proposal.proposedCapability,
729 capabilityConfidence: 'proposed' ,
730 });
731 }
732 return entities. sort (( a , b ) => a.sourceEntityRef. localeCompare (b.sourceEntityRef));
733 }
734
735 function buildGenericNotes ( kind , entry ) {
736 const notes = [];
737 if (kind === 'taxonomy' && entry?.hierarchical === true ) {
738 notes. push ( 'Hierarchical source taxonomy: mapping must record a faithfulness-ledger entry if the chosen Wix target is flat.' );
739 }
740 notes. push ( 'Derived structurally without a plugin profile; unregistered post meta (show_in_rest absent) is not readable over REST and is therefore not included.' );
741 return notes;
742 }
743
744 // Coverage rows: one per capability, the user-facing answer to "what happens to my plugins".
745 //
746 // Row shape. Common fields: capability,
747 // recognized, basis ('profile' | 'list' | 'proposed' | 'decision'), plugins[], sourceEntities[],
748 // channels[], recordCounts, profileVersion, blocked[], userImpact, action. Status-specific
749 // fields:
750 // migration-planned - via ('api' | 'cms'), confidence ('confirmed' | 'proposed'), targetRefs[]
751 // manual-mapping - via ('manual'), confidence ('confirmed'), targetRefs[], manualSteps
752 // (the rp-target-wix entity's manualSteps object, verbatim)
753 // no-need-to-migrate - reason ('platform-does-it' | 'not-needed' | 'reconfigure-in-wix'),
754 // replacedBy, rationale (required); plus decidedBy/decidedOn when
755 // basis is 'decision' (the signed per-capability register)
756 // pending - reason (including 'cannot-tell'), searched[]
757 // requires-development - decidedBy, decidedOn, evidence[]
758 //
759 // CMS fallback (recognized capabilities only). A profile never names a cms/* ref; CMS is
760 // reached only through the signed eligibility register plus the merchant's per-migration
761 // approval (`pluginCmsFallback:<capability>` in decisions.json):
762 // migration-planned · via cms · confidence confirmed · basis decision, with
763 // cmsFallback { eligibleBy, eligibleOn, approvedBy, approvedAt, featureLoss, targetRefsByEntity,
764 // proposalDigest, functionalityRestored: false }
765 // pending, reason cms-fallback-awaiting-approval | cms-fallback-decision-stale |
766 // cms-fallback-declined | cms-fallback-target-knowledge-unavailable, with
767 // cmsFallback { eligible: true, proposedTargetRefsByEntity, featureLoss,
768 // nativeAlternativesChecked, proposalDigest, staleDecision?, invalidDecision?, decision? }
769 // cmsFallbackDecisionIgnored { reason: native-target-available | eligibility-removed, value, decidedAt }
770 // on a row whose stored CMS answer no longer applies.
771 //
772 // Operator override. Every natively routed row (migration-planned via api, or
773 // manual-mapping) carries the offer the operator may take instead:
774 // mappingTargetOverride { available: true, proposedTargetRefsByEntity, featureLoss,
775 // nativeTargetRefs, proposalDigest }
776 // and, when the native target has a side effect worth weighing, `notices[]` (verbatim text).
777 // A valid `mappingTargetOverride:<capability>` answer bound to that offer wins before native
778 // precedence: migration-planned · via cms · basis decision, with
779 // cmsFallback { override: true, approvedBy, approvedAt, featureLoss, targetRefsByEntity,
780 // nativeTargetRefs, proposalDigest, functionalityRestored: false }
781 // A stale or malformed answer makes the row pending (reason mapping-target-override-stale) so
782 // it is asked again; nothing is written to the native target or to CMS meanwhile.
783
784 // For manual-mapping: every one of `refs` resolves to a manual-mapping target (never a mix — a
785 // capability with some manual and some automated targets is a KB authoring error, not
786 // something this layer should silently resolve one way or the other). Returns the first
787 // manualSteps object to attach to the row, or null when `refs` is empty or not entirely
788 // manual-mapping.
789 function resolveManualMapping ( refs , targetKnowledge ) {
790 const manualStepsByRef = targetKnowledge && targetKnowledge.manualStepsByRef;
791 if ( ! manualStepsByRef || ! refs || refs. length === 0 ) return null ;
792 const steps = refs. map (( ref ) => manualStepsByRef. get (ref)). filter (Boolean);
793 return steps. length === refs. length ? steps[ 0 ] : null ;
794 }
795
796 function classifyCoverage ({
797 detection = null ,
798 genericEntities = [],
799 // Tier C entities (deriveBridgePlannedEntities): an unprofiled plugin's own tables, planned.
800 bridgeEntities = [],
801 unprofiledRoutes = [],
802 targetKnowledge = null ,
803 entityStatsByRoute = new Map (),
804 // The human-signed register (capabilities-without-native-target.json). The ONLY source of
805 // requires-development. Entries carry decidedBy/decidedOn/evidence; a bare Set of capability
806 // strings is also accepted.
807 humanRuledOutCapabilities = new Set (),
808 requiresDevelopmentEntries = [],
809 // Working notes for profiled capabilities we have not placed (capabilities-pending-decision.json).
810 pendingNotes = [],
811 // The no-migration-needed file, both tiers:
812 // hints[] - installed plugins with no data to move at all. A hit here is strong
813 // evidence, not the final answer — the agent confirms it during classification and
814 // records the rationale; the row is marked `basis: list` so an
815 // unconfirmed hit is auditable.
816 // capabilities[] - human-signed per-capability verdicts on PROFILED plugins, the only way
817 // a profiled capability reaches no-need-to-migrate. `basis: decision`.
818 noMigrationNeeded = null ,
819 // The signed CMS-fallback eligibility register entries (cms-fallback-eligible.json
820 // capabilities[]) and the run's decisions object (only `pluginCmsFallback:*` keys are read).
821 cmsFallbackEntries = [],
822 cmsFallbackDecisions = {},
823 // The cutover-task-only register (cutover-task-only.json): no data, a named task.
824 cutoverTasks = null ,
825 // Routes this run actually read (sampled entities). A coreCovered plugin counts as covered
826 // only when one of its core namespaces is among them.
827 readRoutes = [],
828 // per detected plugin, what its profile's dataPresence checks found on this site
829 // (plugin-data-presence.js presenceByPlugin). Empty for every profile without dataPresence,
830 // so those classify exactly as before.
831 dataPresence = new Map (),
832 } = {}) {
833 const rows = [];
834 const registers = slugRegisters ({ noMigrationNeeded, cutoverTasks });
835 const readNamespaceSet = readNamespaces (readRoutes);
836 const knownRefs = targetKnowledge?.knownRefs || new Set ();
837 const capabilityRefs = targetKnowledge?.capabilityRefs || new Map ();
838 // A load failure leaves empty lookups; an empty lookup caused by a failure never counts as
839 // "nothing native fits", so no CMS fallback may be applied on it.
840 const targetKnowledgeUnavailable = ! targetKnowledge || Boolean (targetKnowledge.loadError);
841 const eligibleByCapability = eligibilityByCapability (cmsFallbackEntries);
842
843 const ruledOut = new Map ();
844 for ( const entry of requiresDevelopmentEntries) {
845 if (entry && entry.capability) ruledOut. set (entry.capability, entry);
846 }
847 for ( const capability of humanRuledOutCapabilities) {
848 if ( ! ruledOut. has (capability)) ruledOut. set (capability, null );
849 }
850 const pendingByCapability = new Map (
851 pendingNotes. filter (( entry ) => entry && entry.capability). map (( entry ) => [entry.capability, entry]),
852 );
853 // Only a SIGNED entry grants the verdict. An unsigned one is ignored here rather than
854 // trusted, so a stale or hand-edited copy of the register cannot let automation decide a
855 // customer has nothing to move; the validator rejects it in the repo, and the row falls
856 // through to pending at runtime, which is the safe direction.
857 const noNeedByCapability = new Map ();
858 for ( const entry of noMigrationNeeded?.capabilities || []) {
859 if (entry && entry.capability && entry.decidedBy && entry.decidedOn) {
860 noNeedByCapability. set (entry.capability, entry);
861 }
862 }
863
864 const byCapability = new Map ();
865 for ( const plugin of detection?.detected || []) {
866 for ( const capability of plugin.capabilities) {
867 if ( ! byCapability. has (capability)) byCapability. set (capability, []);
868 byCapability. get (capability). push (plugin);
869 }
870 }
871
872 // Recognized capabilities are classified independently. A resolved target is migration-planned
873 // even when its source channel is blocked; an unresolved capability remains pending.
874 for ( const [ capability , plugins ] of byCapability) {
875 const entities = plugins. flatMap (( plugin ) => {
876 const hasAttribution = plugin.entities. some (( entity ) => entity.capability);
877 return hasAttribution
878 ? plugin.entities. filter (( entity ) => entity.capability === capability)
879 : plugin.entities;
880 });
881 // Defence in depth: a stale or hand-edited profile's cms/* refs are dropped here, so a
882 // profile can never plan CMS on its own (the validator rejects them in the repo).
883 const refs = Array. from ( new Set (entities. flatMap (( entity ) => entity.candidateTargetRefs)))
884 . filter (( ref ) => knownRefs. has (ref) && ! String (ref). startsWith ( 'cms/' ));
885 const nativeRefs = capabilityRefs. get (capability) || [];
886 const recordCounts = {};
887 for ( const entity of entities) {
888 const stats = entity.route ? entityStatsByRoute. get (entity.route) : null ;
889 if (stats && stats.recordCount !== null ) recordCounts[entity.entity] = stats.recordCount;
890 }
891
892 const channels = Array. from ( new Set (entities. map (( entity ) => entity.channel))). sort ();
893 const base = {
894 capability,
895 recognized: true ,
896 basis: 'profile' ,
897 plugins: plugins. map (( plugin ) => plugin.plugin). sort (),
898 sourceEntities: entities. map (( entity ) => entity.entity). sort (),
899 recordCounts,
900 channels,
901 channel: channels. length === 1 ? channels[ 0 ] : channels. join ( '+' ),
902 profileVersion: Array. from ( new Set (plugins. map (( plugin ) => plugin.profileVersion). filter (Boolean))). join ( '+' ) || null ,
903 pitfalls: entities. flatMap (( entity ) => entity.pitfalls),
904 blocked: blockersForEntities (entities),
905 };
906
907 let row;
908 const storedCmsAnswer = cmsFallbackDecisions ? cmsFallbackDecisions[ cmsFallbackDecisionKey (capability)] : undefined ;
909 const ignoredCmsAnswer = ( reason ) => (storedCmsAnswer === undefined ? null : {
910 reason,
911 value: storedCmsAnswer && typeof storedCmsAnswer === 'object' ? storedCmsAnswer.value ?? null : null ,
912 decidedAt: storedCmsAnswer && typeof storedCmsAnswer === 'object' ? storedCmsAnswer.decidedAt ?? null : null ,
913 });
914 // Eligibility applies only when the register entry covers exactly the entities this
915 // capability resolves to now; a profile that gained, lost or re-attributed an entity makes
916 // the signed proposal describe something else, so it no longer applies.
917 const rawEligibility = eligibleByCapability. get (capability) || null ;
918 const eligibility = rawEligibility
919 && JSON . stringify (Object. keys ( canonicalTargetRefsByEntity (rawEligibility.targetRefsByEntity)))
920 === JSON . stringify ( entityKeysForCapability (plugins, capability))
921 ? rawEligibility
922 : null ;
923 const verdict = ruledOut. get (capability);
924 const nativeRefsManualSteps = resolveManualMapping (nativeRefs, targetKnowledge);
925 const refsManualSteps = resolveManualMapping (refs, targetKnowledge);
926 const presence = capabilityPresence (plugins, dataPresence);
927 if (presence?.outcome) {
928 // Checked on the site: nothing stored (no-data-found), or only an inactive plugin's
929 // leftovers (inactive-leftover, a product decision). Nothing to move, so nothing blocked.
930 // An unknown check never reaches here: it keeps the normal path below.
931 row = {
932 ... base,
933 status: 'no-need-to-migrate' ,
934 basis: 'probe' ,
935 reason: presence.outcome,
936 replacedBy: null ,
937 rationale: presence.rationale,
938 dataPresence: presence.checks,
939 targetRefs: [],
940 blocked: [],
941 };
942 } else if (ruledOut. has (capability)) {
943 // Only reachable from the human-signed register.
944 row = {
945 ... base,
946 status: 'requires-development' ,
947 basis: 'decision' ,
948 decidedBy: verdict?.decidedBy || null ,
949 decidedOn: verdict?.decidedOn || null ,
950 evidence: [ ... (verdict?.evidence || [])],
951 targetRefs: [],
952 };
953 } else if (nativeRefs. length > 0 && nativeRefsManualSteps) {
954 // For manual-mapping: every target ref this capability resolved to is manual-mapping — there is
955 // nothing for our code to call, only a runbook to hand the merchant.
956 row = {
957 ... base,
958 status: 'manual-mapping' ,
959 via: 'manual' ,
960 confidence: 'confirmed' ,
961 targetRefs: [ ... nativeRefs]. sort (),
962 manualSteps: nativeRefsManualSteps,
963 };
964 } else if (nativeRefs. length > 0 ) {
965 // How well the target entity is exercised is our problem, not the customer's: verified
966 // and unverified native targets classify identically.
967 row = { ... base, status: 'migration-planned' , via: 'api' , confidence: 'confirmed' , targetRefs: [ ... nativeRefs]. sort () };
968 } else if (refs. length > 0 && refsManualSteps) {
969 row = {
970 ... base,
971 status: 'manual-mapping' ,
972 via: 'manual' ,
973 confidence: 'confirmed' ,
974 targetRefs: refs. sort (),
975 manualSteps: refsManualSteps,
976 };
977 } else if (refs. length > 0 ) {
978 row = { ... base, status: 'migration-planned' , via: 'api' , confidence: 'confirmed' , targetRefs: refs. sort () };
979 } else if (eligibility) {
980 row = classifyCmsFallback ({ base, capability, entry: eligibility, decisions: cmsFallbackDecisions, targetKnowledgeUnavailable });
981 } else if (noNeedByCapability. has (capability)) {
982 // A human decided this profiled capability has nothing to move. Not impossibility
983 // — the outcome exists in Wix, or was never data — but still a decision about
984 // what the customer does not get, so it is signed and it is persistent: without this
985 // exit the row re-lands as pending on every run and the verdict has nowhere to live.
986 const decision = noNeedByCapability. get (capability);
987 row = {
988 ... base,
989 status: 'no-need-to-migrate' ,
990 basis: 'decision' ,
991 reason: normalizeNoMigrationReason (decision.reason),
992 replacedBy: decision.replacedBy || null ,
993 rationale: decision.rationale || null ,
994 decidedBy: decision.decidedBy,
995 decidedOn: decision.decidedOn,
996 targetRefs: [],
997 // Nothing to move means nothing is blocked. An unreadable channel on a capability
998 // that is not being migrated is not something to ask the user to fix.
999 blocked: [],
1000 };
1001 } else {
1002 // A profiled capability we have not placed. Pending is a statement about OUR knowledge;
1003 // the working note carries what we searched and what we suspect, with no authority.
1004 const note = pendingByCapability. get (capability);
1005 row = {
1006 ... base,
1007 status: 'pending' ,
1008 reason: note?.reason || 'no Wix target resolved for this capability yet' ,
1009 searched: note?.searched ? [note.searched] : [],
1010 targetRefs: [],
1011 };
1012 }
1013 if (presence && ! row.dataPresence) row.dataPresence = presence.checks;
1014 if ( ! (row.status === 'no-need-to-migrate' && row.basis === 'decision' )) {
1015 const external = externalDataTasks (plugins);
1016 // Only a cutover task: never a blocker, never a question, never a stop.
1017 if (external.cutoverTask) {
1018 row = { ... row, externalData: external.externalData, cutoverTask: row.cutoverTask || external.cutoverTask };
1019 }
1020 }
1021 if ([ 'manual-mapping' , 'migration-planned' ]. includes (row.status) && row.via !== 'cms' ) {
1022 row = applyMappingTargetOverride ({ row, base, capability, plugins, decisions: cmsFallbackDecisions });
1023 }
1024 if (storedCmsAnswer !== undefined && ! row.cmsFallback) {
1025 if ([ 'manual-mapping' , 'migration-planned' ]. includes (row.status)) {
1026 row.cmsFallbackDecisionIgnored = ignoredCmsAnswer ( 'native-target-available' );
1027 } else if ( ! eligibility && row.status !== 'requires-development' ) {
1028 row.cmsFallbackDecisionIgnored = ignoredCmsAnswer ( 'eligibility-removed' );
1029 }
1030 }
1031 row.userImpact = describeImpact (row, entities);
1032 row.action = describeAction (row);
1033 rows. push (row);
1034 }
1035
1036 // Attribute generic entities to an installed-but-unprofiled plugin BEFORE the shape-only
1037 // grouping below, and only when its slug/textdomain genuinely prefixes the entity's own
1038 // rest_base/slug. Two unrelated plugins that both register "a custom post type" or "a
1039 // custom taxonomy" must never end up sharing one row: a shared shape-bucket row attributed
1040 // wholesale to whichever plugin matched first previously let one plugin absorb
1041 // other plugins' own entities and an unrelated taxonomy (verified against a live WordPress installation).
1042 // An entity with no confidently-matched owner stays in the shape bucket, unattributed —
1043 // visible at the mapping review, credited to no one, which is honest; false attribution is
1044 // not.
1045 const MIN_OWNER_TOKEN_LENGTH = 4 ;
1046 const ownedEntityKeys = new Set ();
1047 const attributed = new Set ();
1048 for ( const installed of detection?.installedButUnprofiled || []) {
1049 const tokens = pluginTokens (installed). filter (( token ) => token. length >= MIN_OWNER_TOKEN_LENGTH );
1050 const ownedEntities = genericEntities. filter (( entity ) => {
1051 const normalizedEntity = normalizeToken (entity.entity);
1052 return tokens. some (( token ) => normalizedEntity. startsWith (token));
1053 });
1054 if (ownedEntities. length === 0 ) continue ;
1055
1056 for ( const entity of ownedEntities) ownedEntityKeys. add (entity.entity);
1057 const recordCounts = {};
1058 for ( const entity of ownedEntities) {
1059 if (entity.recordCount !== null ) recordCounts[entity.entity] = entity.recordCount;
1060 }
1061 const nativeRefs = Array. from ( new Set (ownedEntities. map (( entity ) => entity.proposedCapability)))
1062 . flatMap (( capability ) => capabilityRefs. get (capability) || []);
1063 const via = nativeRefs. length > 0 ? 'api' : 'cms' ;
1064 const names = ownedEntities. map (( entity ) => entity.displayName). join ( ', ' );
1065 rows. push ({
1066 capability: `derived:${ installed . plugin }` ,
1067 recognized: false ,
1068 basis: 'proposed' ,
1069 plugins: [installed.plugin],
1070 sourceEntities: ownedEntities. map (( entity ) => entity.entity). sort (),
1071 recordCounts,
1072 channels: Array. from ( new Set (ownedEntities. map (( entity ) => entity.channel))). sort (),
1073 channel: 'core-cpt' ,
1074 profileVersion: null ,
1075 status: 'migration-planned' ,
1076 via,
1077 confidence: 'proposed' ,
1078 targetRefs: via === 'api' ? Array. from ( new Set (nativeRefs)). sort () : [ ... GENERIC_TARGET_REFS ],
1079 pitfalls: [],
1080 blocked: [],
1081 hierarchicalEntities: ownedEntities. filter (( entity ) => entity.hierarchical). map (( entity ) => entity.entity),
1082 userImpact: via === 'api'
1083 ? `${ names } appears to match a native Wix entity. We derived this mapping rather than authored it, so it is proposed and needs your confirmation at the mapping review.`
1084 : `${ names } will come across as CMS collections, keeping original record IDs. We derived this mapping rather than authored it, so review it at the mapping review.` ,
1085 action: null ,
1086 });
1087 attributed. add (installed.plugin);
1088 }
1089
1090 // Derived (unrecognized but readable) entities with no confidently-matched plugin owner,
1091 // grouped by proposed capability so the report stays one row per capability rather than one
1092 // row per derived post type. The proposal is matched against the domain KB: a confirmed
1093 // match maps via API, the same as a profiled plugin — but as `proposed`, decided at the
1094 // mapping review, never by automation alone.
1095 const genericByCapability = new Map ();
1096 for ( const entity of genericEntities) {
1097 if (ownedEntityKeys. has (entity.entity)) continue ;
1098 if ( ! genericByCapability. has (entity.proposedCapability)) genericByCapability. set (entity.proposedCapability, []);
1099 genericByCapability. get (entity.proposedCapability). push (entity);
1100 }
1101 for ( const [ capability , group ] of genericByCapability) {
1102 const recordCounts = {};
1103 for ( const entity of group) {
1104 if (entity.recordCount !== null ) recordCounts[entity.entity] = entity.recordCount;
1105 }
1106 const nativeRefs = capabilityRefs. get (capability) || [];
1107 const via = nativeRefs. length > 0 ? 'api' : 'cms' ;
1108 const names = group. map (( entity ) => entity.displayName). join ( ', ' );
1109 rows. push ({
1110 capability,
1111 recognized: false ,
1112 basis: 'proposed' ,
1113 plugins: [],
1114 sourceEntities: group. map (( entity ) => entity.entity). sort (),
1115 recordCounts,
1116 channels: Array. from ( new Set (group. map (( entity ) => entity.channel))). sort (),
1117 channel: 'core-cpt' ,
1118 profileVersion: null ,
1119 status: 'migration-planned' ,
1120 via,
1121 confidence: 'proposed' ,
1122 targetRefs: via === 'api' ? [ ... nativeRefs]. sort () : [ ... GENERIC_TARGET_REFS ],
1123 pitfalls: [],
1124 blocked: [],
1125 hierarchicalEntities: group. filter (( entity ) => entity.hierarchical). map (( entity ) => entity.entity),
1126 userImpact: via === 'api'
1127 ? `${ names } appears to match a native Wix entity. We derived this mapping rather than authored it, so it is proposed and needs your confirmation at the mapping review.`
1128 : `${ names } will come across as CMS collections, keeping original record IDs. We derived this mapping rather than authored it, so review it at the mapping review.` ,
1129 action: null ,
1130 });
1131 }
1132
1133 // Tier C: an unprofiled plugin's own tables, planned through the bridge. Owned by the ref's
1134 // slug exactly (the plan names it), never by token guessing. Merged into the plugin's
1135 // `derived:` row when its post types already produced one, so a plugin has one row.
1136 const bridgeBySlug = new Map ();
1137 for ( const entity of bridgeEntities) {
1138 if ( ! bridgeBySlug. has (entity.slug)) bridgeBySlug. set (entity.slug, []);
1139 bridgeBySlug. get (entity.slug). push (entity);
1140 }
1141 for ( const [ slug , group ] of bridgeBySlug) {
1142 const installed = (detection?.installedButUnprofiled || [])
1143 . find (( candidate ) => normalizeToken ( String (candidate.plugin || '' ). split ( '/' )[ 0 ]) === normalizeToken (slug));
1144 const pluginId = installed ? installed.plugin : slug;
1145 const nativeRefs = Array. from ( new Set (group. flatMap (( entity ) => entity.candidateTargetRefs. filter (( ref ) => ! ref. startsWith ( 'cms/' )))));
1146 let row = rows. find (( candidate ) => candidate.capability === `derived:${ pluginId }` );
1147 if ( ! row) {
1148 row = {
1149 capability: `derived:${ pluginId }` ,
1150 recognized: false ,
1151 basis: 'proposed' ,
1152 plugins: installed ? [installed.plugin] : [],
1153 sourceEntities: [],
1154 recordCounts: {},
1155 channels: [],
1156 channel: 'db-only' ,
1157 profileVersion: null ,
1158 status: 'migration-planned' ,
1159 via: 'cms' ,
1160 confidence: 'proposed' ,
1161 targetRefs: [],
1162 pitfalls: [],
1163 blocked: [],
1164 hierarchicalEntities: [],
1165 action: null ,
1166 };
1167 rows. push (row);
1168 }
1169 for ( const entity of group) {
1170 if (entity.recordCount !== null ) row.recordCounts[entity.sourceEntityRef] = entity.recordCount;
1171 }
1172 row.sourceEntities = Array. from ( new Set ([ ... row.sourceEntities, ... group. map (( entity ) => entity.sourceEntityRef)])). sort ();
1173 row.channels = Array. from ( new Set ([ ... row.channels, 'db-only' ])). sort ();
1174 row.bridgeEntities = group. map (({ sourceEntityRef , proposedCapability , candidateTargetRefs }) => ({ sourceEntityRef, proposedCapability, candidateTargetRefs }));
1175 const native = Array. from ( new Set ([ ... (row.via === 'api' ? row.targetRefs : []), ... nativeRefs])). sort ();
1176 row.via = native. length > 0 ? 'api' : 'cms' ;
1177 row.targetRefs = native. length > 0 ? native : [ ... GENERIC_TARGET_REFS ];
1178 const names = group. map (( entity ) => entity.displayName). join ( ', ' );
1179 row.userImpact = row.via === 'api'
1180 ? `${ names } (read from ${ slug }'s own tables) appears to match a native Wix entity. We derived this mapping rather than authored it, so it is proposed and needs your confirmation at the mapping review; a private CMS archive is offered instead.`
1181 : `${ names } (read from ${ slug }'s own tables) will come across as CMS collections, keeping original record IDs. We derived this mapping rather than authored it, so review it at the mapping review.` ;
1182 // The same side-effect notice a profiled native row carries. The CMS choice for a derived row is
1183 // made between its candidate targets at the mapping review, so it needs no override offer.
1184 const notices = row.via === 'api' ? nativeRouteNotices (row.targetRefs) : [];
1185 if (notices. length ) { row.notices = notices; row.userImpact = `${ row . userImpact } ${ notices . join ( ' ' ) }` ; } else delete row.notices;
1186 if (installed) attributed. add (installed.plugin);
1187 }
1188
1189 for ( const unprofiled of unprofiledRoutes) {
1190 const read = unprofiled.read !== false ;
1191 rows. push (read ? {
1192 capability: `unknown:${ unprofiled . namespace }` ,
1193 recognized: false ,
1194 basis: 'proposed' ,
1195 plugins: [],
1196 sourceEntities: unprofiled.routes,
1197 recordCounts: {},
1198 channels: [ 'plugin-rest' ],
1199 channel: 'plugin-rest' ,
1200 profileVersion: null ,
1201 status: 'migration-planned' ,
1202 via: 'cms' ,
1203 confidence: 'proposed' ,
1204 targetRefs: [ ... GENERIC_TARGET_REFS ],
1205 pitfalls: [],
1206 blocked: [],
1207 userImpact: `Records under ${ unprofiled . namespace } look durable but we do not recognise the plugin that owns them. They were accepted by shape and will map to CMS collections unless you exclude them.` ,
1208 action: null ,
1209 } : {
1210 // Collection-shaped routes in an unrecognized namespace, deliberately not read (reading
1211 // them speculatively pulled in cookie-consent and object-cache config).
1212 // They surface as Pending so silence cannot read as "this plugin has nothing".
1213 capability: `unknown:${ unprofiled . namespace }` ,
1214 recognized: false ,
1215 basis: 'proposed' ,
1216 plugins: [],
1217 sourceEntities: unprofiled.routes,
1218 recordCounts: {},
1219 channels: [ 'plugin-rest' ],
1220 channel: 'plugin-rest' ,
1221 profileVersion: null ,
1222 status: 'pending' ,
1223 reason: 'collection-shaped routes in an unrecognized namespace; not read by default' ,
1224 searched: [],
1225 targetRefs: [],
1226 pitfalls: [],
1227 blocked: [],
1228 userImpact: `${ unprofiled . namespace } exposes ${ unprofiled . routes . length } collection-style route(s) that look like they hold records, but we do not recognise this plugin and did not read them. If this plugin holds data you need, tell us and we will add support.` ,
1229 action: 'Confirm whether this plugin holds data you need migrated.' ,
1230 });
1231 }
1232
1233 // An installed-but-unprofiled plugin whose namespace IS being read is not unreadable — it is
1234 // unrecognized but covered by the generic tier. Attribute it to that row instead of emitting
1235 // a second, contradictory row that tells the user their data will not migrate when it will.
1236 // Runs unconditionally (not gated on the ownership attribution above): a plugin can genuinely
1237 // own both a generic CPT/taxonomy AND a separate unread custom namespace at once (MailPoet:
1238 // `mailpoet_email` CPT is owned and readable, `mailpoet/v1`'s 9 routes are a distinct,
1239 // unread namespace) — both facts belong on its row, not just whichever attributed first.
1240 for ( const installed of detection?.installedButUnprofiled || []) {
1241 const tokens = pluginTokens (installed);
1242 const namespaceRow = unprofiledRoutes. find (( entry ) => tokens. some (( token ) => normalizeToken (entry.namespace. split ( '/' )[ 0 ]) === token));
1243 if (namespaceRow) {
1244 const row = rows. find (( candidate ) => candidate.capability === `unknown:${ namespaceRow . namespace }` );
1245 if (row) {
1246 row.plugins. push (installed.plugin);
1247 attributed. add (installed.plugin);
1248 }
1249 }
1250 }
1251
1252 for ( const installed of detection?.installedButUnprofiled || []) {
1253 const slug = String (installed.plugin || '' ). split ( '/' )[ 0 ];
1254 const keys = registerKeys (installed.plugin);
1255 // This skill's own plugin on the source site: not the merchant's, left out of the report.
1256 if (registers. lookup ( 'toolkit' , keys)) continue ;
1257 // A cutover task is added even when data was attributed to the plugin: the task never
1258 // replaces a data row, it only replaces the "could not identify any data" one.
1259 const task = registers. lookup ( 'task' , keys);
1260 if (task) {
1261 upsertRow (rows, cutoverTaskRow (task, installed.name || task.displayName), installed.plugin);
1262 continue ;
1263 }
1264 if (attributed. has (installed.plugin)) continue ;
1265 const core = registers. lookup ( 'core' , keys);
1266 if (core && (core.coreNamespaces || []). some (( ns ) => readNamespaceSet. has (ns))) {
1267 upsertRow (rows, coreCoveredRow (core, installed.name || core.displayName), installed.plugin);
1268 continue ;
1269 }
1270 const hint = registers. lookup ( 'hint' , keys);
1271 if (hint && rows. some (( row ) => row.capability === `no-migration-needed:${ hint . slug }` )) {
1272 // A second copy of the same plugin in another folder (wp-rocket2/wp-rocket): one row.
1273 upsertRow (rows, null , installed.plugin, `no-migration-needed:${ hint . slug }` );
1274 continue ;
1275 }
1276 if (hint) {
1277 // The no-migration-needed list: nothing to move. The row carries its rationale and is
1278 // marked `basis: list`, so the mapping review can see the answer came from the list
1279 // (the agent confirms it there — a list hit alone is evidence, not the decision).
1280 rows. push ({
1281 capability: `no-migration-needed:${ hint . slug }` ,
1282 recognized: false ,
1283 basis: 'list' ,
1284 plugins: [installed.plugin],
1285 sourceEntities: [],
1286 recordCounts: {},
1287 channels: [],
1288 channel: null ,
1289 profileVersion: null ,
1290 status: 'no-need-to-migrate' ,
1291 reason: normalizeNoMigrationReason (hint.reason || hint.disposition),
1292 replacedBy: hint.replacedBy || null ,
1293 rationale: [hint.does, hint.provenance ? `(${ hint . provenance })` : null ]. filter (Boolean). join ( ' ' )
1294 || 'Listed on the no-migration-needed list.' ,
1295 targetRefs: [],
1296 pitfalls: [],
1297 blocked: [],
1298 userImpact: hint.replacedBy || `${ installed . name || slug } needs no data migration: ${ hint . does || 'nothing to move'}.` ,
1299 action: null ,
1300 });
1301 continue ;
1302 }
1303 // "cannot tell": an unrecognized plugin we can read no intent from. This is a
1304 // statement about OUR knowledge — the honest exit that stops classification from becoming
1305 // a silent way to drop data. Decided by a human at the mapping review, the only exit from
1306 // Pending. Deliberately says "could not identify", not "exposes no data": without a
1307 // profile we cannot tell whether it has no readable data or data we failed to attribute.
1308 rows. push ({
1309 capability: `unknown-plugin:${ installed . plugin }` ,
1310 recognized: false ,
1311 basis: 'proposed' ,
1312 plugins: [installed.plugin],
1313 sourceEntities: [],
1314 recordCounts: {},
1315 channels: [],
1316 channel: null ,
1317 profileVersion: null ,
1318 status: 'pending' ,
1319 reason: 'cannot-tell' ,
1320 searched: [],
1321 targetRefs: [],
1322 pitfalls: [],
1323 blocked: [],
1324 userImpact: core
1325 ? `${ installed . name || installed . plugin } holds the store's core data, but this run did not read ${ core . coreNamespaces . join ( ', ' ) }. Its products, orders and customers will not migrate until that read runs.`
1326 : `${ installed . name || installed . plugin } is installed but we could not identify any migratable data for it. If it holds data you need, it will not migrate as things stand.` ,
1327 action: core
1328 ? `Re-run discovery with the credential that reads ${ core . coreNamespaces . join ( ', ' ) }.`
1329 : 'Tell us if this plugin holds data you need; it may need an export file or plugin support added.' ,
1330 });
1331 }
1332
1333 // Fingerprinted plugins — public evidence only, so typically an unauthenticated run
1334 // (detectPlugins already deduplicates against the installed list). Each classifies exactly
1335 // like an installed-but-unrecognized plugin: the no-migration-needed list can
1336 // clear it, otherwise it is Pending — never silently fine, and never a route in scope.
1337 const installedSlugs = new Set ([
1338 ... (detection?.installedButUnprofiled || []). map (( entry ) => String (entry.plugin || '' ). split ( '/' )[ 0 ]),
1339 ... (detection?.detected || []). map (( entry ) => entry.plugin),
1340 ]);
1341 for ( const print of detection?.fingerprinted || []) {
1342 // A fingerprint that names an installed plugin adds nothing (detectPlugins drops these;
1343 // this also covers stored inventories from before an alias was added).
1344 if (installedSlugs. has (print.slug)) continue ;
1345 const printKeys = [print.slug, print.token]. filter (Boolean);
1346 if (registers. lookup ( 'toolkit' , printKeys)) continue ;
1347 const name = print.displayName || print.token;
1348 const task = registers. lookup ( 'task' , printKeys);
1349 if (task) {
1350 const row = upsertRow (rows, cutoverTaskRow (task, print.displayName || task.displayName), print.slug);
1351 row.fingerprintEvidence = Array. from ( new Set ([ ... (row.fingerprintEvidence || []), ... print.evidence])). sort ();
1352 continue ;
1353 }
1354 const hint = registers. lookup ( 'hint' , printKeys);
1355 if (hint) {
1356 rows. push ({
1357 capability: `no-migration-needed:${ hint . slug }` ,
1358 recognized: false ,
1359 basis: 'list' ,
1360 plugins: [print.slug],
1361 sourceEntities: [],
1362 recordCounts: {},
1363 channels: [],
1364 channel: null ,
1365 profileVersion: null ,
1366 status: 'no-need-to-migrate' ,
1367 reason: normalizeNoMigrationReason (hint.reason || hint.disposition),
1368 replacedBy: hint.replacedBy || null ,
1369 rationale: [hint.does, hint.provenance ? `(${ hint . provenance })` : null ]. filter (Boolean). join ( ' ' )
1370 || 'Listed on the no-migration-needed list.' ,
1371 targetRefs: [],
1372 pitfalls: [],
1373 blocked: [],
1374 fingerprintEvidence: print.evidence,
1375 userImpact: hint.replacedBy || `${ name } needs no data migration: ${ hint . does || 'nothing to move'}.` ,
1376 action: null ,
1377 });
1378 continue ;
1379 }
1380 rows. push ({
1381 capability: `fingerprinted:${ print . token }` ,
1382 recognized: false ,
1383 basis: 'proposed' ,
1384 plugins: [print.slug],
1385 sourceEntities: [],
1386 recordCounts: {},
1387 channels: [],
1388 channel: null ,
1389 profileVersion: null ,
1390 status: 'pending' ,
1391 reason: 'cannot-tell' ,
1392 searched: [],
1393 targetRefs: [],
1394 pitfalls: [],
1395 blocked: [],
1396 fingerprintEvidence: print.evidence,
1397 userImpact: `${ name } is visible on the site (public fingerprint) but we could not read what it holds${ print . aliasMatched ? '' : ' and do not recognise it'}. If it holds data you need, it will not migrate as things stand.` ,
1398 action: 'Tell us if this plugin holds data you need; an administrator credential or plugin support may be required.' ,
1399 });
1400 }
1401
1402 for ( const row of rows) row.plugins = Array. from ( new Set (row.plugins)). sort ();
1403 return rows. sort (( a , b ) => a.capability. localeCompare (b.capability));
1404 }
1405
1406 // The operator's CMS override for a natively routed row. Without an answer the row keeps its
1407 // native route and only carries the offer; a valid answer bound to the offer replaces it.
1408 function applyMappingTargetOverride ({ row , base , capability , plugins , decisions }) {
1409 const proposal = mappingTargetOverrideProposal ({
1410 capability,
1411 entityKeys: entityKeysForCapability (plugins, capability),
1412 nativeTargetRefs: row.targetRefs || [],
1413 });
1414 const proposalDigest = cmsFallbackProposalDigest (proposal);
1415 const offer = {
1416 available: true ,
1417 proposedTargetRefsByEntity: proposal.targetRefsByEntity,
1418 featureLoss: proposal.featureLoss,
1419 nativeTargetRefs: proposal.nativeTargetRefs,
1420 proposalDigest,
1421 };
1422 const evaluation = evaluateMappingTargetOverride ({ capability, proposal, decisions: decisions || {} });
1423 if (evaluation.state === 'cms' ) {
1424 return {
1425 ... base,
1426 status: 'migration-planned' ,
1427 via: 'cms' ,
1428 confidence: 'confirmed' ,
1429 basis: 'decision' ,
1430 targetRefs: unionTargetRefs (proposal.targetRefsByEntity),
1431 cmsFallback: {
1432 override: true ,
1433 approvedBy: evaluation.raw.approvedBy,
1434 approvedAt: evaluation.raw.decidedAt || null ,
1435 featureLoss: proposal.featureLoss,
1436 targetRefsByEntity: proposal.targetRefsByEntity,
1437 nativeTargetRefs: proposal.nativeTargetRefs,
1438 proposalDigest,
1439 functionalityRestored: false ,
1440 },
1441 };
1442 }
1443 if (evaluation.state === 'stale' || evaluation.state === 'invalid' ) {
1444 const raw = evaluation.raw && typeof evaluation.raw === 'object' && ! Array. isArray (evaluation.raw) ? evaluation.raw : {};
1445 return {
1446 ... base,
1447 status: 'pending' ,
1448 reason: 'mapping-target-override-stale' ,
1449 searched: [],
1450 targetRefs: [],
1451 mappingTargetOverride: {
1452 ... offer,
1453 staleDecision: { value: raw.value ?? null , decidedAt: raw.decidedAt ?? null },
1454 ... (evaluation.state === 'invalid' ? { invalidDecision: { key: evaluation.key, problem: evaluation.problem } } : {}),
1455 },
1456 };
1457 }
1458 const notices = nativeRouteNotices (row.targetRefs);
1459 return { ... row, mappingTargetOverride: offer, ... (notices. length ? { notices } : {}) };
1460 }
1461
1462 // Step 4 of the recognized-capability precedence: a signed eligibility entry exists and steps
1463 // 1-3 found no native target. Only a valid merchant approval bound to this exact proposal plans
1464 // CMS; everything else stays pending and reports why.
1465 function classifyCmsFallback ({ base , capability , entry , decisions , targetKnowledgeUnavailable }) {
1466 const proposalDigest = cmsFallbackProposalDigest (entry);
1467 const proposedTargetRefsByEntity = canonicalTargetRefsByEntity (entry.targetRefsByEntity);
1468 const evaluation = evaluateCmsFallbackDecision ({ capability, entry, decisions: decisions || {} });
1469 const pending = ( reason , extra = {}) => ({
1470 ... base,
1471 status: 'pending' ,
1472 reason,
1473 searched: [],
1474 targetRefs: [],
1475 cmsFallback: {
1476 eligible: true ,
1477 proposedTargetRefsByEntity,
1478 featureLoss: entry.featureLoss,
1479 nativeAlternativesChecked: entry.nativeAlternativesChecked || null ,
1480 eligibleBy: entry.decidedBy,
1481 eligibleOn: entry.decidedOn,
1482 proposalDigest,
1483 ... extra,
1484 },
1485 });
1486 if (targetKnowledgeUnavailable) return pending ( 'cms-fallback-target-knowledge-unavailable' );
1487 if (evaluation.state === 'approved' ) {
1488 return {
1489 ... base,
1490 status: 'migration-planned' ,
1491 via: 'cms' ,
1492 confidence: 'confirmed' ,
1493 basis: 'decision' ,
1494 targetRefs: unionTargetRefs (entry.targetRefsByEntity),
1495 cmsFallback: {
1496 eligibleBy: entry.decidedBy,
1497 eligibleOn: entry.decidedOn,
1498 approvedBy: evaluation.raw.approvedBy,
1499 approvedAt: evaluation.raw.decidedAt || null ,
1500 featureLoss: entry.featureLoss,
1501 targetRefsByEntity: proposedTargetRefsByEntity,
1502 proposalDigest,
1503 functionalityRestored: false ,
1504 },
1505 };
1506 }
1507 if (evaluation.state === 'declined' ) return pending ( 'cms-fallback-declined' , { decision: 'declined' });
1508 if (evaluation.state === 'stale' || evaluation.state === 'invalid' ) {
1509 const raw = evaluation.raw && typeof evaluation.raw === 'object' && ! Array. isArray (evaluation.raw) ? evaluation.raw : {};
1510 return pending ( 'cms-fallback-decision-stale' , {
1511 staleDecision: { value: raw.value ?? null , decidedAt: raw.decidedAt ?? null },
1512 ... (evaluation.state === 'invalid' ? { invalidDecision: { key: evaluation.key, problem: evaluation.problem } } : {}),
1513 });
1514 }
1515 return pending ( 'cms-fallback-awaiting-approval' );
1516 }
1517
1518 const NO_MIGRATION_REASONS = new Set ([ 'platform-does-it' , 'not-needed' , 'reconfigure-in-wix' ]);
1519
1520 // Accept the legacy `platform-replaced` value so a stale vendored list cannot crash a run;
1521 // the shipped list uses the current vocabulary.
1522 // Lookup keys for an installed plugin id (`dir/main-file`): the folder slug first, then the
1523 // main-file name, so a second copy in another folder (wp-rocket2/wp-rocket) still matches.
1524 // Folder slug first. The main-file name is a fallback only for a renamed copy of the same
1525 // plugin, where the folder starts with it (wp-rocket2/wp-rocket, code-snippets-pro/code-snippets);
1526 // an unrelated plugin whose main file happens to be called "worker" never borrows a register entry.
1527 function registerKeys ( pluginId ) {
1528 const [ dir , file ] = String (pluginId || '' ). split ( '/' );
1529 const keys = [dir];
1530 if (dir && file && file !== dir && dir. startsWith (file)) keys. push (file);
1531 return keys. filter (Boolean);
1532 }
1533
1534 // Slug-keyed registers in one place. Only signed cutover tasks and toolkit entries count:
1535 // an unsigned copy falls through to pending, the safe direction.
1536 function slugRegisters ({ noMigrationNeeded = null , cutoverTasks = null } = {}) {
1537 const maps = { hint: new Map (), core: new Map (), toolkit: new Map (), task: new Map () };
1538 for ( const hint of noMigrationNeeded?.hints || []) if (hint && hint.slug) maps.hint. set (hint.slug, hint);
1539 for ( const entry of noMigrationNeeded?.coreCovered || []) {
1540 if (entry && entry.slug && Array. isArray (entry.coreNamespaces)) maps.core. set (entry.slug, entry);
1541 }
1542 for ( const entry of noMigrationNeeded?.toolkitPlugins || []) {
1543 if (entry && entry.slug && entry.decidedBy && entry.decidedOn) maps.toolkit. set (entry.slug, entry);
1544 }
1545 for ( const entry of cutoverTasks?.tasks || []) {
1546 if (entry && entry.slug && entry.decidedBy && entry.decidedOn && entry.task
1547 && Array. isArray (entry.manualSteps?.steps) && entry.manualSteps.steps. length > 0 ) {
1548 maps.task. set (entry.slug, entry);
1549 }
1550 }
1551 return {
1552 lookup ( kind , keys ) {
1553 for ( const key of keys) if (maps[kind]. has (key)) return maps[kind]. get (key);
1554 return null ;
1555 },
1556 };
1557 }
1558
1559 function readNamespaces ( routes ) {
1560 const namespaces = new Set ();
1561 for ( const route of routes || []) {
1562 const parts = String (route || '' ). replace ( / ^ \/ / , '' ). split ( '/' );
1563 if (parts. length >= 2 && parts[ 0 ] && parts[ 1 ]) namespaces. add ( `${ parts [ 0 ] }/${ parts [ 1 ] }` );
1564 }
1565 return namespaces;
1566 }
1567
1568 // Add `plugin` to the row with this capability, creating it from `template` when absent.
1569 function upsertRow ( rows , template , plugin , capability = template && template.capability) {
1570 let row = rows. find (( candidate ) => candidate.capability === capability);
1571 if ( ! row) {
1572 row = template;
1573 rows. push (row);
1574 }
1575 if (plugin) row.plugins. push (plugin);
1576 return row;
1577 }
1578
1579 function cutoverTaskRow ( task , name ) {
1580 return {
1581 capability: `cutover-task:${ task . slug }` ,
1582 recognized: false ,
1583 basis: 'list' ,
1584 plugins: [],
1585 sourceEntities: [],
1586 recordCounts: {},
1587 channels: [],
1588 channel: null ,
1589 profileVersion: null ,
1590 status: 'manual-mapping' ,
1591 via: 'manual' ,
1592 confidence: 'confirmed' ,
1593 targetRefs: [],
1594 manualSteps: task.manualSteps,
1595 cutoverTask: { workType: task.workType, task: task.task, doneWhen: task.doneWhen },
1596 rationale: [task.does, task.note]. filter (Boolean). join ( ' ' ),
1597 decidedBy: task.decidedBy,
1598 decidedOn: task.decidedOn,
1599 pitfalls: [],
1600 blocked: [],
1601 userImpact: `${ name || task . displayName } holds no data to move. Cutover task: ${ task . task }.${ task . note ? ` ${ task . note }` : ''}` ,
1602 action: task.task,
1603 };
1604 }
1605
1606 function coreCoveredRow ( core , name ) {
1607 return {
1608 capability: `core-covered:${ core . slug }` ,
1609 recognized: false ,
1610 basis: 'core-reader' ,
1611 plugins: [],
1612 sourceEntities: [],
1613 recordCounts: {},
1614 channels: [],
1615 channel: null ,
1616 profileVersion: null ,
1617 status: 'migration-planned' ,
1618 via: 'api' ,
1619 confidence: 'confirmed' ,
1620 targetRefs: [],
1621 rationale: core.does,
1622 pitfalls: [],
1623 blocked: [],
1624 userImpact: `${ name || core . displayName } is read natively (${ core . coreNamespaces . join ( ', ' ) }): ${ core . does } Each core entity has its own row in the mapping.` ,
1625 action: null ,
1626 };
1627 }
1628
1629 function normalizeNoMigrationReason ( value ) {
1630 if ( NO_MIGRATION_REASONS . has (value)) return value;
1631 if (value === 'platform-replaced' ) return 'platform-does-it' ;
1632 return 'not-needed' ;
1633 }
1634
1635 function normalizeToken ( value ) {
1636 return String (value || '' ). toLowerCase (). replace ( / [ ^ a-z0-9] / g , '' );
1637 }
1638
1639 // Identity tokens for an installed plugin: its directory slug and its textdomain. Both are
1640 // conventionally derived from the same name, which is what makes namespace correlation work.
1641 function pluginTokens ( installed ) {
1642 const tokens = new Set ();
1643 const dir = String (installed.plugin || '' ). split ( '/' )[ 0 ];
1644 if (dir) tokens. add ( normalizeToken (dir));
1645 if (installed.textdomain) tokens. add ( normalizeToken (installed.textdomain));
1646 return Array. from (tokens). filter (Boolean);
1647 }
1648
1649 function describeImpact ( row , entities ) {
1650 // A signed "nothing to move" row is not a loss report: whatever its entities' pitfalls say
1651 // about unmovable fields, the decision already accounts for them. The verdict is what the
1652 // customer reads, and the rationale on the row carries the detail.
1653 if (row.status === 'no-need-to-migrate' && row.basis === 'probe' ) return row.rationale;
1654 if (row.status === 'no-need-to-migrate' ) {
1655 return [
1656 row.replacedBy
1657 ? `${ row . capability } needs no data migration: ${ row . replacedBy }.`
1658 : `${ row . capability } needs no data migration.` ,
1659 row.decidedBy ? `Decided by ${ row . decidedBy }${ row . decidedOn ? ` on ${ row . decidedOn }` : ''}.` : null ,
1660 ]. filter (Boolean). join ( ' ' );
1661 }
1662 if (row.cmsFallback && row.cmsFallback.override === true ) {
1663 return `${ row . capability } will be archived as private Wix CMS data, as the operator chose. ${ row . cmsFallback . featureLoss }` ;
1664 }
1665 if (row.reason === 'mapping-target-override-stale' ) {
1666 return `${ row . capability }: the earlier choice to archive these records in Wix CMS no longer matches what is proposed, so it must be asked again. Until then they are not migrated.` ;
1667 }
1668 if (row.cmsFallback) return describeCmsFallbackImpact (row);
1669 if (row.notices && row.notices. length ) return [ describeImpactBase (row, entities), ... row.notices]. join ( ' ' );
1670 return describeImpactBase (row, entities);
1671 }
1672
1673 function describeImpactBase ( row , entities ) {
1674 const blockers = entities. flatMap (( entity ) => entity.pitfalls). filter (( pitfall ) => pitfall.severity === 'blocker' );
1675 if (blockers. length > 0 ) return blockers[ 0 ].summary;
1676 switch (row.status) {
1677 case 'migration-planned' :
1678 return row.via === 'api'
1679 ? `${ row . capability } comes across into a native Wix entity.`
1680 : `${ row . capability } comes across into Wix CMS collections, keeping original record IDs. Wix has no matching feature, so the data is preserved but nothing acts on it until a page is built against it.` ;
1681 case 'manual-mapping' :
1682 return `${ row . capability } is a complete, decided mapping — Wix can do this, but reaching it takes a few steps you click through yourself, not something our code writes for you.` ;
1683 case 'requires-development' :
1684 return `${ row . capability } has no Wix surface today; someone has to build it before this data can move.${ row . decidedBy ? ` Decided by ${ row . decidedBy }.` : ''}` ;
1685 case 'pending' :
1686 return `${ row . capability } is not resolved yet — we do not know how to migrate it. Our open item, pending our review; never a statement that Wix cannot do it.` ;
1687 default :
1688 return `${ row . capability } coverage status: ${ row . status }.` ;
1689 }
1690 }
1691
1692 // CMS archival is never presented as restoring the plugin's working feature: every line names
1693 // what is lost, verbatim from the signed register.
1694 function describeCmsFallbackImpact ( row ) {
1695 const loss = row.cmsFallback.featureLoss;
1696 if (row.status === 'migration-planned' ) {
1697 return `${ row . capability } will be archived as Wix CMS data, as you approved. ${ loss }` ;
1698 }
1699 switch (row.reason) {
1700 case 'cms-fallback-declined' :
1701 return `${ row . capability }: you declined archiving these records in Wix CMS, so they are not migrated.` ;
1702 case 'cms-fallback-target-knowledge-unavailable' :
1703 return `${ row . capability }: Wix target knowledge could not be loaded, so we could not confirm that nothing native fits; these records are not migrated until it loads and you decide.` ;
1704 case 'cms-fallback-decision-stale' :
1705 return `${ row . capability }: the earlier answer about archiving these records in Wix CMS no longer matches what is proposed, so it must be asked again. Until then they are not migrated. ${ loss }` ;
1706 default :
1707 return `${ row . capability }: nothing native in Wix fits, so these records can be archived as Wix CMS data if you approve. ${ loss } Until you decide, they are not migrated.` ;
1708 }
1709 }
1710
1711 function describeAction ( row ) {
1712 if (row.reason === 'mapping-target-override-stale' ) {
1713 return 'At the mapping review, ask again: keep the native Wix target, or archive these records in a private Wix CMS collection.' ;
1714 }
1715 if (row.cmsFallback && row.status === 'pending' ) {
1716 return row.reason === 'cms-fallback-target-knowledge-unavailable'
1717 ? 'Rerun discovery once Wix target knowledge loads; the CMS fallback cannot be applied until then.'
1718 : 'At the mapping review, approve or decline archiving these records in Wix CMS.' ;
1719 }
1720 if (row.status === 'pending' ) {
1721 return 'Tell us if this capability is needed for your migration so we can prioritise resolving it.' ;
1722 }
1723 return null ;
1724 }
1725
1726 function coverageSummary ( rows ) {
1727 const byStatus = {};
1728 const byVia = {};
1729 const byConfidence = {};
1730 let recognized = 0 ;
1731 for ( const row of rows) {
1732 byStatus[row.status] = (byStatus[row.status] || 0 ) + 1 ;
1733 if (row.recognized) recognized += 1 ;
1734 if (row.status === 'migration-planned' ) {
1735 byVia[row.via] = (byVia[row.via] || 0 ) + 1 ;
1736 byConfidence[row.confidence] = (byConfidence[row.confidence] || 0 ) + 1 ;
1737 }
1738 }
1739 return {
1740 capabilities: rows. length ,
1741 recognized,
1742 byStatus: Object. fromEntries (Object. entries (byStatus). sort (([ a ], [ b ]) => a. localeCompare (b))),
1743 byVia: Object. fromEntries (Object. entries (byVia). sort (([ a ], [ b ]) => a. localeCompare (b))),
1744 byConfidence: Object. fromEntries (Object. entries (byConfidence). sort (([ a ], [ b ]) => a. localeCompare (b))),
1745 // The batched blocked-but-recoverable ask (J1 step 9): everything here is asked once,
1746 // individually skippable, and never a mapping decision.
1747 blocked: rows
1748 . filter (( row ) => (row.blocked || []). length > 0 )
1749 . map (( row ) => ({ capability: row.capability, status: row.status, blocked: row.blocked })),
1750 };
1751 }
1752
1753 function summarizeDetection ( inventory ) {
1754 return {
1755 pluginListAvailable: Boolean (inventory.pluginListAvailable),
1756 detected: (inventory.detected || []). map (( plugin ) => ({
1757 plugin: plugin.plugin,
1758 confidence: plugin.confidence,
1759 capabilities: plugin.capabilities,
1760 channels: Array. from ( new Set ((plugin.entities || []). map (( entity ) => entity.channel))). sort (),
1761 })),
1762 unprofiledNamespaces: (inventory.unprofiled || []). map (( entry ) => entry.namespace),
1763 installedButUnprofiledCount: (inventory.installedButUnprofiled || []). length ,
1764 fingerprintedCount: (inventory.fingerprinted || []). length ,
1765 };
1766 }
1767
1768 // Collect record property keys from sampled entities so core-embedded and core-meta plugins
1769 // — the ones that add no REST route at all — are detectable at all. Top-level keys catch
1770 // core-embedded injections (e.g. yoast_head_json). Registered postmeta (core-meta) is a
1771 // different shape: WooCommerce/WordPress expose it as entries inside a `meta_data` array
1772 // (`[{key, value}, ...]`), not as a literal top-level property — several major WooCommerce
1773 // extensions (Meta for WooCommerce's `_wc_facebook_*` keys, Discount Rules' `_wdr_discounts`)
1774 // store their data exactly this way, so meta_data keys are collected into the same set a
1775 // core-meta profile's recordProperties/propertyPath is checked against.
1776 //
1777 // An order's own meta_data is one layer; WooCommerce order LINE ITEMS carry their own,
1778 // nested one layer deeper still (VERIFIED 2026-08-11 against the woo-discount-rules plugin
1779 // source: it writes `_wdr_discounts` to the order item via `setOrderItemMeta`, not just to
1780 // the order). Same failure shape as the order-level case, one layer in — so line_items[]
1781 // meta_data is scanned too, or a per-item-only key would stay invisible to detection.
1782 function collectRecordProperties ( entities ) {
1783 const properties = new Set ();
1784 const collectMetaDataKeys = ( record ) => {
1785 if ( ! record || typeof record !== 'object' || ! Array. isArray (record.meta_data)) return ;
1786 for ( const meta of record.meta_data) {
1787 if (meta && typeof meta.key === 'string' ) properties. add (meta.key);
1788 }
1789 };
1790 for ( const entity of entities || []) {
1791 for ( const record of entity.sampleRecords || []) {
1792 if ( ! record || typeof record !== 'object' ) continue ;
1793 for ( const key of Object. keys (record)) properties. add (key);
1794 collectMetaDataKeys (record);
1795 if (Array. isArray (record.line_items)) {
1796 for ( const lineItem of record.line_items) collectMetaDataKeys (lineItem);
1797 }
1798 }
1799 }
1800 return Array. from (properties). sort ();
1801 }
1802
1803 module . exports = {
1804 COVERAGE_STATUSES,
1805 SIGNAL_CONFIDENCE,
1806 GENERIC_TARGET_REFS,
1807 detectPlugins,
1808 describeProfiledEntity,
1809 collectUnprofiledRoutes,
1810 collectCandidateNamespaces,
1811 deriveGenericEntities,
1812 deriveBridgePlannedEntities,
1813 proposeBridgeEntity,
1814 proposeCapability,
1815 classifyCoverage,
1816 registerKeys,
1817 slugRegisters,
1818 coverageSummary,
1819 summarizeDetection,
1820 collectRecordProperties,
1821 compareVersions,
1822 };