Setting the file. One moment.
Wp Plugin Detect · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page 385
const consider
— line 385
This file
Number 43.14
Position 14 of 46
Type JavaScript
Size 58 KB
Lines 1,270 lib/ wp-plugin-detect.js
JavaScript · 1,270 lines · 58 KB
patternMatchesRoute
}
=
require
(
'./plugin-knowledge.js'
);
15 const { childRouteAdvertised } = require ( './wp-route-classifier.js' );
16
17 const CONFIDENCE_ORDER = { high: 3 , medium: 2 , low: 1 };
18
19 // Signal strength, strongest first.
20 const SIGNAL_CONFIDENCE = {
21 'wp.v2.plugins' : 'high' ,
22 route: 'high' ,
23 'rest-base' : 'medium' ,
24 'record-property' : 'medium' ,
25 namespace: 'low' ,
26 'asset-path' : 'low' ,
27 };
28
29 // The five statuses of the plugin-classification scheme (+ manual-mapping). Every installed plugin lands
30 // in exactly one, every status is a sentence a merchant can act on, and there is no second
31 // internal vocabulary.
32 //
33 // migration-planned - "this comes across", via `api` (native Wix entity) or via `cms`
34 // (kept as data with its original IDs). `confidence` states whether a
35 // human authored the mapping (confirmed) or we derived it (proposed).
36 // manual-mapping - "Wix can do this — here are the exact steps you take yourself".
37 // A complete, decided mapping with no write for our code to make: no
38 // `preferredWrite` call exists, only a `manualSteps` runbook (row.
39 // manualSteps). Unlike `pending`, nothing here is undecided — it needs
40 // no human sign-off gate, only the merchant's own follow-through.
41 // no-need-to-migrate - "nothing to move": Wix already does it, it was never data, or it is
42 // a setting you reconfigure once in Wix. Two sources: the slug list
43 // (`basis: list`, unprofiled plugins) and the human-signed
44 // per-capability register (`basis: decision`, profiled plugins).
45 // pending - "we do not know how to migrate this yet". OUR open item, decided at
46 // the mapping review; never a statement about the source or about Wix.
47 // requires-development - "Wix has no surface for this; build it first". Reachable ONLY from
48 // the human-signed register — automation may never conclude
49 // impossibility. Two agent-made impossibility claims (gift cards,
50 // events) were already wrong; both had a create API.
51 //
52 // Blocked — recoverable is deliberately NOT here: a failed or unavailable read attaches to the
53 // row as `blocked[]` and never becomes a mapping decision (see CHANNEL_BLOCKERS).
54 const COVERAGE_STATUSES = new Set ([
55 'migration-planned' ,
56 'manual-mapping' ,
57 'no-need-to-migrate' ,
58 'pending' ,
59 'requires-development' ,
60 ]);
61
62 // A channel that cannot be read today attaches a blocker to the row: fix and re-run. This is
63 // what keeps a db-only gift-card balance "Migration planned + blocked", not "pending" —
64 // unreadable today, recoverable by an export, and never reported as a limitation of Wix.
65 const CHANNEL_BLOCKERS = {
66 'needs-export-file' : {
67 kind: 'user-file' ,
68 resolution: 'Provide the plugin export file produced on the source site; only you can produce it (Application Passwords never reach wp-admin).' ,
69 },
70 unavailable: {
71 kind: 'user-file' ,
72 resolution: 'The plugin keeps this data in its own database tables; provide a database export or a source-side bridge.' ,
73 },
74 'needs-user-transcription' : {
75 kind: 'user-file' ,
76 resolution: 'The plugin exposes this configuration only in wp-admin; transcribe it or provide screenshots from an authenticated browser session.' ,
77 },
78 'route-absent' : {
79 kind: 'surface-changed' ,
80 resolution: 'The profile declares a route this site does not expose; the profile may be stale or the plugin version differs.' ,
81 },
82 'property-absent' : {
83 kind: 'surface-changed' ,
84 resolution: 'The profile declares an embedded property the sampled records do not carry; the profile may be stale or the plugin version differs.' ,
85 },
86 'api-below-min-version' : {
87 kind: 'surface-changed' ,
88 resolution: 'The installed plugin version predates the REST surface this profile reads; upgrade the plugin or supply an export file.' ,
89 },
90 };
91
92 function blockersForEntities ( entities ) {
93 const blocked = [];
94 for ( const entity of entities) {
95 const declared = (entity.pitfalls || []). flatMap (( pitfall ) => pitfall.blocked || []);
96 if (declared. length > 0 ) {
97 for ( const blocker of declared) {
98 blocked. push ({
99 ... blocker,
100 entity: entity.entity,
101 channel: entity.channel,
102 declined: blocker.declined === true ,
103 });
104 }
105 continue ;
106 }
107 const spec = CHANNEL_BLOCKERS [entity.channelStatus];
108 if ( ! spec) continue ;
109 blocked. push ({
110 kind: spec.kind,
111 entity: entity.entity,
112 channel: entity.channel,
113 resolution: spec.resolution,
114 declined: false ,
115 });
116 }
117 return blocked;
118 }
119
120 const CHANNEL_AVAILABILITY = {
121 'plugin-rest' : 'available' ,
122 'core-cpt' : 'available' ,
123 'core-embedded' : 'available' ,
124 'core-meta' : 'available' ,
125 'plugin-rest-child' : 'available' ,
126 'export-file' : 'needs-export-file' ,
127 'db-only' : 'unavailable' ,
128 'admin-page-only' : 'needs-user-transcription' ,
129 };
130
131 function bestConfidence ( signals ) {
132 let best = null ;
133 for ( const signal of signals) {
134 const kind = String (signal). split ( ':' )[ 0 ];
135 const confidence = SIGNAL_CONFIDENCE [kind] || 'low' ;
136 if ( ! best || CONFIDENCE_ORDER [confidence] > CONFIDENCE_ORDER [best]) best = confidence;
137 }
138 return best || 'low' ;
139 }
140
141 function compareVersions ( a , b ) {
142 const pa = String (a). split ( '.' ). map (( part ) => Number. parseInt (part, 10 ) || 0 );
143 const pb = String (b). split ( '.' ). map (( part ) => Number. parseInt (part, 10 ) || 0 );
144 for ( let i = 0 ; i < Math. max (pa. length , pb. length ); i += 1 ) {
145 const diff = (pa[i] || 0 ) - (pb[i] || 0 );
146 if (diff !== 0 ) return diff < 0 ? - 1 : 1 ;
147 }
148 return 0 ;
149 }
150
151 function routeSet ( restIndex ) {
152 return new Set (Object. keys (restIndex?.routes || {}));
153 }
154
155 function namespaceSet ( restIndex ) {
156 return new Set (Array. isArray (restIndex?.namespaces) ? restIndex.namespaces : []);
157 }
158
159 function restBaseSet ( types , taxonomies ) {
160 const bases = new Set ();
161 for ( const collection of [types, taxonomies]) {
162 for ( const entry of Object. values (collection || {})) {
163 if (entry && entry.rest_base) bases. add (entry.rest_base);
164 if (entry && entry.slug) bases. add (entry.slug);
165 }
166 }
167 return bases;
168 }
169
170 // Plugin file ids look like "the-events-calendar/the-events-calendar.php". The list endpoint
171 // returns them without the ".php" on some versions, so compare on the directory segment too.
172 function pluginFileVariants ( pluginFileId ) {
173 const variants = new Set ([pluginFileId]);
174 variants. add (pluginFileId. replace ( / \. php $ / , '' ));
175 const dir = pluginFileId. split ( '/' )[ 0 ];
176 if (dir) variants. add (dir);
177 return variants;
178 }
179
180 // VERIFIED LIVE 2026-07-30: a plugin's main file basename frequently differs from its
181 // directory — real ids include `wordpress-seo/wp-seo`, `woo-custom-product-addons/start`,
182 // `chaty/cht-icons`, `print-google-cloud-print-gcp-woocommerce/index`. A profile author
183 // writing the conventional `dir/dir.php` guess must still match, so compare the two id
184 // variant SETS (which both include the bare directory) rather than comparing each declared
185 // string against the other's set — the latter never intersects on the directory token.
186 // Plugin directories are unique per install, so directory-level matching cannot collide.
187 function matchInstalled ( profile , installed ) {
188 const declared = profile.detect?.pluginFileIds || [];
189 if (declared. length === 0 ) return null ;
190 const declaredVariants = declared. map (( declaredId ) => pluginFileVariants (declaredId));
191 for ( const entry of installed) {
192 const candidates = pluginFileVariants ( String (entry.plugin || '' ));
193 for ( const variants of declaredVariants) {
194 for ( const variant of variants) {
195 if (candidates. has (variant)) return entry;
196 }
197 }
198 }
199 return null ;
200 }
201
202 function detectPlugins ({
203 profiles = [],
204 restIndex = null ,
205 pluginList = null ,
206 types = null ,
207 taxonomies = null ,
208 htmlSources = [],
209 sampledRecordProperties = [],
210 // The fingerprint alias map ({ aliases: { token: { displayName, slug } } }).
211 fingerprintAliases = null ,
212 } = {}) {
213 const routes = routeSet (restIndex);
214 const rawRoutes = restIndex?.routes || {};
215 const namespaces = namespaceSet (restIndex);
216 const bases = restBaseSet (types, taxonomies);
217 const properties = new Set (sampledRecordProperties);
218 const html = htmlSources. join ( ' \n ' );
219 const installed = Array. isArray (pluginList) ? pluginList : [];
220 const pluginListAvailable = Array. isArray (pluginList);
221
222 const detected = [];
223 const claimedInstalled = new Set ();
224
225 for ( const profile of profiles) {
226 const signals = [];
227 const detect = profile.detect || {};
228
229 const installedEntry = matchInstalled (profile, installed);
230 if (installedEntry) {
231 signals. push ( 'wp.v2.plugins' );
232 claimedInstalled. add (installedEntry.plugin);
233 }
234 for ( const route of detect.routes || []) {
235 if (routes. has (route)) signals. push ( `route:${ route }` );
236 }
237 for ( const entity of profile.entities || []) {
238 if (entity.route && routes. has (entity.route)) signals. push ( `route:${ entity . route }` );
239 }
240 for ( const base of detect.restBases || []) {
241 if (bases. has (base)) signals. push ( `rest-base:${ base }` );
242 }
243 for ( const property of detect.recordProperties || []) {
244 if (properties. has (property)) signals. push ( `record-property:${ property }` );
245 }
246 for ( const entity of profile.entities || []) {
247 if ((entity.channel === 'core-embedded' || entity.channel === 'core-meta' ) && entity.propertyPath && properties. has (entity.propertyPath)) {
248 signals. push ( `record-property:${ entity . propertyPath }` );
249 }
250 }
251 for ( const namespace of detect.restNamespaces || []) {
252 if (namespaces. has (namespace)) signals. push ( `namespace:${ namespace }` );
253 }
254 for ( const slug of detect.assetPathSlugs || []) {
255 if (html. includes ( `/wp-content/plugins/${ slug }/` )) signals. push ( `asset-path:${ slug }` );
256 }
257
258 if (signals. length === 0 ) continue ;
259
260 const uniqueSignals = Array. from ( new Set (signals)). sort ();
261 const version = installedEntry?.version || null ;
262 // A version below the profile's documented REST threshold means the capability is
263 // present but this profile's read contract does not apply. Saying so is more useful
264 // than either claiming a readable entity or dropping the plugin entirely.
265 const apiBelowMinVersion = Boolean (
266 detect.minVersion && version && compareVersions (version, detect.minVersion) < 0 ,
267 );
268
269 detected. push ({
270 plugin: profile.plugin,
271 displayName: profile.displayName,
272 version,
273 active: installedEntry ? installedEntry.status === 'active' : null ,
274 distribution: profile.distribution || 'unknown' ,
275 confidence: bestConfidence (uniqueSignals),
276 signals: uniqueSignals,
277 capabilities: [ ... (profile.capabilities || [])],
278 profileVersion: profile.profileVersion,
279 apiBelowMinVersion,
280 minVersion: detect.minVersion || null ,
281 entities: (profile.entities || []). map (( entity ) => describeProfiledEntity (entity, {
282 routes,
283 rawRoutes,
284 properties,
285 apiBelowMinVersion,
286 })),
287 });
288 }
289
290 const profiledNamespaces = new Set ();
291 const profiledRoutePatterns = [];
292 for ( const profile of profiles) {
293 for ( const namespace of profile.detect?.restNamespaces || []) profiledNamespaces. add (namespace);
294 for ( const entity of profile.entities || []) {
295 if (entity.route) profiledRoutePatterns. push (entity.route);
296 }
297 for ( const rule of profile.dataRoutePatterns || []) profiledRoutePatterns. push (rule.pattern);
298 }
299
300 const installedButUnprofiled = installed
301 . filter (( entry ) => ! claimedInstalled. has (entry.plugin))
302 . map (( entry ) => ({
303 plugin: entry.plugin,
304 name: entry.name || null ,
305 version: entry.version || null ,
306 active: entry.status === 'active' ,
307 textdomain: entry.textdomain || null ,
308 }))
309 . sort (( a , b ) => String (a.plugin). localeCompare ( String (b.plugin)));
310
311 const fingerprinted = collectFingerprints ({
312 namespaces,
313 html,
314 profiles,
315 installed,
316 aliases: fingerprintAliases?.aliases || {},
317 });
318
319 return {
320 pluginListAvailable,
321 detected: detected. sort (( a , b ) => a.plugin. localeCompare (b.plugin)),
322 installedButUnprofiled,
323 fingerprinted,
324 profiledNamespaces: Array. from (profiledNamespaces). sort (),
325 profiledRoutePatterns: profiledRoutePatterns. sort (),
326 };
327 }
328
329 // The fingerprinted tier: public evidence of plugins nothing else claimed.
330 // wp-content/plugins/<slug> asset paths and unrecognized REST namespaces are read and named
331 // through the alias map instead of thrown away. Names only: the tier makes no completeness
332 // claim (the incomplete-list warning stands unchanged) and never feeds route selection — it
333 // is inventory, not scope. Verified against a live WordPress installation: unauthenticated,
334 // dozens of plugins publicly self-announced while the function returned 1 detected + 0 unprofiled.
335 // Platform namespaces that are the host or the commerce platform, not a plugin: WordPress
336 // core, the WordPress.com hosting layer, and WooCommerce's own admin/telemetry surfaces
337 // (VERIFIED LIVE 2026-08-10: a stock WooCommerce store advertises eight wc-* namespaces
338 // that would otherwise read as eight unknown plugins). A row for one of these would tell
339 // the user to worry about their own platform.
340 const CORE_FINGERPRINT_TOKENS = new Set ([
341 'wp' , 'wc' , 'oembed' , 'wp-block-editor' , 'wp-site-health' , 'wp-abilities' , 'mcp' ,
342 'wpcom' , 'wpcomsh' , 'help-center' ,
343 'woocommerce' , 'wc-admin' , 'wc-admin-email' , 'wc-analytics' , 'wc-push-notifications' ,
344 'wc-telemetry' , 'wccom-site' , 'woocommerce-email-editor' ,
345 ]);
346
347 function collectFingerprints ({ namespaces , html , profiles , installed , aliases }) {
348 const claimed = new Set ( CORE_FINGERPRINT_TOKENS );
349 for ( const profile of profiles) {
350 for ( const slug of profile.detect?.assetPathSlugs || []) claimed. add (slug);
351 for ( const namespace of profile.detect?.restNamespaces || []) claimed. add (namespace. split ( '/' )[ 0 ]);
352 claimed. add (profile.plugin);
353 }
354 // A plugin already on the installed list is inventory we hold with certainty; its public
355 // fingerprint adds nothing and must not produce a second row. Match fuzzily, the same way
356 // namespace attribution does — a namespace token is often a shortening of the directory
357 // (acme-shop vs acme-shop-vouchers) — because the tier's claim is precision, and a
358 // duplicate row costs more than a missed name.
359 const installedTokens = new Set ();
360 for ( const entry of installed) {
361 const dir = String (entry.plugin || '' ). split ( '/' )[ 0 ];
362 if (dir) installedTokens. add ( normalizeToken (dir));
363 if (entry.textdomain) installedTokens. add ( normalizeToken (entry.textdomain));
364 }
365 // VERIFIED against live WordPress installations: a fingerprint token is frequently a cryptic internal
366 // namespace (wpjm-internal, fb_api, wc-facebook, mc4wp, zprint, yoast) that fails the fuzzy
367 // token match above even though the alias map already resolves it to an exact installed
368 // slug (wp-job-manager, facebook-for-woocommerce, mailchimp-for-wp,
369 // print-google-cloud-print-gcp-woocommerce, wordpress-seo) — that is the whole reason an
370 // alias exists. Skipping this check produced a second, confusing per-plugin row for five
371 // different already-installed plugins in one real run, each showing both "fingerprinted"
372 // and "installed but unprofiled" text glued together.
373 const matchesInstalled = ( token ) => {
374 const normalized = normalizeToken (token);
375 if ( ! normalized) return false ;
376 const aliasSlug = aliases[token]?.slug;
377 if (aliasSlug && installedTokens. has ( normalizeToken (aliasSlug))) return true ;
378 for ( const installedToken of installedTokens) {
379 if (installedToken. includes (normalized) || normalized. includes (installedToken)) return true ;
380 }
381 return false ;
382 };
383
384 const byToken = new Map ();
385 const consider = ( token , evidence ) => {
386 if ( ! token || claimed. has (token) || matchesInstalled (token)) return ;
387 if ( ! byToken. has (token)) byToken. set (token, { token, evidence: new Set () });
388 byToken. get (token).evidence. add (evidence);
389 };
390
391 for ( const namespace of namespaces) consider (namespace. split ( '/' )[ 0 ], `namespace:${ namespace }` );
392 for ( const match of html. matchAll ( / \/ wp-content \/ plugins \/ ( [a-z0-9_-] + ) \/ / g )) {
393 consider (match[ 1 ], `asset-path:${ match [ 1 ] }` );
394 }
395
396 return Array. from (byToken. values ())
397 . map (( entry ) => {
398 const alias = aliases[entry.token] || null ;
399 return {
400 token: entry.token,
401 evidence: Array. from (entry.evidence). sort (),
402 displayName: alias?.displayName || null ,
403 slug: alias?.slug || entry.token,
404 aliasMatched: Boolean (alias),
405 };
406 })
407 . sort (( a , b ) => a.token. localeCompare (b.token));
408 }
409
410 // Channel status describes only whether the SOURCE exposes this entity. Credential state is
411 // deliberately not encoded here: it is resolved in classifyCoverage against the credentials
412 // actually granted for the run, so granting one later cannot leave a stale entity status.
413 function describeProfiledEntity ( entity , { routes , rawRoutes , properties , apiBelowMinVersion }) {
414 let channelStatus = CHANNEL_AVAILABILITY [entity.channel] || 'unavailable' ;
415
416 if (channelStatus === 'available' ) {
417 // plugin-rest-child's route is a {parentId} template, never a literal index route — it is
418 // checked below, against both the parent collection and the templated child route.
419 if (entity.channel !== 'plugin-rest-child' && entity.route && ! routes. has (entity.route) && ! entity.route. includes ( '*' )) {
420 channelStatus = 'route-absent' ;
421 }
422 if ((entity.channel === 'core-embedded' || entity.channel === 'core-meta' ) && entity.propertyPath && ! properties. has (entity.propertyPath)) {
423 channelStatus = 'property-absent' ;
424 }
425 if (entity.channel === 'plugin-rest-child' ) {
426 // Presence only: the parent collection is in scope AND the site's REST index
427 // advertises a sub-resource shaped like the entity's route template. Whether any real
428 // parent record actually has data there is a live, per-parent question answered later
429 // by wp-discovery.js's representative sample — never assumed here.
430 const parentAvailable = Boolean (entity.parentRoute) && routes. has (entity.parentRoute);
431 const childAdvertised = Boolean (entity.route) && childRouteAdvertised (rawRoutes, entity.route);
432 if ( ! parentAvailable || ! childAdvertised) {
433 channelStatus = 'route-absent' ;
434 }
435 }
436 }
437 if (apiBelowMinVersion && channelStatus === 'available' ) channelStatus = 'api-below-min-version' ;
438
439 return {
440 entity: entity.entity,
441 capability: entity.capability || null ,
442 channel: entity.channel,
443 channelStatus,
444 route: entity.route || null ,
445 parentRoute: entity.parentRoute || null ,
446 embeddedIn: entity.embeddedIn || null ,
447 propertyPath: entity.propertyPath || null ,
448 context: entity.context || null ,
449 requiresParent: entity.requiresParent || null ,
450 hierarchical: entity.hierarchical === true ,
451 // Read-mechanics fields (spec 0044/0045) that rp-import-codegen's sourceMeta contract
452 // depends on — dropping them here would leave the documented contract with nothing to
453 // copy from, since this is the structured (non-prose) per-entity description that
454 // detection writes to disk.
455 requestMethod: entity.requestMethod || null ,
456 requestBody: entity.requestBody || null ,
457 responseFragmentGroupSize: entity.responseFragmentGroupSize || null ,
458 candidateTargetRefs: [ ... (entity.candidateTargetRefs || [])],
459 pitfalls: entity.pitfalls || [],
460 };
461 }
462
463 // Unprofiled REST namespaces, so heuristic acceptance is visible rather than
464 // indistinguishable from known-good coverage.
465 function collectUnprofiledRoutes ({ classifications = [], detection }) {
466 const profiledPatterns = detection?.profiledRoutePatterns || [];
467 const byNamespace = new Map ();
468
469 for ( const classification of classifications) {
470 if (classification.effectiveAction !== 'sample' ) continue ;
471 // Core WordPress and WooCommerce families are covered by the classifier's own
472 // allowlist; they are not "unprofiled plugins". Profile-matched routes are filtered
473 // below by pattern, not by rule id — the generic fallback rule id also begins with
474 // "plugin.", so a prefix test would wrongly hide heuristic acceptances.
475 if ( / ^ (wp | wc) \. / . test (classification.ruleId || '' )) continue ;
476 if (profiledPatterns. some (( pattern ) => patternMatchesRoute (pattern, classification.routePath))) continue ;
477
478 const namespace = classification.namespace || classification.routePath. split ( '/' ). slice ( 1 , 3 ). join ( '/' );
479 if ( ! byNamespace. has (namespace)) {
480 byNamespace. set (namespace, { namespace, routes: [], ruleIds: new Set () });
481 }
482 const entry = byNamespace. get (namespace);
483 entry.routes. push (classification.routePath);
484 entry.ruleIds. add (classification.ruleId);
485 }
486
487 return Array. from (byNamespace. values ())
488 . map (( entry ) => ({
489 namespace: entry.namespace,
490 routes: entry.routes. sort (),
491 ruleIds: Array. from (entry.ruleIds). sort (),
492 read: true ,
493 reason: 'no plugin profile matched; accepted by generic collection-shape classification' ,
494 }))
495 . sort (( a , b ) => a.namespace. localeCompare (b.namespace));
496 }
497
498 // Collection-shaped routes in namespaces we do not recognize that were NOT read. Verified
499 // live: the REST index advertises no per-route schema, so these never reach `sample`. They
500 // are reported rather than sampled so the coverage report can say "this plugin looks like it
501 // holds records we did not read" instead of omitting it.
502 const CORE_NAMESPACE_PREFIXES = [ 'wp/v2' , 'wc/v1' , 'wc/v2' , 'wc/v3' , 'wc/store' , 'oembed/1.0' ];
503
504 function collectCandidateNamespaces ({ classifications = [], candidates = [], detection = null }) {
505 const profiledPatterns = detection?.profiledRoutePatterns || [];
506 const shaped = new Map (candidates. map (( candidate ) => [candidate.routePath, candidate]));
507 const byNamespace = new Map ();
508
509 for ( const classification of classifications) {
510 if (classification.effectiveAction !== 'skip' ) continue ;
511 // Only the "we had no rule for it" bucket. Anything a deliberate exclusion family
512 // caught (admin, runtime, diagnostics, integration…) stays out of scope by design.
513 if (classification.ruleId !== 'unsupported.default' ) continue ;
514 const candidate = shaped. get (classification.routePath);
515 const hasShape = Boolean (candidate?.getEndpoint?.args?.page || candidate?.getEndpoint?.args?.per_page);
516 if ( ! hasShape) continue ;
517 if ( CORE_NAMESPACE_PREFIXES . some (( prefix ) => classification.routePath. startsWith ( `/${ prefix }/` ))) continue ;
518 if (profiledPatterns. some (( pattern ) => patternMatchesRoute (pattern, classification.routePath))) continue ;
519
520 const namespace = classification.namespace || classification.routePath. split ( '/' ). slice ( 1 , 3 ). join ( '/' );
521 if ( ! byNamespace. has (namespace)) byNamespace. set (namespace, { namespace, routes: [] });
522 byNamespace. get (namespace).routes. push (classification.routePath);
523 }
524
525 return Array. from (byNamespace. values ())
526 . map (( entry ) => ({
527 namespace: entry.namespace,
528 routes: entry.routes. sort (),
529 read: false ,
530 reason: 'collection-shaped routes in an unrecognized namespace; not read by default' ,
531 }))
532 . sort (( a , b ) => a.namespace. localeCompare (b.namespace));
533 }
534
535 // Tier B: derive entities for unprofiled, REST-visible plugin post types and taxonomies.
536 // Built from /wp/v2/types and /wp/v2/taxonomies, which is what makes broad coverage the
537 // default rather than a per-plugin achievement.
538 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' ]);
539 const CORE_TAXONOMIES = new Set ([ 'category' , 'post_tag' , 'nav_menu' , 'link_category' , 'post_format' , 'wp_pattern_category' , 'product_cat' , 'product_tag' , 'product_brand' ]);
540
541 const GENERIC_TARGET_REFS = [ 'cms/collection' , 'cms/data-item' ];
542
543 function deriveGenericEntities ({
544 types = null ,
545 taxonomies = null ,
546 classifications = [],
547 detection = null ,
548 sampledByRoute = new Map (),
549 } = {}) {
550 const profiledPatterns = detection?.profiledRoutePatterns || [];
551 const sampledRoutes = new Set (
552 classifications
553 . filter (( classification ) => [ 'sample' , 'metadata' ]. includes (classification.effectiveAction))
554 . map (( classification ) => classification.routePath),
555 );
556 const entities = [];
557
558 const consider = ( kind , key , entry ) => {
559 const restBase = entry?.rest_base || entry?.slug || key;
560 if ( ! restBase) return ;
561 if (entry?.show_in_rest === false ) return ;
562 if (kind === 'post-type' && CORE_POST_TYPES . has (key)) return ;
563 if (kind === 'taxonomy' && CORE_TAXONOMIES . has (key)) return ;
564
565 const namespace = entry?.rest_namespace || 'wp/v2' ;
566 const route = `/${ namespace }/${ restBase }` ;
567 if (profiledPatterns. some (( pattern ) => patternMatchesRoute (pattern, route))) return ;
568 // Scope containment: only routes the 0005 classifier already accepted may be derived.
569 // The generic path must never resurrect an excluded route family.
570 if ( ! sampledRoutes. has (route)) return ;
571
572 const sampled = sampledByRoute. get (route) || null ;
573 entities. push ({
574 entity: restBase,
575 origin: kind === 'taxonomy' ? 'generic-taxonomy' : 'generic-post-type' ,
576 recognized: false ,
577 channel: 'core-cpt' ,
578 route,
579 restBase,
580 sourcePostType: kind === 'post-type' ? key : null ,
581 sourceTaxonomy: kind === 'taxonomy' ? key : null ,
582 displayName: entry?.name || restBase,
583 hierarchical: entry?.hierarchical === true ,
584 attachedToTypes: Array. isArray (entry?.types) ? [ ... entry.types] : [],
585 recordCount: sampled ? sampled.recordCount : null ,
586 inUse: sampled ? sampled.inUse : null ,
587 candidateTargetRefs: [ ... GENERIC_TARGET_REFS ],
588 proposedCapability: proposeCapability (kind, key, restBase, entry),
589 capabilityConfidence: 'proposed' ,
590 notes: buildGenericNotes (kind, entry),
591 });
592 };
593
594 for ( const [ key , entry ] of Object. entries (types || {})) consider ( 'post-type' , key, entry);
595 for ( const [ key , entry ] of Object. entries (taxonomies || {})) consider ( 'taxonomy' , key, entry);
596
597 return entities. sort (( a , b ) => a.route. localeCompare (b.route));
598 }
599
600 // A proposal, never a decision: it is recorded with unverified confidence and reviewed by
601 // the user at the existing mapping-review checkpoint. Native selection still requires a
602 // target-KB match (see classifyCoverage), so a wrong guess here cannot cause a bad write.
603 function proposeCapability ( kind , key , restBase , entry ) {
604 const haystack = [key, restBase, entry?.name, ... (entry?.tags || [])]
605 . filter (Boolean)
606 . join ( ' ' )
607 . toLowerCase ();
608 const rules = [
609 [ /(event | calendar | webinar)/ , 'content.events' ],
610 [ /(course | lesson | lms | quiz)/ , 'content.courses' ],
611 [ /(booking | appointment | reservation)/ , 'commerce.bookings' ],
612 [ /(testimonial | review | rating)/ , 'content.reviews' ],
613 [ /(portfolio | project | gallery | album)/ , 'content.portfolio' ],
614 // Jobs before listings: "jobs_listing" contains both, and the job reading is the
615 // specific one. Rule order is the tie-breaker, so keep specific before generic.
616 [ /(job | vacancy | career | resume)/ , 'content.jobs' ],
617 [ /(listing | directory | property | realestate)/ , 'content.listings' ],
618 [ /(recipe | menu | dish)/ , 'content.recipes' ],
619 [ /(podcast | episode)/ , 'content.podcast' ],
620 [ /(faq | question)/ , 'content.faq' ],
621 [ /(team | staff | person | member-profile)/ , 'content.people' ],
622 [ /(form | entry | submission)/ , 'content.form-submissions' ],
623 [ /(donation | campaign | fundrais)/ , 'commerce.donations' ],
624 [ /(membership | subscriber | plan)/ , 'commerce.memberships' ],
625 [ /(location | store-locator | branch)/ , 'business.locations' ],
626 ];
627 for ( const [ pattern , capability ] of rules) {
628 if (pattern. test (haystack)) return capability;
629 }
630 return kind === 'taxonomy' ? 'content.custom-taxonomy' : 'content.custom-records' ;
631 }
632
633 function buildGenericNotes ( kind , entry ) {
634 const notes = [];
635 if (kind === 'taxonomy' && entry?.hierarchical === true ) {
636 notes. push ( 'Hierarchical source taxonomy: mapping must record a faithfulness-ledger entry if the chosen Wix target is flat.' );
637 }
638 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.' );
639 return notes;
640 }
641
642 // Coverage rows: one per capability, the user-facing answer to "what happens to my plugins".
643 //
644 // Row shape. Common fields: capability,
645 // recognized, basis ('profile' | 'list' | 'proposed' | 'decision'), plugins[], sourceEntities[],
646 // channels[], recordCounts, profileVersion, blocked[], userImpact, action. Status-specific
647 // fields:
648 // migration-planned - via ('api' | 'cms'), confidence ('confirmed' | 'proposed'), targetRefs[]
649 // manual-mapping - via ('manual'), confidence ('confirmed'), targetRefs[], manualSteps
650 // (the rp-target-wix entity's manualSteps object, verbatim)
651 // no-need-to-migrate - reason ('platform-does-it' | 'not-needed' | 'reconfigure-in-wix'),
652 // replacedBy, rationale (required); plus decidedBy/decidedOn when
653 // basis is 'decision' (the signed per-capability register)
654 // pending - reason (including 'cannot-tell'), searched[]
655 // requires-development - decidedBy, decidedOn, evidence[]
656
657 // For manual-mapping: every one of `refs` resolves to a manual-mapping target (never a mix — a
658 // capability with some manual and some automated targets is a KB authoring error, not
659 // something this layer should silently resolve one way or the other). Returns the first
660 // manualSteps object to attach to the row, or null when `refs` is empty or not entirely
661 // manual-mapping.
662 function resolveManualMapping ( refs , targetKnowledge ) {
663 const manualStepsByRef = targetKnowledge && targetKnowledge.manualStepsByRef;
664 if ( ! manualStepsByRef || ! refs || refs. length === 0 ) return null ;
665 const steps = refs. map (( ref ) => manualStepsByRef. get (ref)). filter (Boolean);
666 return steps. length === refs. length ? steps[ 0 ] : null ;
667 }
668
669 function classifyCoverage ({
670 detection = null ,
671 genericEntities = [],
672 unprofiledRoutes = [],
673 targetKnowledge = null ,
674 entityStatsByRoute = new Map (),
675 // The human-signed register (capabilities-without-native-target.json). The ONLY source of
676 // requires-development. Entries carry decidedBy/decidedOn/evidence; a bare Set of capability
677 // strings is also accepted.
678 humanRuledOutCapabilities = new Set (),
679 requiresDevelopmentEntries = [],
680 // Working notes for profiled capabilities we have not placed (capabilities-pending-decision.json).
681 pendingNotes = [],
682 // The no-migration-needed file, both tiers:
683 // hints[] - installed plugins with no data to move at all. A hit here is strong
684 // evidence, not the final answer — the agent confirms it during classification and
685 // records the rationale; the row is marked `basis: list` so an
686 // unconfirmed hit is auditable.
687 // capabilities[] - human-signed per-capability verdicts on PROFILED plugins, the only way
688 // a profiled capability reaches no-need-to-migrate. `basis: decision`.
689 noMigrationNeeded = null ,
690 } = {}) {
691 const rows = [];
692 const knownRefs = targetKnowledge?.knownRefs || new Set ();
693 const capabilityRefs = targetKnowledge?.capabilityRefs || new Map ();
694
695 const ruledOut = new Map ();
696 for ( const entry of requiresDevelopmentEntries) {
697 if (entry && entry.capability) ruledOut. set (entry.capability, entry);
698 }
699 for ( const capability of humanRuledOutCapabilities) {
700 if ( ! ruledOut. has (capability)) ruledOut. set (capability, null );
701 }
702 const pendingByCapability = new Map (
703 pendingNotes. filter (( entry ) => entry && entry.capability). map (( entry ) => [entry.capability, entry]),
704 );
705 const hintsBySlug = new Map ();
706 for ( const hint of noMigrationNeeded?.hints || []) {
707 if (hint && hint.slug) hintsBySlug. set (hint.slug, hint);
708 }
709 // Only a SIGNED entry grants the verdict. An unsigned one is ignored here rather than
710 // trusted, so a stale or hand-edited copy of the register cannot let automation decide a
711 // customer has nothing to move; the validator rejects it in the repo, and the row falls
712 // through to pending at runtime, which is the safe direction.
713 const noNeedByCapability = new Map ();
714 for ( const entry of noMigrationNeeded?.capabilities || []) {
715 if (entry && entry.capability && entry.decidedBy && entry.decidedOn) {
716 noNeedByCapability. set (entry.capability, entry);
717 }
718 }
719
720 const byCapability = new Map ();
721 for ( const plugin of detection?.detected || []) {
722 for ( const capability of plugin.capabilities) {
723 if ( ! byCapability. has (capability)) byCapability. set (capability, []);
724 byCapability. get (capability). push (plugin);
725 }
726 }
727
728 // Recognized capabilities are classified independently. A resolved target is migration-planned
729 // even when its source channel is blocked; an unresolved capability remains pending.
730 for ( const [ capability , plugins ] of byCapability) {
731 const entities = plugins. flatMap (( plugin ) => {
732 const hasAttribution = plugin.entities. some (( entity ) => entity.capability);
733 return hasAttribution
734 ? plugin.entities. filter (( entity ) => entity.capability === capability)
735 : plugin.entities;
736 });
737 const refs = Array. from ( new Set (entities. flatMap (( entity ) => entity.candidateTargetRefs)))
738 . filter (( ref ) => knownRefs. has (ref));
739 const nativeRefs = capabilityRefs. get (capability) || [];
740 const recordCounts = {};
741 for ( const entity of entities) {
742 const stats = entity.route ? entityStatsByRoute. get (entity.route) : null ;
743 if (stats && stats.recordCount !== null ) recordCounts[entity.entity] = stats.recordCount;
744 }
745
746 const channels = Array. from ( new Set (entities. map (( entity ) => entity.channel))). sort ();
747 const base = {
748 capability,
749 recognized: true ,
750 basis: 'profile' ,
751 plugins: plugins. map (( plugin ) => plugin.plugin). sort (),
752 sourceEntities: entities. map (( entity ) => entity.entity). sort (),
753 recordCounts,
754 channels,
755 channel: channels. length === 1 ? channels[ 0 ] : channels. join ( '+' ),
756 profileVersion: Array. from ( new Set (plugins. map (( plugin ) => plugin.profileVersion). filter (Boolean))). join ( '+' ) || null ,
757 pitfalls: entities. flatMap (( entity ) => entity.pitfalls),
758 blocked: blockersForEntities (entities),
759 };
760
761 let row;
762 const verdict = ruledOut. get (capability);
763 const nativeRefsManualSteps = resolveManualMapping (nativeRefs, targetKnowledge);
764 const refsManualSteps = resolveManualMapping (refs, targetKnowledge);
765 if (ruledOut. has (capability)) {
766 // Only reachable from the human-signed register.
767 row = {
768 ... base,
769 status: 'requires-development' ,
770 basis: 'decision' ,
771 decidedBy: verdict?.decidedBy || null ,
772 decidedOn: verdict?.decidedOn || null ,
773 evidence: [ ... (verdict?.evidence || [])],
774 targetRefs: [],
775 };
776 } else if (nativeRefs. length > 0 && nativeRefsManualSteps) {
777 // For manual-mapping: every target ref this capability resolved to is manual-mapping — there is
778 // nothing for our code to call, only a runbook to hand the merchant.
779 row = {
780 ... base,
781 status: 'manual-mapping' ,
782 via: 'manual' ,
783 confidence: 'confirmed' ,
784 targetRefs: [ ... nativeRefs]. sort (),
785 manualSteps: nativeRefsManualSteps,
786 };
787 } else if (nativeRefs. length > 0 ) {
788 // How well the target entity is exercised is our problem, not the customer's: verified
789 // and unverified native targets classify identically.
790 row = { ... base, status: 'migration-planned' , via: 'api' , confidence: 'confirmed' , targetRefs: [ ... nativeRefs]. sort () };
791 } else if (refs. length > 0 && refsManualSteps) {
792 row = {
793 ... base,
794 status: 'manual-mapping' ,
795 via: 'manual' ,
796 confidence: 'confirmed' ,
797 targetRefs: refs. sort (),
798 manualSteps: refsManualSteps,
799 };
800 } else if (refs. length > 0 ) {
801 const via = refs. every (( ref ) => ref. startsWith ( 'cms/' )) ? 'cms' : 'api' ;
802 row = { ... base, status: 'migration-planned' , via, confidence: 'confirmed' , targetRefs: refs. sort () };
803 } else if (noNeedByCapability. has (capability)) {
804 // A human decided this profiled capability has nothing to move. Not impossibility
805 // — the outcome exists in Wix, or was never data — but still a decision about
806 // what the customer does not get, so it is signed and it is persistent: without this
807 // exit the row re-lands as pending on every run and the verdict has nowhere to live.
808 const decision = noNeedByCapability. get (capability);
809 row = {
810 ... base,
811 status: 'no-need-to-migrate' ,
812 basis: 'decision' ,
813 reason: normalizeNoMigrationReason (decision.reason),
814 replacedBy: decision.replacedBy || null ,
815 rationale: decision.rationale || null ,
816 decidedBy: decision.decidedBy,
817 decidedOn: decision.decidedOn,
818 targetRefs: [],
819 // Nothing to move means nothing is blocked. An unreadable channel on a capability
820 // that is not being migrated is not something to ask the user to fix.
821 blocked: [],
822 };
823 } else {
824 // A profiled capability we have not placed. Pending is a statement about OUR knowledge;
825 // the working note carries what we searched and what we suspect, with no authority.
826 const note = pendingByCapability. get (capability);
827 row = {
828 ... base,
829 status: 'pending' ,
830 reason: note?.reason || 'no Wix target resolved for this capability yet' ,
831 searched: note?.searched ? [note.searched] : [],
832 targetRefs: [],
833 };
834 }
835 row.userImpact = describeImpact (row, entities);
836 row.action = describeAction (row);
837 rows. push (row);
838 }
839
840 // Attribute generic entities to an installed-but-unprofiled plugin BEFORE the shape-only
841 // grouping below, and only when its slug/textdomain genuinely prefixes the entity's own
842 // rest_base/slug. Two unrelated plugins that both register "a custom post type" or "a
843 // custom taxonomy" must never end up sharing one row: a shared shape-bucket row attributed
844 // wholesale to whichever plugin matched first previously let Disclaimer Popup (a site
845 // popup) absorb Jetpack Pay's own order/product entities, MailPoet's post type, Npcink
846 // Ad's promotions, and a duplicate of The Events Calendar's organizer/venue entities, while
847 // DineKit absorbed an unrelated knowledge-base taxonomy (verified against a live WordPress installation).
848 // An entity with no confidently-matched owner stays in the shape bucket, unattributed —
849 // visible at the mapping review, credited to no one, which is honest; false attribution is
850 // not.
851 const MIN_OWNER_TOKEN_LENGTH = 4 ;
852 const ownedEntityKeys = new Set ();
853 const attributed = new Set ();
854 for ( const installed of detection?.installedButUnprofiled || []) {
855 const tokens = pluginTokens (installed). filter (( token ) => token. length >= MIN_OWNER_TOKEN_LENGTH );
856 const ownedEntities = genericEntities. filter (( entity ) => {
857 const normalizedEntity = normalizeToken (entity.entity);
858 return tokens. some (( token ) => normalizedEntity. startsWith (token));
859 });
860 if (ownedEntities. length === 0 ) continue ;
861
862 for ( const entity of ownedEntities) ownedEntityKeys. add (entity.entity);
863 const recordCounts = {};
864 for ( const entity of ownedEntities) {
865 if (entity.recordCount !== null ) recordCounts[entity.entity] = entity.recordCount;
866 }
867 const nativeRefs = Array. from ( new Set (ownedEntities. map (( entity ) => entity.proposedCapability)))
868 . flatMap (( capability ) => capabilityRefs. get (capability) || []);
869 const via = nativeRefs. length > 0 ? 'api' : 'cms' ;
870 const names = ownedEntities. map (( entity ) => entity.displayName). join ( ', ' );
871 rows. push ({
872 capability: `derived:${ installed . plugin }` ,
873 recognized: false ,
874 basis: 'proposed' ,
875 plugins: [installed.plugin],
876 sourceEntities: ownedEntities. map (( entity ) => entity.entity). sort (),
877 recordCounts,
878 channels: Array. from ( new Set (ownedEntities. map (( entity ) => entity.channel))). sort (),
879 channel: 'core-cpt' ,
880 profileVersion: null ,
881 status: 'migration-planned' ,
882 via,
883 confidence: 'proposed' ,
884 targetRefs: via === 'api' ? Array. from ( new Set (nativeRefs)). sort () : [ ... GENERIC_TARGET_REFS ],
885 pitfalls: [],
886 blocked: [],
887 hierarchicalEntities: ownedEntities. filter (( entity ) => entity.hierarchical). map (( entity ) => entity.entity),
888 userImpact: via === 'api'
889 ? `${ 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.`
890 : `${ 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.` ,
891 action: null ,
892 });
893 attributed. add (installed.plugin);
894 }
895
896 // Derived (unrecognized but readable) entities with no confidently-matched plugin owner,
897 // grouped by proposed capability so the report stays one row per capability rather than one
898 // row per derived post type. The proposal is matched against the domain KB: a confirmed
899 // match maps via API, the same as a profiled plugin — but as `proposed`, decided at the
900 // mapping review, never by automation alone.
901 const genericByCapability = new Map ();
902 for ( const entity of genericEntities) {
903 if (ownedEntityKeys. has (entity.entity)) continue ;
904 if ( ! genericByCapability. has (entity.proposedCapability)) genericByCapability. set (entity.proposedCapability, []);
905 genericByCapability. get (entity.proposedCapability). push (entity);
906 }
907 for ( const [ capability , group ] of genericByCapability) {
908 const recordCounts = {};
909 for ( const entity of group) {
910 if (entity.recordCount !== null ) recordCounts[entity.entity] = entity.recordCount;
911 }
912 const nativeRefs = capabilityRefs. get (capability) || [];
913 const via = nativeRefs. length > 0 ? 'api' : 'cms' ;
914 const names = group. map (( entity ) => entity.displayName). join ( ', ' );
915 rows. push ({
916 capability,
917 recognized: false ,
918 basis: 'proposed' ,
919 plugins: [],
920 sourceEntities: group. map (( entity ) => entity.entity). sort (),
921 recordCounts,
922 channels: Array. from ( new Set (group. map (( entity ) => entity.channel))). sort (),
923 channel: 'core-cpt' ,
924 profileVersion: null ,
925 status: 'migration-planned' ,
926 via,
927 confidence: 'proposed' ,
928 targetRefs: via === 'api' ? [ ... nativeRefs]. sort () : [ ... GENERIC_TARGET_REFS ],
929 pitfalls: [],
930 blocked: [],
931 hierarchicalEntities: group. filter (( entity ) => entity.hierarchical). map (( entity ) => entity.entity),
932 userImpact: via === 'api'
933 ? `${ 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.`
934 : `${ 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.` ,
935 action: null ,
936 });
937 }
938
939 for ( const unprofiled of unprofiledRoutes) {
940 const read = unprofiled.read !== false ;
941 rows. push (read ? {
942 capability: `unknown:${ unprofiled . namespace }` ,
943 recognized: false ,
944 basis: 'proposed' ,
945 plugins: [],
946 sourceEntities: unprofiled.routes,
947 recordCounts: {},
948 channels: [ 'plugin-rest' ],
949 channel: 'plugin-rest' ,
950 profileVersion: null ,
951 status: 'migration-planned' ,
952 via: 'cms' ,
953 confidence: 'proposed' ,
954 targetRefs: [ ... GENERIC_TARGET_REFS ],
955 pitfalls: [],
956 blocked: [],
957 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.` ,
958 action: null ,
959 } : {
960 // Collection-shaped routes in an unrecognized namespace, deliberately not read (reading
961 // them speculatively pulled in cookie-consent and object-cache config on a real store).
962 // They surface as Pending so silence cannot read as "this plugin has nothing".
963 capability: `unknown:${ unprofiled . namespace }` ,
964 recognized: false ,
965 basis: 'proposed' ,
966 plugins: [],
967 sourceEntities: unprofiled.routes,
968 recordCounts: {},
969 channels: [ 'plugin-rest' ],
970 channel: 'plugin-rest' ,
971 profileVersion: null ,
972 status: 'pending' ,
973 reason: 'collection-shaped routes in an unrecognized namespace; not read by default' ,
974 searched: [],
975 targetRefs: [],
976 pitfalls: [],
977 blocked: [],
978 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.` ,
979 action: 'Confirm whether this plugin holds data you need migrated.' ,
980 });
981 }
982
983 // An installed-but-unprofiled plugin whose namespace IS being read is not unreadable — it is
984 // unrecognized but covered by the generic tier. Attribute it to that row instead of emitting
985 // a second, contradictory row that tells the user their data will not migrate when it will.
986 // Runs unconditionally (not gated on the ownership attribution above): a plugin can genuinely
987 // own both a generic CPT/taxonomy AND a separate unread custom namespace at once (MailPoet:
988 // `mailpoet_email` CPT is owned and readable, `mailpoet/v1`'s 9 routes are a distinct,
989 // unread namespace) — both facts belong on its row, not just whichever attributed first.
990 for ( const installed of detection?.installedButUnprofiled || []) {
991 const tokens = pluginTokens (installed);
992 const namespaceRow = unprofiledRoutes. find (( entry ) => tokens. some (( token ) => normalizeToken (entry.namespace. split ( '/' )[ 0 ]) === token));
993 if (namespaceRow) {
994 const row = rows. find (( candidate ) => candidate.capability === `unknown:${ namespaceRow . namespace }` );
995 if (row) {
996 row.plugins. push (installed.plugin);
997 attributed. add (installed.plugin);
998 }
999 }
1000 }
1001
1002 for ( const installed of detection?.installedButUnprofiled || []) {
1003 if (attributed. has (installed.plugin)) continue ;
1004 const slug = String (installed.plugin || '' ). split ( '/' )[ 0 ];
1005 const hint = hintsBySlug. get (slug);
1006 if (hint) {
1007 // The no-migration-needed list: nothing to move. The row carries its rationale and is
1008 // marked `basis: list`, so the mapping review can see the answer came from the list
1009 // (the agent confirms it there — a list hit alone is evidence, not the decision).
1010 rows. push ({
1011 capability: `no-migration-needed:${ slug }` ,
1012 recognized: false ,
1013 basis: 'list' ,
1014 plugins: [installed.plugin],
1015 sourceEntities: [],
1016 recordCounts: {},
1017 channels: [],
1018 channel: null ,
1019 profileVersion: null ,
1020 status: 'no-need-to-migrate' ,
1021 reason: normalizeNoMigrationReason (hint.reason || hint.disposition),
1022 replacedBy: hint.replacedBy || null ,
1023 rationale: [hint.does, hint.provenance ? `(${ hint . provenance })` : null ]. filter (Boolean). join ( ' ' )
1024 || 'Listed on the no-migration-needed list.' ,
1025 targetRefs: [],
1026 pitfalls: [],
1027 blocked: [],
1028 userImpact: hint.replacedBy || `${ installed . name || slug } needs no data migration: ${ hint . does || 'nothing to move'}.` ,
1029 action: null ,
1030 });
1031 continue ;
1032 }
1033 // "cannot tell": an unrecognized plugin we can read no intent from. This is a
1034 // statement about OUR knowledge — the honest exit that stops classification from becoming
1035 // a silent way to drop data. Decided by a human at the mapping review, the only exit from
1036 // Pending. Deliberately says "could not identify", not "exposes no data": without a
1037 // profile we cannot tell whether it has no readable data or data we failed to attribute.
1038 rows. push ({
1039 capability: `unknown-plugin:${ installed . plugin }` ,
1040 recognized: false ,
1041 basis: 'proposed' ,
1042 plugins: [installed.plugin],
1043 sourceEntities: [],
1044 recordCounts: {},
1045 channels: [],
1046 channel: null ,
1047 profileVersion: null ,
1048 status: 'pending' ,
1049 reason: 'cannot-tell' ,
1050 searched: [],
1051 targetRefs: [],
1052 pitfalls: [],
1053 blocked: [],
1054 userImpact: `${ 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.` ,
1055 action: 'Tell us if this plugin holds data you need; it may need an export file or plugin support added.' ,
1056 });
1057 }
1058
1059 // Fingerprinted plugins — public evidence only, so typically an unauthenticated run
1060 // (detectPlugins already deduplicates against the installed list). Each classifies exactly
1061 // like an installed-but-unrecognized plugin: the no-migration-needed list can
1062 // clear it, otherwise it is Pending — never silently fine, and never a route in scope.
1063 for ( const print of detection?.fingerprinted || []) {
1064 const hint = hintsBySlug. get (print.slug) || hintsBySlug. get (print.token);
1065 const name = print.displayName || print.token;
1066 if (hint) {
1067 rows. push ({
1068 capability: `no-migration-needed:${ print . slug }` ,
1069 recognized: false ,
1070 basis: 'list' ,
1071 plugins: [print.slug],
1072 sourceEntities: [],
1073 recordCounts: {},
1074 channels: [],
1075 channel: null ,
1076 profileVersion: null ,
1077 status: 'no-need-to-migrate' ,
1078 reason: normalizeNoMigrationReason (hint.reason || hint.disposition),
1079 replacedBy: hint.replacedBy || null ,
1080 rationale: [hint.does, hint.provenance ? `(${ hint . provenance })` : null ]. filter (Boolean). join ( ' ' )
1081 || 'Listed on the no-migration-needed list.' ,
1082 targetRefs: [],
1083 pitfalls: [],
1084 blocked: [],
1085 fingerprintEvidence: print.evidence,
1086 userImpact: hint.replacedBy || `${ name } needs no data migration: ${ hint . does || 'nothing to move'}.` ,
1087 action: null ,
1088 });
1089 continue ;
1090 }
1091 rows. push ({
1092 capability: `fingerprinted:${ print . token }` ,
1093 recognized: false ,
1094 basis: 'proposed' ,
1095 plugins: [print.slug],
1096 sourceEntities: [],
1097 recordCounts: {},
1098 channels: [],
1099 channel: null ,
1100 profileVersion: null ,
1101 status: 'pending' ,
1102 reason: 'cannot-tell' ,
1103 searched: [],
1104 targetRefs: [],
1105 pitfalls: [],
1106 blocked: [],
1107 fingerprintEvidence: print.evidence,
1108 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.` ,
1109 action: 'Tell us if this plugin holds data you need; an administrator credential or plugin support may be required.' ,
1110 });
1111 }
1112
1113 for ( const row of rows) row.plugins = Array. from ( new Set (row.plugins)). sort ();
1114 return rows. sort (( a , b ) => a.capability. localeCompare (b.capability));
1115 }
1116
1117 const NO_MIGRATION_REASONS = new Set ([ 'platform-does-it' , 'not-needed' , 'reconfigure-in-wix' ]);
1118
1119 // Accept the legacy `platform-replaced` value so a stale vendored list cannot crash a run;
1120 // the shipped list uses the spec vocabulary.
1121 function normalizeNoMigrationReason ( value ) {
1122 if ( NO_MIGRATION_REASONS . has (value)) return value;
1123 if (value === 'platform-replaced' ) return 'platform-does-it' ;
1124 return 'not-needed' ;
1125 }
1126
1127 function normalizeToken ( value ) {
1128 return String (value || '' ). toLowerCase (). replace ( / [ ^ a-z0-9] / g , '' );
1129 }
1130
1131 // Identity tokens for an installed plugin: its directory slug and its textdomain. Both are
1132 // conventionally derived from the same name, which is what makes namespace correlation work.
1133 function pluginTokens ( installed ) {
1134 const tokens = new Set ();
1135 const dir = String (installed.plugin || '' ). split ( '/' )[ 0 ];
1136 if (dir) tokens. add ( normalizeToken (dir));
1137 if (installed.textdomain) tokens. add ( normalizeToken (installed.textdomain));
1138 return Array. from (tokens). filter (Boolean);
1139 }
1140
1141 function describeImpact ( row , entities ) {
1142 // A signed "nothing to move" row is not a loss report: whatever its entities' pitfalls say
1143 // about unmovable fields, the decision already accounts for them. The verdict is what the
1144 // customer reads, and the rationale on the row carries the detail.
1145 if (row.status === 'no-need-to-migrate' ) {
1146 return [
1147 row.replacedBy
1148 ? `${ row . capability } needs no data migration: ${ row . replacedBy }.`
1149 : `${ row . capability } needs no data migration.` ,
1150 row.decidedBy ? `Decided by ${ row . decidedBy }${ row . decidedOn ? ` on ${ row . decidedOn }` : ''}.` : null ,
1151 ]. filter (Boolean). join ( ' ' );
1152 }
1153 const blockers = entities. flatMap (( entity ) => entity.pitfalls). filter (( pitfall ) => pitfall.severity === 'blocker' );
1154 if (blockers. length > 0 ) return blockers[ 0 ].summary;
1155 switch (row.status) {
1156 case 'migration-planned' :
1157 return row.via === 'api'
1158 ? `${ row . capability } comes across into a native Wix entity.`
1159 : `${ 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.` ;
1160 case 'manual-mapping' :
1161 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.` ;
1162 case 'requires-development' :
1163 return `${ row . capability } has no Wix surface today; someone has to build it before this data can move.${ row . decidedBy ? ` Decided by ${ row . decidedBy }.` : ''}` ;
1164 case 'pending' :
1165 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.` ;
1166 default :
1167 return `${ row . capability } coverage status: ${ row . status }.` ;
1168 }
1169 }
1170
1171 function describeAction ( row ) {
1172 if (row.status === 'pending' ) {
1173 return 'Tell us if this capability is needed for your migration so we can prioritise resolving it.' ;
1174 }
1175 return null ;
1176 }
1177
1178 function coverageSummary ( rows ) {
1179 const byStatus = {};
1180 const byVia = {};
1181 const byConfidence = {};
1182 let recognized = 0 ;
1183 for ( const row of rows) {
1184 byStatus[row.status] = (byStatus[row.status] || 0 ) + 1 ;
1185 if (row.recognized) recognized += 1 ;
1186 if (row.status === 'migration-planned' ) {
1187 byVia[row.via] = (byVia[row.via] || 0 ) + 1 ;
1188 byConfidence[row.confidence] = (byConfidence[row.confidence] || 0 ) + 1 ;
1189 }
1190 }
1191 return {
1192 capabilities: rows. length ,
1193 recognized,
1194 byStatus: Object. fromEntries (Object. entries (byStatus). sort (([ a ], [ b ]) => a. localeCompare (b))),
1195 byVia: Object. fromEntries (Object. entries (byVia). sort (([ a ], [ b ]) => a. localeCompare (b))),
1196 byConfidence: Object. fromEntries (Object. entries (byConfidence). sort (([ a ], [ b ]) => a. localeCompare (b))),
1197 // The batched blocked-but-recoverable ask (J1 step 9): everything here is asked once,
1198 // individually skippable, and never a mapping decision.
1199 blocked: rows
1200 . filter (( row ) => (row.blocked || []). length > 0 )
1201 . map (( row ) => ({ capability: row.capability, status: row.status, blocked: row.blocked })),
1202 };
1203 }
1204
1205 function summarizeDetection ( inventory ) {
1206 return {
1207 pluginListAvailable: Boolean (inventory.pluginListAvailable),
1208 detected: (inventory.detected || []). map (( plugin ) => ({
1209 plugin: plugin.plugin,
1210 confidence: plugin.confidence,
1211 capabilities: plugin.capabilities,
1212 channels: Array. from ( new Set ((plugin.entities || []). map (( entity ) => entity.channel))). sort (),
1213 })),
1214 unprofiledNamespaces: (inventory.unprofiled || []). map (( entry ) => entry.namespace),
1215 installedButUnprofiledCount: (inventory.installedButUnprofiled || []). length ,
1216 fingerprintedCount: (inventory.fingerprinted || []). length ,
1217 };
1218 }
1219
1220 // Collect record property keys from sampled entities so core-embedded and core-meta plugins
1221 // — the ones that add no REST route at all — are detectable at all. Top-level keys catch
1222 // core-embedded injections (e.g. yoast_head_json). Registered postmeta (core-meta) is a
1223 // different shape: WooCommerce/WordPress expose it as entries inside a `meta_data` array
1224 // (`[{key, value}, ...]`), not as a literal top-level property — several major WooCommerce
1225 // extensions (Meta for WooCommerce's `_wc_facebook_*` keys, Discount Rules' `_wdr_discounts`)
1226 // store their data exactly this way, so meta_data keys are collected into the same set a
1227 // core-meta profile's recordProperties/propertyPath is checked against.
1228 //
1229 // An order's own meta_data is one layer; WooCommerce order LINE ITEMS carry their own,
1230 // nested one layer deeper still (VERIFIED 2026-08-11 against the woo-discount-rules plugin
1231 // source: it writes `_wdr_discounts` to the order item via `setOrderItemMeta`, not just to
1232 // the order). Same failure shape as the order-level case, one layer in — so line_items[]
1233 // meta_data is scanned too, or a per-item-only key would stay invisible to detection.
1234 function collectRecordProperties ( entities ) {
1235 const properties = new Set ();
1236 const collectMetaDataKeys = ( record ) => {
1237 if ( ! record || typeof record !== 'object' || ! Array. isArray (record.meta_data)) return ;
1238 for ( const meta of record.meta_data) {
1239 if (meta && typeof meta.key === 'string' ) properties. add (meta.key);
1240 }
1241 };
1242 for ( const entity of entities || []) {
1243 for ( const record of entity.sampleRecords || []) {
1244 if ( ! record || typeof record !== 'object' ) continue ;
1245 for ( const key of Object. keys (record)) properties. add (key);
1246 collectMetaDataKeys (record);
1247 if (Array. isArray (record.line_items)) {
1248 for ( const lineItem of record.line_items) collectMetaDataKeys (lineItem);
1249 }
1250 }
1251 }
1252 return Array. from (properties). sort ();
1253 }
1254
1255 module . exports = {
1256 COVERAGE_STATUSES,
1257 SIGNAL_CONFIDENCE,
1258 GENERIC_TARGET_REFS,
1259 detectPlugins,
1260 describeProfiledEntity,
1261 collectUnprofiledRoutes,
1262 collectCandidateNamespaces,
1263 deriveGenericEntities,
1264 proposeCapability,
1265 classifyCoverage,
1266 coverageSummary,
1267 summarizeDetection,
1268 collectRecordProperties,
1269 compareVersions,
1270 };