Setting the file. One moment.
Plugin Knowledge · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page · lib
This file
Number 39.22
Position 22 of 92
Type JavaScript
Size 67 KB
Lines 1,315 lib/ plugin-knowledge.js
JavaScript · 1,315 lines · 67 KB
const
fs
=
require
(
'node:fs'
);
15 const path = require ( 'node:path' );
16
17 const CHANNELS = new Set ([
18 'plugin-rest' ,
19 'core-cpt' ,
20 'core-embedded' ,
21 'core-meta' ,
22 'plugin-rest-child' ,
23 'export-file' ,
24 'db-only' ,
25 'admin-page-only' ,
26 ]);
27 const ROUTE_CHANNELS = new Set ([ 'plugin-rest' , 'core-cpt' ]);
28 // A {parentId} placeholder in `route` — never a literal REST index route, so
29 // plugin-rest-child stays out of ROUTE_CHANNELS (which feeds classifier data rules matched
30 // against literal candidate routes; a templated route never appears as one).
31 const CHILD_ROUTE_PLACEHOLDER = '{parentId}' ;
32 const DISTRIBUTIONS = new Set ([ 'wordpress-org' , 'premium-or-unlisted' , 'unknown' ]);
33 const SEVERITIES = new Set ([ 'blocker' , 'warning' , 'info' ]);
34 const CONTEXTS = new Set ([ 'view' , 'edit' , 'both' ]);
35 const REQUEST_METHODS = new Set ([ 'GET' , 'POST' ]);
36 const BLOCKED_KINDS = new Set ([ 'user-file' , 'structure-bridge-plugin' ]);
37 const FULFILLMENT_KINDS = new Set ([ 'csv-upload' , 'structure-bridge-plugin' ]);
38 const SAMPLE_AGGREGATE_ENUM = new Set ([ 'sum' , 'count' , 'avg' , 'min' , 'max' ]);
39 const SAMPLE_DIRECTION_ENUM = new Set ([ 'asc' , 'desc' ]);
40 // Mirrors plugins/wix-wp-plugin-v2/schemas/structure-request.v1.schema.json's `where[].op`
41 // enum, the live plugin's own authoritative wire shape -- kept in sync by hand since this
42 // repo and that plugin ship independently (see plugins/wix-wp-plugin-v2/README.md).
43 const SAMPLE_WHERE_OP_ENUM = new Set ([ 'eq' , 'neq' , 'gt' , 'gte' , 'lt' , 'lte' , 'in' ]);
44 // WordPress core tables reachable through wix-wp-plugin-v2 (not on its always-denied list)
45 // but shared across every post type/taxonomy/comment type on the site -- a plugin only owns
46 // a SUBSET of rows, distinguished by a discriminator column (post_type, taxonomy,
47 // comment_type). Unlike a plugin's own dedicated table, there is no safe "select every
48 // column, no filter" default here. Meta tables (postmeta/usermeta/commentmeta) and `users`
49 // are deliberately excluded: the meta tables are already unconditionally denied by
50 // wix-wp-plugin-v2's own core-table denylist regardless of what a profile declares, and
51 // `users` rows are not a mix of unrelated content types the way posts/terms/comments are.
52 const { SHARED_CORE_TABLES_REQUIRING_WHERE } = require ( './wix-wp-plugin-v2-client.js' );
53
54 const RESERVED_FILES = new Set ([
55 'schema.json' ,
56 'index.json' ,
57 'requires-development.json' ,
58 'capabilities-pending-decision.json' ,
59 'no-migration-needed.json' ,
60 'fingerprint-aliases.json' ,
61 'cms-fallback-eligible.json' ,
62 'cutover-task-only.json' ,
63 ]);
64 // Cutover work types (rp-execute-import SKILL.md, "Cutover coverage and general task types").
65 const CUTOVER_WORK_TYPES = new Set ([ 'manual-setup' , 'missing-apps' , 'changed-behavior' , 'checks' , 'review' ]);
66 // Same actors as an rp-target-wix manual-mapping runbook (domain-knowledge.js MANUAL_STEP_ACTORS).
67 const CUTOVER_STEP_ACTORS = new Set ([ 'merchant' , 'external' , 'wix-automatable' ]);
68 // Coupon meta rules a profile may declare. Each meaning has one fixed mapper action in
69 // lib/coupon-discovery.js; the list is closed so a new coupon plugin needs data, never code.
70 const COUPON_RULE_MEANINGS = new Set ([ 'start-date' , 'same-as-core' , 'no-effect' , 'restriction' , 'gift' , 'auto-apply' ]);
71 const COUPON_RULE_CORE_FIELDS = new Set ([ 'product_ids' , 'product_categories' , 'excluded_product_ids' , 'excluded_product_categories' ]);
72 const COUPON_RULE_SHAPES = new Set ([ 'id-map-min-max' , 'id-list' ]);
73 const NO_MIGRATION_REASONS = new Set ([ 'platform-does-it' , 'not-needed' , 'reconfigure-in-wix' ]);
74 // Namespaces every WordPress or WooCommerce site advertises. Using one as a detection
75 // signal makes a profile match every such site, which is a false positive, not a detection.
76 const SHARED_NAMESPACES = new Set ([ 'wp/v2' , 'wc/v3' , 'wc/v2' , 'wc/v1' , 'wc/store' , 'wc/store/v1' , 'oembed/1.0' ]);
77
78 function pluginsRoot ( rootDir = path. resolve (__dirname, '..' )) {
79 return path. join (rootDir, 'plugins' );
80 }
81
82 function readJson ( filePath ) {
83 return JSON . parse (fs. readFileSync (filePath, 'utf8' ));
84 }
85
86 function writeJson ( filePath , value ) {
87 fs. writeFileSync (filePath, `${ JSON . stringify ( value , null , 2 ) } \n ` );
88 }
89
90 function listProfileFiles ( pluginsDir ) {
91 if ( ! fs. existsSync (pluginsDir)) return [];
92 return fs
93 . readdirSync (pluginsDir, { withFileTypes: true })
94 . filter (( entry ) => entry. isFile () && entry.name. endsWith ( '.json' ) && ! RESERVED_FILES . has (entry.name))
95 . map (( entry ) => entry.name)
96 . sort ();
97 }
98
99 function loadProfiles ( pluginsDir ) {
100 return listProfileFiles (pluginsDir). map (( fileName ) => readJson (path. join (pluginsDir, fileName)));
101 }
102
103 // The no-migration-needed file: plugins with nothing to move. Two
104 // tiers live here — `hints[]`, the cheap slug lookup for plugins that never get a profile,
105 // and `capabilities[]`, the human-signed per-capability register below.
106 function loadNoMigrationNeeded ( pluginsDir ) {
107 const filePath = path. join (pluginsDir, 'no-migration-needed.json' );
108 if ( ! fs. existsSync (filePath)) return { hints: [], capabilities: [] };
109 const file = readJson (filePath);
110 return { hints: [], capabilities: [], ... file };
111 }
112
113 // The signed no-need-to-migrate register: per-CAPABILITY verdicts on PROFILED plugins, where
114 // the slug list cannot reach (a slug with a profile is rejected there, and rightly — the two
115 // would disagree). "Nothing to move" is not impossibility, but it is still a verdict about
116 // what the customer loses, so it is human-signed exactly like requires-development: without
117 // it, a decided capability re-lands as pending on every run and the decision has no home.
118 function loadNoMigrationCapabilities ( pluginsDir ) {
119 return loadNoMigrationNeeded (pluginsDir).capabilities || [];
120 }
121
122 function loadPendingDecisions ( pluginsDir ) {
123 const filePath = path. join (pluginsDir, 'capabilities-pending-decision.json' );
124 if ( ! fs. existsSync (filePath)) return { capabilities: [] };
125 return readJson (filePath);
126 }
127
128 // The Requires development register: human-signed verdicts only.
129 function loadRequiresDevelopment ( pluginsDir ) {
130 const filePath = path. join (pluginsDir, 'requires-development.json' );
131 if ( ! fs. existsSync (filePath)) return { capabilities: [] };
132 return readJson (filePath);
133 }
134
135 // The CMS-fallback eligibility register: human-signed statements that nothing native fits a
136 // recognized capability, so CMS is an acceptable place to keep its records, and what the
137 // merchant loses. Eligibility alone never plans a write — each migration still needs the
138 // merchant's own approval in orchestration/decisions.json (`pluginCmsFallback:<capability>`).
139 function loadCmsFallbackEligible ( pluginsDir ) {
140 const filePath = path. join (pluginsDir, 'cms-fallback-eligible.json' );
141 if ( ! fs. existsSync (filePath)) return { capabilities: [] };
142 return { capabilities: [], ... readJson (filePath) };
143 }
144
145 // The cutover-task-only register: plugins with no data to move whose function the
146 // owner must redo on Wix. Each entry becomes a named Cutover Operator task, not "pending".
147 function loadCutoverTasks ( pluginsDir ) {
148 const filePath = path. join (pluginsDir, 'cutover-task-only.json' );
149 if ( ! fs. existsSync (filePath)) return { tasks: [] };
150 return { tasks: [], ... readJson (filePath) };
151 }
152
153 // The fingerprint alias map: public evidence token -> plugin name. Names only —
154 // no entities, no channels, no target refs.
155 function loadFingerprintAliases ( pluginsDir ) {
156 const filePath = path. join (pluginsDir, 'fingerprint-aliases.json' );
157 if ( ! fs. existsSync (filePath)) return { aliases: {} };
158 return readJson (filePath);
159 }
160
161 // A wildcard route pattern must keep at least two concrete leading segments. This is what
162 // stops a profile from writing "/yoast/*" and silently reopening a whole excluded route
163 // family, while still allowing the legitimate "/ssp/v1/*" namespace-wide data case.
164 function wildcardKeepsScope ( pattern ) {
165 if ( ! pattern. includes ( '*' )) return true ;
166 const concrete = pattern. slice ( 0 , pattern. indexOf ( '*' )). split ( '/' ). filter (Boolean);
167 return concrete. length >= 2 ;
168 }
169
170 function entityRouteOf ( entity ) {
171 return ROUTE_CHANNELS . has (entity.channel) ? entity.route : null ;
172 }
173
174 function generateIndex ( pluginsDir ) {
175 const index = {
176 schemaVersion: 1 ,
177 plugins: {},
178 routeIndex: {},
179 namespaceIndex: {},
180 restBaseIndex: {},
181 recordPropertyIndex: {},
182 capabilityIndex: {},
183 channels: {},
184 };
185
186 for ( const fileName of listProfileFiles (pluginsDir)) {
187 const profile = readJson (path. join (pluginsDir, fileName));
188 const slug = fileName. replace ( / \. json $ / , '' );
189
190 index.plugins[slug] = {
191 path: `plugins/${ fileName }` ,
192 displayName: profile.displayName,
193 profileVersion: profile.profileVersion,
194 distribution: profile.distribution || 'unknown' ,
195 capabilities: [ ... (profile.capabilities || [])]. sort (),
196 entityCount: (profile.entities || []). length ,
197 channels: Array. from ( new Set ((profile.entities || []). map (( entity ) => entity.channel))). sort (),
198 requiresCredentials: (profile.credentials || []). length > 0 ,
199 };
200
201 const push = ( collection , key , value ) => {
202 if ( ! key) return ;
203 if ( ! collection[key]) collection[key] = [];
204 collection[key]. push (value);
205 };
206
207 for ( const route of profile.detect?.routes || []) push (index.routeIndex, route, slug);
208 for ( const entity of profile.entities || []) {
209 push (index.routeIndex, entityRouteOf (entity), slug);
210 push (index.channels, entity.channel, slug);
211 for ( const property of entity.channel === 'core-embedded' && entity.propertyPath ? [entity.propertyPath] : []) {
212 push (index.recordPropertyIndex, property, slug);
213 }
214 }
215 for ( const pattern of (profile.dataRoutePatterns || []). map (( rule ) => rule.pattern)) {
216 push (index.routeIndex, pattern, slug);
217 }
218 for ( const namespace of profile.detect?.restNamespaces || []) push (index.namespaceIndex, namespace, slug);
219 for ( const restBase of profile.detect?.restBases || []) push (index.restBaseIndex, restBase, slug);
220 for ( const property of profile.detect?.recordProperties || []) push (index.recordPropertyIndex, property, slug);
221 for ( const capability of profile.capabilities || []) push (index.capabilityIndex, capability, slug);
222 }
223
224 for ( const collection of [
225 index.routeIndex,
226 index.namespaceIndex,
227 index.restBaseIndex,
228 index.recordPropertyIndex,
229 index.capabilityIndex,
230 index.channels,
231 ]) {
232 for ( const key of Object. keys (collection)) {
233 collection[key] = Array. from ( new Set (collection[key])). sort ();
234 }
235 }
236
237 return index;
238 }
239
240 function requireFields ( value , fields , label , errors ) {
241 for ( const field of fields) {
242 if (value[field] === undefined ) errors. push ( `${ label }: missing required field ${ field }` );
243 }
244 }
245
246 function validateEntity ( entity , label , errors , seenEntities ) {
247 requireFields (entity, [ 'entity' , 'channel' , 'candidateTargetRefs' ], label, errors);
248 if ( ! CHANNELS . has (entity.channel)) {
249 errors. push ( `${ label }: invalid channel ${ entity . channel }` );
250 }
251 if (entity.entity) {
252 if (seenEntities. has (entity.entity)) errors. push ( `${ label }: duplicate entity id ${ entity . entity }` );
253 seenEntities. add (entity.entity);
254 }
255 if ( ROUTE_CHANNELS . has (entity.channel)) {
256 if ( ! entity.route) {
257 errors. push ( `${ label }: channel ${ entity . channel } requires route` );
258 } else if ( ! entity.route. startsWith ( '/' )) {
259 errors. push ( `${ label }: route must start with /` );
260 } else if ( ! wildcardKeepsScope (entity.route)) {
261 errors. push ( `${ label }: route pattern ${ entity . route } is too broad; keep at least two concrete leading segments before a wildcard` );
262 }
263 }
264 if (entity.channel === 'core-embedded' ) {
265 if ( ! Array. isArray (entity.embeddedIn) || entity.embeddedIn. length === 0 ) {
266 errors. push ( `${ label }: core-embedded requires a non-empty embeddedIn` );
267 }
268 if ( ! entity.propertyPath) errors. push ( `${ label }: core-embedded requires propertyPath` );
269 }
270 if (entity.channel === 'core-meta' && ! entity.propertyPath) {
271 errors. push ( `${ label }: core-meta requires propertyPath` );
272 }
273 if (entity.channel === 'plugin-rest-child' ) {
274 if ( ! entity.route || ! entity.route. startsWith ( '/' )) {
275 errors. push ( `${ label }: plugin-rest-child requires route` );
276 } else if ( ! entity.route. includes ( CHILD_ROUTE_PLACEHOLDER )) {
277 errors. push ( `${ label }: plugin-rest-child route must contain a ${ CHILD_ROUTE_PLACEHOLDER } placeholder, e.g. /wc/v3/orders/${ CHILD_ROUTE_PLACEHOLDER }/notes` );
278 }
279 if ( ! entity.parentRoute) {
280 errors. push ( `${ label }: plugin-rest-child requires parentRoute — the already-sampled collection route that supplies parent ids (e.g. /wc/v3/orders)` );
281 } else if ( ! entity.parentRoute. startsWith ( '/' ) || entity.parentRoute. includes ( CHILD_ROUTE_PLACEHOLDER )) {
282 errors. push ( `${ label }: parentRoute must be a literal collection route, not a template` );
283 }
284 }
285 if (entity.responseEnvelope !== undefined ) {
286 if ( ! ROUTE_CHANNELS . has (entity.channel)) {
287 errors. push ( `${ label }: responseEnvelope only applies to route-bearing channels (${ [ ... ROUTE_CHANNELS ]. join ( ', ' ) })` );
288 }
289 const itemsPath = entity.responseEnvelope && entity.responseEnvelope.itemsPath;
290 if ( typeof itemsPath !== 'string' || itemsPath. length === 0 ) {
291 errors. push ( `${ label }: responseEnvelope requires itemsPath — the dot-path to the array of records within the response body, e.g. "data.items"` );
292 }
293 const countPath = entity.responseEnvelope && entity.responseEnvelope.countPath;
294 if (countPath !== undefined && ( typeof countPath !== 'string' || countPath. length === 0 )) {
295 errors. push ( `${ label }: responseEnvelope.countPath must be a non-empty string dot-path when present` );
296 }
297 }
298 if (entity.responseFragmentGroupSize !== undefined ) {
299 if ( ! ROUTE_CHANNELS . has (entity.channel)) {
300 errors. push ( `${ label }: responseFragmentGroupSize only applies to route-bearing channels (${ [ ... ROUTE_CHANNELS ]. join ( ', ' ) })` );
301 }
302 if ( ! Number. isInteger (entity.responseFragmentGroupSize) || entity.responseFragmentGroupSize < 2 ) {
303 errors. push ( `${ label }: responseFragmentGroupSize must be an integer >= 2 — the number of flat array entries that reassemble into one record` );
304 }
305 }
306 if (entity.requestMethod !== undefined || entity.requestBody !== undefined ) {
307 if ( ! ROUTE_CHANNELS . has (entity.channel)) {
308 errors. push ( `${ label }: requestMethod/requestBody only apply to route-bearing channels (${ [ ... ROUTE_CHANNELS ]. join ( ', ' ) })` );
309 }
310 if (entity.requestMethod !== undefined && ! REQUEST_METHODS . has (entity.requestMethod)) {
311 errors. push ( `${ label }: invalid requestMethod ${ entity . requestMethod } (expected one of ${ [ ... REQUEST_METHODS ]. join ( ', ' ) })` );
312 }
313 if (entity.requestMethod === 'GET' && entity.requestBody !== undefined ) {
314 errors. push ( `${ label }: requestBody is not meaningful with requestMethod GET` );
315 }
316 if (entity.requestMethod && entity.requestMethod !== 'GET' && entity.requestBody === undefined ) {
317 errors. push ( `${ label }: requestMethod ${ entity . requestMethod } requires requestBody` );
318 }
319 if (entity.requestBody !== undefined && entity.requestMethod === undefined ) {
320 errors. push ( `${ label }: requestBody requires an explicit non-GET requestMethod — a body with no declared method is not a real request override` );
321 }
322 if (entity.requestBody !== undefined && ( typeof entity.requestBody !== 'object' || entity.requestBody === null || Array. isArray (entity.requestBody))) {
323 errors. push ( `${ label }: requestBody must be a plain JSON object` );
324 }
325 }
326 if (entity.context !== undefined && ! CONTEXTS . has (entity.context)) {
327 errors. push ( `${ label }: invalid context ${ entity . context }` );
328 }
329 if (Array. isArray (entity.candidateTargetRefs)) {
330 // CMS is never a profile-declared target: it would ship as a confirmed CMS plan with no
331 // merchant decision. A capability with nothing native gets a signed entry in
332 // cms-fallback-eligible.json, and each migration's merchant approves or declines it.
333 for ( const ref of entity.candidateTargetRefs) {
334 if ( typeof ref === 'string' && ref. startsWith ( 'cms/' )) {
335 errors. push ( `${ label }: entity ${ entity . entity } must not name the CMS ref ${ ref } in candidateTargetRefs — CMS is only an eligible fallback (plugins/cms-fallback-eligible.json) that the merchant approves per migration` );
336 }
337 }
338 }
339 if ( ! Array. isArray (entity.candidateTargetRefs)) {
340 errors. push ( `${ label }: candidateTargetRefs must be an array` );
341 } else if (entity.candidateTargetRefs. length === 0 && (entity.pitfalls || []). length === 0 ) {
342 errors. push ( `${ label }: an entity with no candidateTargetRefs must record a pitfall explaining the gap` );
343 }
344 for ( const pitfall of entity.pitfalls || []) {
345 if ( ! SEVERITIES . has (pitfall.severity)) errors. push ( `${ label }: invalid pitfall severity ${ pitfall . severity }` );
346 if ( ! pitfall.code || ! pitfall.summary) errors. push ( `${ label }: pitfalls need code and summary` );
347 }
348 // Redirect-map storage (format, bare tables) lives with the provider code that reads it.
349 require ( './wp-redirect-sources.js' ). validateRedirectSource (entity, label, errors);
350 validateBlockedEntries (entity.blocked, label, errors);
351 for ( const [ index , pitfall ] of (entity.pitfalls || []). entries ()) {
352 validateBlockedEntries (pitfall.blocked, `${ label } pitfalls[${ index }]` , errors);
353 }
354 }
355
356 function validateBlockedEntries ( blocked , label , errors ) {
357 if (blocked === undefined ) return ;
358 if ( ! Array. isArray (blocked) || blocked. length === 0 ) {
359 errors. push ( `${ label }: blocked must be a non-empty array when present` );
360 return ;
361 }
362 blocked. forEach (( entry , index ) => {
363 const entryLabel = `${ label } blocked[${ index }]` ;
364 if ( ! entry || typeof entry !== 'object' || Array. isArray (entry)) {
365 errors. push ( `${ entryLabel } must be an object` );
366 return ;
367 }
368 if ( ! BLOCKED_KINDS . has (entry.kind)) errors. push ( `${ entryLabel }.kind must be one of ${ [ ... BLOCKED_KINDS ]. join ( ', ' ) }` );
369 if ( ! entry.resolution || typeof entry.resolution !== 'string' ) errors. push ( `${ entryLabel }.resolution must be a non-empty string` );
370 if ( typeof entry.declined !== 'boolean' ) errors. push ( `${ entryLabel }.declined must be boolean` );
371 if (entry.fulfillment === undefined ) return ;
372 const fulfillment = entry.fulfillment;
373 if ( ! fulfillment || typeof fulfillment !== 'object' || Array. isArray (fulfillment)) {
374 errors. push ( `${ entryLabel }.fulfillment must be an object` );
375 return ;
376 }
377 if ( ! FULFILLMENT_KINDS . has (fulfillment.kind)) errors. push ( `${ entryLabel }.fulfillment.kind must be csv-upload or structure-bridge-plugin` );
378 if ( ! fulfillment.handlerId || typeof fulfillment.handlerId !== 'string' ) errors. push ( `${ entryLabel }.fulfillment.handlerId must be a non-empty string` );
379 if (fulfillment.freshnessWindowHours !== undefined && ( ! Number. isFinite (fulfillment.freshnessWindowHours) || fulfillment.freshnessWindowHours < 0 )) {
380 errors. push ( `${ entryLabel }.fulfillment.freshnessWindowHours must be a non-negative number` );
381 }
382 if (fulfillment.kind === 'csv-upload' && ( ! fulfillment.expectedInputPath || typeof fulfillment.expectedInputPath !== 'string' )) {
383 errors. push ( `${ entryLabel }.fulfillment.expectedInputPath is required for csv-upload` );
384 }
385 // structure-bridge-plugin (wix-wp-plugin-v2) has no manifest of its own to
386 // cross-check -- authorization is a live core-table denylist + DESCRIBE check at request
387 // time, not a build-time readiness flag. The only thing a fulfillment must declare is
388 // which bare table to read; everything else is decided live by the plugin itself.
389 if (fulfillment.kind === 'structure-bridge-plugin' ) {
390 if ( ! fulfillment.table || typeof fulfillment.table !== 'string' || ! / ^ [A-Za-z0-9_] +$ / . test (fulfillment.table)) {
391 errors. push ( `${ entryLabel }.fulfillment.table is required for structure-bridge-plugin and must be a bare identifier` );
392 }
393 // Optional: the safe sample query for discovery-time capture. Omitted
394 // means "select every discovered column, limit-N, ordered by the first live key" is
395 // safe -- declared means the omitted default is NOT safe for this table (e.g. a raw
396 // row dump would expose a full transaction ledger) and this is the query discovery
397 // must run instead. Validated against wix-wp-plugin-v2's own structureRequest shape
398 // so a malformed declaration is caught here, not only when discovery actually runs.
399 if (fulfillment.sampleStructureRequest !== undefined ) {
400 validateSampleStructureRequest (fulfillment.sampleStructureRequest, `${ entryLabel }.fulfillment.sampleStructureRequest` , errors);
401 }
402 // A shared WordPress core table (not this plugin's own dedicated table) holds every
403 // plugin's/feature's rows mixed together, distinguished only by a discriminator
404 // column (post_type on `posts`, taxonomy on `term_taxonomy`, etc.). wp-discovery's
405 // no-declared-sampleStructureRequest default is "select every column, no where" --
406 // safe for a dedicated table, but a silent full-table dump of every post type/term/
407 // comment on the site for one whose default was left undeclared against `posts`
408 // (the dump covers products/pages/attachments/etc.,
409 // not just the plugin's own custom-post-type rows). Require an explicit filtered
410 // sampleStructureRequest.where for these tables instead of trusting the default.
411 // Compare case-insensitively: the format check above (line ~345) permits uppercase in
412 // `table`, but this Set is all-lowercase -- a re-cased name like "Posts" must still be
413 // caught here, or it silently bypasses this entire safety check.
414 if ( SHARED_CORE_TABLES_REQUIRING_WHERE . has ( String (fulfillment.table). toLowerCase ())
415 && ! (fulfillment.sampleStructureRequest && Array. isArray (fulfillment.sampleStructureRequest.where) && fulfillment.sampleStructureRequest.where. length > 0 )) {
416 errors. push ( `${ entryLabel }.fulfillment.sampleStructureRequest.where is required when table is "${ fulfillment . table }" -- a shared WordPress core table holds every plugin's rows mixed together; an unfiltered default sample would dump the whole table, not just this plugin's data` );
417 }
418 }
419 });
420 }
421
422 const SAMPLE_STRUCTURE_REQUEST_KEYS = [ 'select' , 'where' , 'groupBy' , 'orderBy' ];
423
424 function validateSampleStructureRequest ( request , label , errors ) {
425 if ( ! request || typeof request !== 'object' || Array. isArray (request)) {
426 errors. push ( `${ label } must be an object` );
427 return ;
428 }
429 // `table` is deliberately NOT an allowed key here: the caller (wp-discovery.js /
430 // rp-import-codegen) always merges the fulfillment's own authoritative `table` into the
431 // final request itself. A declared sampleStructureRequest with its own `table` (or a
432 // stray `limit`/`cursor`, which the caller also controls) would let a profile silently
433 // query a different table than the one this entity's filename/notes claim to describe --
434 // rejecting any key outside this exact set closes that off at authoring time, not just
435 // by hoping the merge order elsewhere is never reversed.
436 const extraKeys = Object. keys (request). filter (( key ) => ! SAMPLE_STRUCTURE_REQUEST_KEYS . includes (key));
437 if (extraKeys. length > 0 ) {
438 errors. push ( `${ label } must not declare ${ extraKeys . join ( ', ' ) } -- only ${ SAMPLE_STRUCTURE_REQUEST_KEYS . join ( '/' ) } are allowed; table/limit/cursor are always supplied by the caller` );
439 }
440 if ( ! Array. isArray (request.select) || request.select. length === 0 ) {
441 errors. push ( `${ label }.select must be a non-empty array` );
442 } else {
443 for ( const [ index , item ] of request.select. entries ()) {
444 if ( ! item || typeof item !== 'object' || typeof item.column !== 'string' || item.column === '' ) {
445 errors. push ( `${ label }.select[${ index }].column must be a non-empty string` );
446 }
447 if (item && item.aggregate !== undefined && ! SAMPLE_AGGREGATE_ENUM . has (item.aggregate)) {
448 errors. push ( `${ label }.select[${ index }].aggregate must be one of ${ [ ... SAMPLE_AGGREGATE_ENUM ]. join ( ', ' ) }` );
449 }
450 }
451 }
452 if (request.groupBy !== undefined && ( ! Array. isArray (request.groupBy) || request.groupBy. some (( c ) => typeof c !== 'string' ))) {
453 errors. push ( `${ label }.groupBy must be an array of strings` );
454 }
455 if (request.where !== undefined && ! Array. isArray (request.where)) {
456 errors. push ( `${ label }.where must be an array` );
457 } else if (Array. isArray (request.where)) {
458 // Presence of a non-empty `where` alone does not guarantee it actually filters anything
459 // (`where: [{}]`, or a clause with no real column/op, would otherwise satisfy every
460 // caller's "an explicit where is required" check with no real discrimination). Validate each entry's shape against the live plugin's own
461 // structure-request.v1 schema (column/op/value) the same way select[]/orderBy[] already
462 // validate their own items -- this cannot prove a clause targets the RIGHT discriminator
463 // column, but it does close off a syntactically-empty or malformed "filter".
464 for ( const [ index , item ] of request.where. entries ()) {
465 if ( ! item || typeof item !== 'object' || Array. isArray (item)) {
466 errors. push ( `${ label }.where[${ index }] must be an object` );
467 continue ;
468 }
469 if ( typeof item.column !== 'string' || item.column === '' ) {
470 errors. push ( `${ label }.where[${ index }].column must be a non-empty string` );
471 }
472 if ( ! SAMPLE_WHERE_OP_ENUM . has (item.op)) {
473 errors. push ( `${ label }.where[${ index }].op must be one of ${ [ ... SAMPLE_WHERE_OP_ENUM ]. join ( ', ' ) }` );
474 }
475 if (item.value === undefined ) {
476 errors. push ( `${ label }.where[${ index }].value is required` );
477 }
478 }
479 }
480 if ( ! Array. isArray (request.orderBy) || request.orderBy. length === 0 ) {
481 errors. push ( `${ label }.orderBy must be a non-empty array -- wix-wp-plugin-v2 requires it on every request` );
482 } else {
483 for ( const [ index , item ] of request.orderBy. entries ()) {
484 if ( ! item || typeof item.column !== 'string' || item.column === '' ) {
485 errors. push ( `${ label }.orderBy[${ index }].column must be a non-empty string` );
486 }
487 if (item && ! SAMPLE_DIRECTION_ENUM . has (item.direction)) {
488 errors. push ( `${ label }.orderBy[${ index }].direction must be asc or desc` );
489 }
490 }
491 }
492 }
493
494 function blockedEntriesOf ( entity ) {
495 return [
496 ... (entity.blocked || []),
497 ... (entity.pitfalls || []). flatMap (( pitfall ) => pitfall.blocked || []),
498 ];
499 }
500
501 function validateProfile ( profile , slug , label , errors ) {
502 requireFields (
503 profile,
504 [ 'schemaVersion' , 'plugin' , 'displayName' , 'profileVersion' , 'sourceOfTruth' , 'capabilities' , 'detect' , 'entities' ],
505 label,
506 errors,
507 );
508 if (profile.schemaVersion !== 1 ) errors. push ( `${ label }: schemaVersion must be 1` );
509 if (profile.plugin !== slug) errors. push ( `${ label }: plugin must match the filename slug (${ slug })` );
510 if (profile.distribution !== undefined && ! DISTRIBUTIONS . has (profile.distribution)) {
511 errors. push ( `${ label }: invalid distribution ${ profile . distribution }` );
512 }
513 if ( ! Array. isArray (profile.capabilities) || profile.capabilities. length === 0 ) {
514 errors. push ( `${ label }: capabilities must be a non-empty array` );
515 }
516 if (profile.dispositionHint !== undefined ) {
517 errors. push ( `${ label }: dispositionHint is retired — a plugin either has a profile (data to read) or an entry on the no-migration-needed list, never a hint on the profile` );
518 }
519
520 const detect = profile.detect || {};
521 for ( const namespace of detect.restNamespaces || []) {
522 if ( SHARED_NAMESPACES . has (namespace)) {
523 errors. push ( `${ label }: detect.restNamespaces must not contain the shared namespace ${ namespace } — it identifies WordPress/WooCommerce, not this plugin. Use a specific route instead.` );
524 }
525 }
526 const signalCount = [ 'pluginFileIds' , 'routes' , 'restNamespaces' , 'restBases' , 'recordProperties' , 'assetPathSlugs' ]
527 . reduce (( total , key ) => total + (Array. isArray (detect[key]) ? detect[key]. length : 0 ), 0 );
528 if (signalCount === 0 ) errors. push ( `${ label }: detect must declare at least one signal` );
529
530 if ( ! Array. isArray (profile.entities) || profile.entities. length === 0 ) {
531 errors. push ( `${ label }: entities must be a non-empty array` );
532 } else {
533 const seenEntities = new Set ();
534 profile.entities. forEach (( entity , i ) => {
535 validateEntity (entity, `${ label } entities[${ i }]` , errors, seenEntities);
536 });
537 for ( const entity of profile.entities) {
538 if (entity.requiresParent && ! seenEntities. has (entity.requiresParent)) {
539 errors. push ( `${ label }: entity ${ entity . entity } requiresParent ${ entity . requiresParent } which is not declared in this profile` );
540 }
541 }
542
543 const declaredCapabilities = Array. isArray (profile.capabilities) ? profile.capabilities : [];
544 const attributedEntities = profile.entities. filter (( entity ) => entity.capability !== undefined );
545 if (attributedEntities. length > 0 && attributedEntities. length !== profile.entities. length ) {
546 errors. push ( `${ label }: when one entity declares capability, every entity must declare capability so coverage cannot leak targets across rows` );
547 }
548 for ( const entity of attributedEntities) {
549 if ( ! declaredCapabilities. includes (entity.capability)) {
550 errors. push ( `${ label }: entity ${ entity . entity } capability ${ entity . capability } is not declared in profile.capabilities` );
551 }
552 }
553 if (attributedEntities. length > 0 ) {
554 for ( const capability of declaredCapabilities) {
555 if ( ! attributedEntities. some (( entity ) => entity.capability === capability)) {
556 errors. push ( `${ label }: capability ${ capability } has no attributed entity` );
557 }
558 }
559 }
560 }
561
562 validateCouponRules (profile.couponRules, label, errors);
563 if (profile.appliedOffers !== undefined ) {
564 const offers = profile.appliedOffers;
565 if ( ! offers || typeof offers.lineMetaKey !== 'string' || ! offers.lineMetaKey. trim ()) errors. push ( `${ label }: appliedOffers.lineMetaKey is required` );
566 if (offers && offers.orderFreeShippingMetaKey !== undefined && ( typeof offers.orderFreeShippingMetaKey !== 'string' || ! offers.orderFreeShippingMetaKey. trim ())) {
567 errors. push ( `${ label }: appliedOffers.orderFreeShippingMetaKey must be a non-empty string` );
568 }
569 }
570 if (detect.addOnPluginFileIds !== undefined ) {
571 if ( ! Array. isArray (detect.addOnPluginFileIds) || detect.addOnPluginFileIds. some (( id ) => typeof id !== 'string' || ! id. trim ())) {
572 errors. push ( `${ label }: detect.addOnPluginFileIds must be an array of plugin file ids` );
573 } else {
574 const own = new Set ((detect.pluginFileIds || []). map (( id ) => id. split ( '/' )[ 0 ]));
575 for ( const id of detect.addOnPluginFileIds) {
576 if (own. has (id. split ( '/' )[ 0 ])) errors. push ( `${ label }: detect.addOnPluginFileIds ${ id } is this profile's own plugin` );
577 }
578 }
579 }
580 // Required lazily: plugin-data-presence -> wp-redirect-sources -> plugin-knowledge.
581 const presenceRules = require ( './plugin-data-presence.js' );
582 presenceRules. validateDataPresence (profile.dataPresence, label, errors);
583 presenceRules. validateDataLocation (profile.dataLocation, label, errors, { required: profile.dataPresence !== undefined });
584
585 for ( const rule of profile.dataRoutePatterns || []) {
586 if ( ! rule.pattern || ! rule.reason) errors. push ( `${ label }: dataRoutePatterns entries need pattern and reason` );
587 if (rule.pattern && ! wildcardKeepsScope (rule.pattern)) {
588 errors. push ( `${ label }: dataRoutePatterns pattern ${ rule . pattern } is too broad; keep at least two concrete leading segments before a wildcard` );
589 }
590 }
591 for ( const rule of profile.excludeRoutes || []) {
592 if ( ! rule.route || ! rule.reason) errors. push ( `${ label }: excludeRoutes entries need route and reason` );
593 }
594 for ( const credential of profile.credentials || []) {
595 if ( ! credential.key || ! credential.description) {
596 errors. push ( `${ label }: credentials entries need key and description` );
597 }
598 }
599 }
600
601 function validateCouponRules ( rules , label , errors ) {
602 if (rules === undefined ) return ;
603 if ( ! Array. isArray (rules) || rules. length === 0 ) {
604 errors. push ( `${ label }: couponRules must be a non-empty array when present` );
605 return ;
606 }
607 const seen = new Set ();
608 rules. forEach (( rule , i ) => {
609 const at = `${ label } couponRules[${ i }]` ;
610 if ( ! rule || typeof rule.metaKey !== 'string' || ! rule.metaKey. trim ()) {
611 errors. push ( `${ at }: metaKey is required` );
612 return ;
613 }
614 if (seen. has (rule.metaKey)) errors. push ( `${ at }: metaKey ${ rule . metaKey } is declared twice` );
615 seen. add (rule.metaKey);
616 if ( ! COUPON_RULE_MEANINGS . has (rule.meaning)) {
617 errors. push ( `${ at }: meaning ${ rule . meaning } is not one of ${ [ ... COUPON_RULE_MEANINGS ]. join ( ', ' ) }` );
618 }
619 if (rule.meaning === 'same-as-core' ) {
620 if ( ! COUPON_RULE_CORE_FIELDS . has (rule.coreField)) errors. push ( `${ at }: same-as-core needs coreField, one of ${ [ ... COUPON_RULE_CORE_FIELDS ]. join ( ', ' ) }` );
621 if ( ! COUPON_RULE_SHAPES . has (rule.shape)) errors. push ( `${ at }: same-as-core needs shape, one of ${ [ ... COUPON_RULE_SHAPES ]. join ( ', ' ) }` );
622 } else if (rule.coreField !== undefined || rule.shape !== undefined ) {
623 errors. push ( `${ at }: coreField/shape apply only to same-as-core` );
624 }
625 if (rule.inertValues !== undefined && ! Array. isArray (rule.inertValues)) errors. push ( `${ at }: inertValues must be an array` );
626 });
627 }
628
629 // Cross-profile checks for couponRules and addOnPluginFileIds: a coupon meta key has one owner, and an
630 // add-on is claimed by one profile and is never another profile's own plugin.
631 function validateCrossProfileClaims ( profiles , errors ) {
632 const metaOwners = new Map ();
633 const addOnOwners = new Map ();
634 const ownDirs = new Map ();
635 for ( const profile of profiles) {
636 for ( const id of profile.detect?.pluginFileIds || []) ownDirs. set (id. split ( '/' )[ 0 ], profile.plugin);
637 }
638 for ( const profile of profiles) {
639 for ( const rule of Array. isArray (profile.couponRules) ? profile.couponRules : []) {
640 if ( ! rule || ! rule.metaKey) continue ;
641 if (metaOwners. has (rule.metaKey) && metaOwners. get (rule.metaKey) !== profile.plugin) {
642 errors. push ( `plugins/${ profile . plugin }.json: coupon metaKey ${ rule . metaKey } is already declared by ${ metaOwners . get ( rule . metaKey ) }` );
643 }
644 metaOwners. set (rule.metaKey, profile.plugin);
645 }
646 for ( const id of Array. isArray (profile.detect?.addOnPluginFileIds) ? profile.detect.addOnPluginFileIds : []) {
647 const dir = String (id). split ( '/' )[ 0 ];
648 if (addOnOwners. has (dir) && addOnOwners. get (dir) !== profile.plugin) {
649 errors. push ( `plugins/${ profile . plugin }.json: add-on ${ id } is already claimed by ${ addOnOwners . get ( dir ) }` );
650 }
651 addOnOwners. set (dir, profile.plugin);
652 if (ownDirs. has (dir) && ownDirs. get (dir) !== profile.plugin) {
653 errors. push ( `plugins/${ profile . plugin }.json: add-on ${ id } has its own profile (${ ownDirs . get ( dir ) })` );
654 }
655 }
656 }
657 }
658
659 // Every profile's coupon meta rules, flattened, each tagged with its owning plugin. The coupon
660 // mapper defaults to this list (lib/coupon-discovery.js).
661 function buildCouponRules ( pluginsDir = pluginsRoot ()) {
662 const rules = [];
663 for ( const profile of loadProfiles (pluginsDir)) {
664 for ( const rule of profile.couponRules || []) rules. push ({ ... rule, plugin: profile.plugin, displayName: profile.displayName });
665 }
666 return rules;
667 }
668
669 // Every profile's applied-offer markers (order meta a cart-offer plugin leaves on the lines it
670 // added), for lib/applied-offer-summary.js.
671 function buildAppliedOfferMarkers ( pluginsDir = pluginsRoot ()) {
672 return loadProfiles (pluginsDir)
673 . filter (( profile ) => profile.appliedOffers && profile.appliedOffers.lineMetaKey)
674 . map (( profile ) => ({ plugin: profile.plugin, displayName: profile.displayName, ... profile.appliedOffers }));
675 }
676
677 // Cross-adapter integrity: candidateTargetRefs must resolve in the Wix target knowledge
678 // base, and every capability must either be claimed by a target entity or be explicitly
679 // allowlisted. This is the check that keeps the two knowledge homes from drifting apart.
680 //
681 // blocked[].fulfillment resolution is handler-registry-only now: every db-only/admin-page-only
682 // entity this repo bridges reads through wix-wp-plugin-v2 (structure-bridge-plugin), which has
683 // no external manifest of its own to cross-check against -- authorization is a live denylist +
684 // DESCRIBE check at request time, not a build-time readiness flag. There is nothing left here
685 // analogous to the old bridge-plugin/manifest-case cross-reference.
686 function validateAgainstTargets ( profiles , pluginsDir , targetKnowledge , errors ) {
687 if ( ! targetKnowledge) return ;
688 const { knownRefs , capabilityRefs } = targetKnowledge;
689 const allowlist = new Set (
690 ( loadRequiresDevelopment (pluginsDir).capabilities || []). map (( entry ) => entry.capability),
691 );
692 const pendingFile = loadPendingDecisions (pluginsDir);
693 const pending = new Set ((pendingFile.capabilities || []). map (( entry ) => entry.capability));
694 const profileEntityByRef = new Map ();
695 for ( const profile of profiles) {
696 for ( const entity of profile.entities || []) profileEntityByRef. set ( `plugin.${ profile . plugin }.${ entity . entity }` , entity);
697 }
698
699 let handlerRegistry = {};
700 try { handlerRegistry = require ( './blocked-data-handlers.js' ).handlers; }
701 catch (error) { errors. push ( `blocked data handler registry could not be loaded: ${ error . message }` ); }
702
703 const referencedSourceRefs = new Set ();
704 for ( const [ targetRef , dependencies ] of targetKnowledge.blockedSourceDependenciesByRef || []) {
705 for ( const dependency of dependencies) {
706 referencedSourceRefs. add (dependency.sourceEntityRef);
707 const sourceEntity = profileEntityByRef. get (dependency.sourceEntityRef);
708 if ( ! sourceEntity) {
709 errors. push ( `${ targetRef }: blockedSourceDependencies sourceEntityRef ${ dependency . sourceEntityRef } does not resolve to a plugin profile entity` );
710 continue ;
711 }
712 const fulfillments = blockedEntriesOf (sourceEntity). map (( entry ) => entry.fulfillment). filter (Boolean);
713 if (fulfillments. length === 0 ) {
714 errors. push ( `${ targetRef }: ${ dependency . sourceEntityRef } has no matching blocked[].fulfillment` );
715 }
716 }
717 }
718 for ( const [ sourceEntityRef , sourceEntity ] of profileEntityByRef) {
719 const fulfillments = blockedEntriesOf (sourceEntity). map (( entry ) => entry.fulfillment). filter (Boolean);
720 if (fulfillments. length > 0 && ! referencedSourceRefs. has (sourceEntityRef)) {
721 errors. push ( `${ sourceEntityRef }: blocked[].fulfillment is not referenced by any target blockedSourceDependencies entry` );
722 }
723 for ( const fulfillment of fulfillments) {
724 const handler = handlerRegistry[fulfillment.handlerId];
725 if ( ! handler) errors. push ( `${ sourceEntityRef }: fulfillment handlerId ${ fulfillment . handlerId } is not registered` );
726 else if (handler.kind !== fulfillment.kind) errors. push ( `${ sourceEntityRef }: handler ${ fulfillment . handlerId } kind ${ handler . kind } does not match ${ fulfillment . kind }` );
727 }
728 }
729
730 // "Wix cannot do this" is a human-only verdict. Automation may say profiled or pending and
731 // nothing else, because a wrong impossibility tells a customer to abandon migratable data.
732 for ( const entry of loadRequiresDevelopment (pluginsDir).capabilities || []) {
733 const label = `requires-development.json[${ entry . capability }]` ;
734 if ( ! entry.reason) errors. push ( `${ label }: needs a reason` );
735 if ( ! entry.searched) errors. push ( `${ label }: needs the recorded search` );
736 if ( ! entry.decidedBy) {
737 errors. push ( `${ label }: needs decidedBy — a named human. An agent may not conclude that Wix cannot do something; record it in capabilities-pending-decision.json instead.` );
738 }
739 if ( ! entry.decidedOn) errors. push ( `${ label }: needs decidedOn` );
740 if (capabilityRefs. has (entry.capability)) {
741 errors. push ( `${ label }: a Wix target entity now claims this capability; remove this entry` );
742 }
743 if (pending. has (entry.capability)) {
744 errors. push ( `${ label }: also listed as pending — a capability is either human-decided or pending, never both` );
745 }
746 }
747 for ( const entry of pendingFile.capabilities || []) {
748 const label = `capabilities-pending-decision.json[${ entry . capability }]` ;
749 if ( ! entry.capability) errors. push ( 'capabilities-pending-decision.json: every entry needs a capability' );
750 if ( ! entry.reason) errors. push ( `${ label }: needs a reason` );
751 if ( ! entry.searched) errors. push ( `${ label }: needs the recorded search` );
752 if ( ! [ 'target-exists' , 'no-target' ]. includes (entry.suspected)) {
753 errors. push ( `${ label }: suspected must be target-exists or no-target` );
754 }
755 if (entry.suspected === 'target-exists' && ! entry.plannedTarget) {
756 errors. push ( `${ label }: suspected target-exists requires plannedTarget — the ref a human should author` );
757 }
758 if (capabilityRefs. has (entry.capability)) {
759 errors. push ( `${ label }: a Wix target entity now claims this capability; remove the pending entry and let it resolve` );
760 }
761 }
762
763 for ( const profile of profiles) {
764 const label = `plugins/${ profile . plugin }.json` ;
765 for ( const entity of profile.entities || []) {
766 for ( const ref of entity.candidateTargetRefs || []) {
767 if ( ! knownRefs. has (ref)) {
768 errors. push ( `${ label }: candidateTargetRefs ${ ref } does not resolve in rp-target-wix/domains/index.json` );
769 }
770 }
771 }
772 for ( const capability of profile.capabilities || []) {
773 // Deliberately NOT an error: a capability with no target is PENDING, which is a valid
774 // default state. Coverage reports it and a human decides; blocking here would push
775 // authors toward inventing a verdict just to make validation pass.
776 void capability;
777 }
778 }
779
780 for ( const entry of loadRequiresDevelopment (pluginsDir).capabilities || []) {
781 if ( ! entry.reason) {
782 errors. push ( `requires-development.json: ${ entry . capability } needs a reason` );
783 }
784 if (capabilityRefs. has (entry.capability)) {
785 errors. push ( `requires-development.json: ${ entry . capability } is allowlisted but a Wix target entity now claims it; remove the allowlist entry` );
786 }
787 }
788 }
789
790 // The signed per-capability tier of no-migration-needed.json. Pure over its inputs, like
791 // validateEntity, so every rule is directly testable without staging a whole KB on disk.
792 function validateNoMigrationCapabilities ({
793 entries = [],
794 requiresDevelopmentCapabilities = new Set (),
795 pendingCapabilities = new Set (),
796 // The Wix target KB's capability -> refs map, or null when it could not be loaded.
797 capabilityRefs = null ,
798 } = {}, errors ) {
799 const seen = new Set ();
800
801 for ( const entry of entries) {
802 const label = `no-migration-needed.json capabilities[${ entry && entry . capability }]` ;
803 if ( ! entry || ! entry.capability) {
804 errors. push ( 'no-migration-needed.json: every capabilities[] entry needs a capability' );
805 continue ;
806 }
807 if (seen. has (entry.capability)) errors. push ( `${ label }: duplicate capability` );
808 seen. add (entry.capability);
809 if ( ! NO_MIGRATION_REASONS . has (entry.reason)) {
810 errors. push ( `${ label }: invalid reason ${ entry . reason } — one of ${ [ ... NO_MIGRATION_REASONS ]. join ( ', ' ) }` );
811 }
812 if ( ! entry.replacedBy) errors. push ( `${ label }: needs replacedBy so the customer is told what covers it` );
813 if ( ! entry.rationale) errors. push ( `${ label }: needs a rationale — every no-need-to-migrate row requires one` );
814 // The signature is the whole point of this register. Unsigned, it would let automation
815 // decide there is nothing to move, which is a decision about what the customer loses.
816 if ( ! entry.decidedBy) {
817 errors. push ( `${ label }: needs decidedBy — a named human. Automation may not decide a profiled capability has nothing to move; record it in capabilities-pending-decision.json instead.` );
818 }
819 if ( ! entry.decidedOn) errors. push ( `${ label }: needs decidedOn` );
820 // Three capability registers, mutually exclusive: a capability is decided
821 // no-need, decided requires-development, or pending — never two of the three.
822 if (requiresDevelopmentCapabilities. has (entry.capability)) {
823 errors. push ( `${ label }: also listed in requires-development.json — "nothing to move" and "Wix has no surface" are contradictory verdicts; keep one` );
824 }
825 if (pendingCapabilities. has (entry.capability)) {
826 errors. push ( `${ label }: also listed as pending — a capability is either human-decided or pending, never both` );
827 }
828 // A signed "nothing to move" verdict and a Wix target entity claiming the same capability
829 // are a direct contradiction: the target says the data has somewhere to go. The register
830 // entry is the one that loses — the KB grew, so the verdict is stale and must be re-taken.
831 if (capabilityRefs && capabilityRefs. has (entry.capability)) {
832 errors. push ( `${ label }: a Wix target entity now claims this capability; the signed no-need verdict contradicts it and must be re-decided` );
833 }
834 }
835 }
836
837 const CMS_FALLBACK_REQUIRED_FIELDS = [ 'capability' , 'targetRefsByEntity' , 'reason' , 'nativeAlternativesChecked' , 'featureLoss' , 'decidedBy' , 'decidedOn' ];
838
839 // The signed CMS-fallback eligibility register. Pure over its inputs, like
840 // validateNoMigrationCapabilities. `knownRefs`/`capabilityRefs` come from the Wix target
841 // knowledge summary and are skipped when it is unavailable.
842 function validateCmsFallbackEligible ({
843 entries = [],
844 profiles = [],
845 requiresDevelopmentCapabilities = new Set (),
846 noMigrationCapabilities = new Set (),
847 knownRefs = null ,
848 capabilityRefs = null ,
849 } = {}, errors ) {
850 const seen = new Set ();
851 for ( const entry of entries) {
852 const label = `cms-fallback-eligible.json capabilities[${ entry && entry . capability }]` ;
853 if ( ! entry || ! entry.capability) {
854 errors. push ( 'cms-fallback-eligible.json: every capabilities[] entry needs a capability' );
855 continue ;
856 }
857 if (seen. has (entry.capability)) errors. push ( `${ label }: duplicate capability` );
858 seen. add (entry.capability);
859 for ( const field of CMS_FALLBACK_REQUIRED_FIELDS ) {
860 const value = entry[field];
861 if (value === undefined || value === null || value === '' ) {
862 errors. push (field === 'decidedBy'
863 ? `${ label }: needs decidedBy — a named human. Automation may not decide that nothing native fits; leave the capability pending instead.`
864 : `${ label }: missing required field ${ field }` );
865 }
866 }
867 const declaring = profiles. filter (( profile ) => (profile.capabilities || []). includes (entry.capability));
868 if (declaring. length === 0 ) {
869 errors. push ( `${ label }: no plugin profile declares this capability` );
870 }
871 for ( const profile of declaring) {
872 if ((profile.capabilities || []). length !== 1 ) {
873 errors. push ( `${ label }: plugins/${ profile . plugin }.json declares ${ profile . capabilities . length } capabilities — eligibility is only valid when every profile declaring the capability declares exactly one, so its entity set is unambiguous` );
874 }
875 }
876 const map = entry.targetRefsByEntity;
877 if (map !== undefined && ( ! map || typeof map !== 'object' || Array. isArray (map) || Object. keys (map). length === 0 )) {
878 errors. push ( `${ label }: targetRefsByEntity must be a non-empty object keyed <plugin-slug>/<entity>` );
879 } else if (map) {
880 const expected = new Set (declaring. flatMap (( profile ) => (profile.entities || []). map (( entity ) => `${ profile . plugin }/${ entity . entity }` )));
881 const actual = new Set (Object. keys (map));
882 for ( const key of expected) {
883 if ( ! actual. has (key)) errors. push ( `${ label }: targetRefsByEntity is missing profile entity ${ key }` );
884 }
885 for ( const key of actual) {
886 if ( ! expected. has (key)) errors. push ( `${ label }: targetRefsByEntity names ${ key }, which is not a profile entity of this capability` );
887 const refs = map[key];
888 if ( ! Array. isArray (refs) || refs. length === 0 ) {
889 errors. push ( `${ label }: targetRefsByEntity[${ key }] must be a non-empty array of CMS refs` );
890 continue ;
891 }
892 for ( const ref of refs) {
893 if ( typeof ref !== 'string' || ! ref. startsWith ( 'cms/' )) errors. push ( `${ label }: targetRefsByEntity[${ key }] ref ${ ref } must start with cms/` );
894 else if (knownRefs && ! knownRefs. has (ref)) errors. push ( `${ label }: targetRefsByEntity[${ key }] ref ${ ref } does not resolve in rp-target-wix/domains/index.json` );
895 }
896 }
897 }
898 if (requiresDevelopmentCapabilities. has (entry.capability)) {
899 errors. push ( `${ label }: also listed in requires-development.json — a capability is eligible for CMS or requires development, never both` );
900 }
901 if (noMigrationCapabilities. has (entry.capability)) {
902 errors. push ( `${ label }: also listed in no-migration-needed.json capabilities[] — "nothing to move" and "archive the records in CMS" are contradictory verdicts; keep one` );
903 }
904 // This register is the DEFAULT fallback only. An operator who wants CMS for a natively
905 // claimed capability records a per-migration `mappingTargetOverride:<capability>` answer
906 // (lib/plugin-cms-fallback.js); it never needs, or adds, an entry here.
907 if (capabilityRefs && capabilityRefs. has (entry.capability)) {
908 errors. push ( `${ label }: a Wix target entity now claims this capability; a native target exists, so the CMS eligibility is stale and must be removed` );
909 }
910 }
911 }
912
913 // The no-migration-needed list is the cheap tier: nothing-to-move plugins that do not warrant
914 // a full profile. A slug must not appear in both tiers, or the two could disagree.
915 function validateNoMigrationNeeded ( pluginsDir , profiles , errors ) {
916 const file = loadNoMigrationNeeded (pluginsDir);
917 const seen = new Set ();
918 const profileSlugs = new Set (profiles. map (( profile ) => profile.plugin));
919 for ( const hint of file.hints || []) {
920 const label = `no-migration-needed.json[${ hint && hint . slug }]` ;
921 if ( ! hint || ! hint.slug) {
922 errors. push ( 'no-migration-needed.json: every entry needs a slug' );
923 continue ;
924 }
925 if (seen. has (hint.slug)) errors. push ( `${ label }: duplicate slug` );
926 seen. add (hint.slug);
927 if ( ! NO_MIGRATION_REASONS . has (hint.reason)) {
928 errors. push ( `${ label }: invalid reason ${ hint . reason } — one of ${ [ ... NO_MIGRATION_REASONS ]. join ( ', ' ) }` );
929 }
930 if ( ! hint.does) errors. push ( `${ label }: needs a plain-language "does"` );
931 if ( ! hint.replacedBy) errors. push ( `${ label }: needs replacedBy so the customer is told what covers it` );
932 if ( ! hint.provenance) errors. push ( `${ label }: needs provenance (observed or expected:not-yet-observed)` );
933 if (profileSlugs. has (hint.slug)) {
934 errors. push ( `${ label }: this slug also has a full profile; keep the verdict in one place` );
935 }
936 }
937 }
938
939 // Every register keyed by plugin slug, checked together so one slug has exactly one answer:
940 // a profile, a no-migration-needed hint, a core-covered entry, a toolkit entry, or a cutover task.
941 function validateSlugRegisters ( pluginsDir , profiles , errors ) {
942 const nmn = loadNoMigrationNeeded (pluginsDir);
943 const owners = new Map ();
944 const claim = ( slug , where ) => {
945 if ( ! owners. has (slug)) owners. set (slug, []);
946 owners. get (slug). push (where);
947 };
948 for ( const profile of profiles) if (profile && profile.plugin) claim (profile.plugin, 'a profile' );
949 for ( const hint of nmn.hints || []) if (hint && hint.slug) claim (hint.slug, 'no-migration-needed.json hints[]' );
950 for ( const entry of nmn.coreCovered || []) {
951 const label = `no-migration-needed.json coreCovered[${ entry && entry . slug }]` ;
952 if ( ! entry || ! entry.slug) { errors. push ( 'no-migration-needed.json: every coreCovered[] entry needs a slug' ); continue ; }
953 claim (entry.slug, 'no-migration-needed.json coreCovered[]' );
954 if ( ! entry.does) errors. push ( `${ label }: needs a plain-language "does"` );
955 if ( ! Array. isArray (entry.coreNamespaces) || entry.coreNamespaces. length === 0
956 || entry.coreNamespaces. some (( ns ) => typeof ns !== 'string' || ! SHARED_NAMESPACES . has (ns))) {
957 errors. push ( `${ label }: coreNamespaces must list core namespaces the adapter reads (${ [ ... SHARED_NAMESPACES ]. join ( ', ' ) })` );
958 }
959 }
960 for ( const entry of nmn.toolkitPlugins || []) {
961 const label = `no-migration-needed.json toolkitPlugins[${ entry && entry . slug }]` ;
962 if ( ! entry || ! entry.slug) { errors. push ( 'no-migration-needed.json: every toolkitPlugins[] entry needs a slug' ); continue ; }
963 claim (entry.slug, 'no-migration-needed.json toolkitPlugins[]' );
964 if ( ! entry.does) errors. push ( `${ label }: needs a plain-language "does"` );
965 if ( ! entry.decidedBy || ! entry.decidedOn) errors. push ( `${ label }: needs decidedBy and decidedOn — leaving a plugin out of the report is a signed decision` );
966 }
967 for ( const entry of loadCutoverTasks (pluginsDir).tasks || []) {
968 const label = `cutover-task-only.json[${ entry && entry . slug }]` ;
969 if ( ! entry || ! entry.slug) { errors. push ( 'cutover-task-only.json: every entry needs a slug' ); continue ; }
970 claim (entry.slug, 'cutover-task-only.json' );
971 for ( const field of [ 'displayName' , 'does' , 'task' , 'doneWhen' , 'provenance' ]) {
972 if ( typeof entry[field] !== 'string' || ! entry[field]. trim ()) errors. push ( `${ label }: needs ${ field }` );
973 }
974 if ( ! CUTOVER_WORK_TYPES . has (entry.workType)) {
975 errors. push ( `${ label }: invalid workType ${ entry . workType } — one of ${ [ ... CUTOVER_WORK_TYPES ]. join ( ', ' ) }` );
976 }
977 if ( ! entry.decidedBy || ! entry.decidedOn) errors. push ( `${ label }: needs decidedBy and decidedOn (unsigned entries are ignored at runtime)` );
978 const steps = entry.manualSteps && entry.manualSteps.steps;
979 if ( ! Array. isArray (steps) || steps. length === 0 ) {
980 errors. push ( `${ label }: manualSteps.steps must be a non-empty array` );
981 } else {
982 steps. forEach (( step , index ) => {
983 if ( ! step || ! CUTOVER_STEP_ACTORS . has (step.actor)) errors. push ( `${ label }: manualSteps.steps[${ index }].actor must be one of ${ [ ... CUTOVER_STEP_ACTORS ]. join ( ', ' ) }` );
984 if ( ! step || typeof step.text !== 'string' || ! step.text. trim ()) errors. push ( `${ label }: manualSteps.steps[${ index }].text must be a non-empty string` );
985 });
986 }
987 }
988 for ( const [ slug , places ] of owners) {
989 // Duplicates inside one register are reported by that register's own check.
990 const distinct = Array. from ( new Set (places));
991 if (distinct. length > 1 ) errors. push ( `plugin slug ${ slug } is listed in ${ distinct . join ( ' and ' ) }; keep its answer in one place` );
992 else if (places. length > 1 && distinct[ 0 ] !== 'no-migration-needed.json hints[]' && distinct[ 0 ] !== 'a profile' ) errors. push ( `${ distinct [ 0 ] }: duplicate slug ${ slug }` );
993 }
994 }
995
996 // The alias map carries names only: a token maps to a displayName + wordpress.org
997 // slug, nothing more — an alias must never smuggle in detection or mapping behavior.
998 function validateFingerprintAliases ( pluginsDir , errors ) {
999 const file = loadFingerprintAliases (pluginsDir);
1000 for ( const [ token , alias ] of Object. entries (file.aliases || {})) {
1001 const label = `fingerprint-aliases.json[${ token }]` ;
1002 if ( ! alias || typeof alias.displayName !== 'string' || alias.displayName. length === 0 ) {
1003 errors. push ( `${ label }: needs a displayName` );
1004 continue ;
1005 }
1006 if ( ! alias.slug) errors. push ( `${ label }: needs the plugin directory slug` );
1007 const extra = Object. keys (alias). filter (( key ) => ! [ 'displayName' , 'slug' ]. includes (key));
1008 if (extra. length > 0 ) {
1009 errors. push ( `${ label }: carries ${ extra . join ( ', ' ) } — the alias map is names only; entities, channels, and target refs belong in a profile` );
1010 }
1011 }
1012 }
1013
1014 function validateKnowledge ( pluginsDir , { targetKnowledge = null } = {}) {
1015 const errors = [];
1016 const profiles = [];
1017
1018 let corePatterns = new Set ();
1019 let isCredentialBearingRoute = () => false ;
1020 try {
1021 ({ isCredentialBearingRoute } = require ( './wp-route-classifier.js' ));
1022 corePatterns = require ( './wp-route-classifier.js' ). coreRulePatterns ();
1023 } catch (error) {
1024 errors. push ( `could not load classifier core rule patterns for collision checks: ${ error . message }` );
1025 }
1026
1027 const routeOwners = new Map ();
1028 for ( const fileName of listProfileFiles (pluginsDir)) {
1029 const slug = fileName. replace ( / \. json $ / , '' );
1030 const label = `plugins/${ fileName }` ;
1031 let profile;
1032 try {
1033 profile = readJson (path. join (pluginsDir, fileName));
1034 } catch (error) {
1035 errors. push ( `${ label }: not valid JSON (${ error . message })` );
1036 continue ;
1037 }
1038 profiles. push (profile);
1039 validateProfile (profile, slug, label, errors);
1040
1041 const routes = [
1042 ... (profile.entities || []). map (entityRouteOf). filter (Boolean),
1043 ... (profile.dataRoutePatterns || []). map (( rule ) => rule.pattern),
1044 ];
1045 const overridingEntities = new Set (
1046 (profile.entities || []). filter (( entity ) => entity.overridesCoreRule === true ). map (entityRouteOf),
1047 );
1048 for ( const route of routes) {
1049 if (routeOwners. has (route) && routeOwners. get (route) !== slug) {
1050 errors. push ( `${ label }: route ${ route } is already claimed by plugin ${ routeOwners . get ( route ) }` );
1051 }
1052 routeOwners. set (route, slug);
1053 // A credential-bearing route is refused outright, with no `overridesCoreRule` escape.
1054 // `overridesCoreRule` exists so a profile can reclaim a route whose core classification is
1055 // a scope judgement; whether a response carries a live API key is not one.
1056 if ( isCredentialBearingRoute (route)) {
1057 errors. push ( `${ label }: route ${ route } returns payment-gateway settings, including live credentials — it cannot be declared as a readable entity route under any flag` );
1058 continue ;
1059 }
1060 // A profile route that shadows a classifier-owned pattern must say so explicitly,
1061 // so an accidental shadow cannot silently change core scope.
1062 if (corePatterns. has (route) && ! overridingEntities. has (route)) {
1063 errors. push ( `${ label }: route ${ route } shadows a classifier core rule; set "overridesCoreRule": true on that entity if this is intended` );
1064 }
1065 }
1066 }
1067
1068 validateNoMigrationNeeded (pluginsDir, profiles, errors);
1069 validateSlugRegisters (pluginsDir, profiles, errors);
1070 validateCrossProfileClaims (profiles, errors);
1071 validateNoMigrationCapabilities ({
1072 entries: loadNoMigrationCapabilities (pluginsDir),
1073 requiresDevelopmentCapabilities: new Set (
1074 ( loadRequiresDevelopment (pluginsDir).capabilities || []). map (( entry ) => entry.capability),
1075 ),
1076 pendingCapabilities: new Set (
1077 ( loadPendingDecisions (pluginsDir).capabilities || []). map (( entry ) => entry.capability),
1078 ),
1079 capabilityRefs: targetKnowledge?.capabilityRefs || null ,
1080 }, errors);
1081 validateCmsFallbackEligible ({
1082 entries: loadCmsFallbackEligible (pluginsDir).capabilities || [],
1083 profiles,
1084 requiresDevelopmentCapabilities: new Set (
1085 ( loadRequiresDevelopment (pluginsDir).capabilities || []). map (( entry ) => entry.capability),
1086 ),
1087 noMigrationCapabilities: new Set ( loadNoMigrationCapabilities (pluginsDir). map (( entry ) => entry.capability)),
1088 knownRefs: targetKnowledge?.knownRefs || null ,
1089 capabilityRefs: targetKnowledge?.capabilityRefs || null ,
1090 }, errors);
1091 validateFingerprintAliases (pluginsDir, errors);
1092 validateAgainstTargets (profiles, pluginsDir, targetKnowledge, errors);
1093
1094 const generated = generateIndex (pluginsDir);
1095 const indexPath = path. join (pluginsDir, 'index.json' );
1096 if ( ! fs. existsSync (indexPath)) {
1097 errors. push ( 'plugins/index.json is missing; run plugin-knowledge-validate.js --write-index' );
1098 } else if ( JSON . stringify ( readJson (indexPath)) !== JSON . stringify (generated)) {
1099 errors. push ( 'plugins/index.json is stale; run plugin-knowledge-validate.js --write-index' );
1100 }
1101
1102 return { ok: errors. length === 0 , errors, generatedIndex: generated, profileCount: profiles. length };
1103 }
1104
1105 function loadIndex ( pluginsDir ) {
1106 return readJson (path. join (pluginsDir, 'index.json' ));
1107 }
1108
1109 function listPlugins ( pluginsDir ) {
1110 const index = loadIndex (pluginsDir);
1111 return Object. entries (index.plugins || {}). map (([ plugin , info ]) => ({ plugin, ... info }));
1112 }
1113
1114 function readProfile ( pluginsDir , slug ) {
1115 const filePath = path. join (pluginsDir, `${ slug }.json` );
1116 if ( ! fs. existsSync (filePath)) throw new Error ( `Unknown plugin profile: ${ slug }` );
1117 return readJson (filePath);
1118 }
1119
1120 function patternMatchesRoute ( pattern , routePath ) {
1121 if (pattern. endsWith ( '/*' )) return routePath. startsWith (pattern. slice ( 0 , - 1 ));
1122 if (pattern. endsWith ( '*' )) return routePath. startsWith (pattern. slice ( 0 , - 1 ));
1123 return pattern === routePath;
1124 }
1125
1126 function resolveRoute ( pluginsDir , routePath ) {
1127 const index = loadIndex (pluginsDir);
1128 const matches = [];
1129 for ( const [ pattern , slugs ] of Object. entries (index.routeIndex || {})) {
1130 if ( patternMatchesRoute (pattern, routePath)) {
1131 for ( const slug of slugs) matches. push ({ plugin: slug, pattern });
1132 }
1133 }
1134 return matches. sort (( a , b ) => a.plugin. localeCompare (b.plugin) || a.pattern. localeCompare (b.pattern));
1135 }
1136
1137 function resolveFromIndexKey ( pluginsDir , collectionName , key ) {
1138 const index = loadIndex (pluginsDir);
1139 return [ ... ((index[collectionName] || {})[key] || [])];
1140 }
1141
1142 function listCapabilities ( pluginsDir ) {
1143 const index = loadIndex (pluginsDir);
1144 const allowlist = new Set (
1145 ( loadRequiresDevelopment (pluginsDir).capabilities || []). map (( entry ) => entry.capability),
1146 );
1147 return Object. entries (index.capabilityIndex || {}). map (([ capability , plugins ]) => ({
1148 capability,
1149 plugins,
1150 hasNativeTarget: ! allowlist. has (capability),
1151 }));
1152 }
1153
1154 // Route rules consumed by wp-route-classifier.js. Built from profiles so adding a plugin
1155 // never means editing classifier code.
1156 function buildRouteRules ( pluginsDir ) {
1157 const dataRules = [];
1158 const excludeRules = [];
1159
1160 for ( const fileName of listProfileFiles (pluginsDir)) {
1161 const profile = readJson (path. join (pluginsDir, fileName));
1162 const slug = profile.plugin;
1163
1164 for ( const rule of profile.excludeRoutes || []) {
1165 excludeRules. push ([rule.route, `plugin.${ slug }.exclude` , rule.reason]);
1166 }
1167 for ( const entity of profile.entities || []) {
1168 const route = entityRouteOf (entity);
1169 if (route) {
1170 dataRules. push ([
1171 route,
1172 `plugin.${ slug }.${ entity . entity }` ,
1173 `${ profile . displayName } ${ entity . entity } records are durable plugin data` ,
1174 ]);
1175 }
1176 }
1177 for ( const rule of profile.dataRoutePatterns || []) {
1178 dataRules. push ([rule.pattern, `plugin.${ slug }.namespace` , rule.reason]);
1179 }
1180 }
1181
1182 // Longest pattern first so a specific route beats a namespace-wide pattern from the same
1183 // or another profile.
1184 dataRules. sort (( a , b ) => b[ 0 ]. length - a[ 0 ]. length || a[ 0 ]. localeCompare (b[ 0 ]));
1185 excludeRules. sort (( a , b ) => b[ 0 ]. length - a[ 0 ]. length || a[ 0 ]. localeCompare (b[ 0 ]));
1186 return { dataRules, excludeRules };
1187 }
1188
1189 // Route -> { itemsPath, countPath } for entities whose profile declares a non-standard
1190 // response envelope (e.g. MailPoet's `{ data: { items: [...], meta: { count } } }` instead of
1191 // a flat array + X-WP-Total header). Built from profiles, like buildRouteRules, so adding a
1192 // plugin with a wrapped response never means editing the sampler.
1193 function buildResponseEnvelopes ( pluginsDir ) {
1194 const envelopes = new Map ();
1195 for ( const fileName of listProfileFiles (pluginsDir)) {
1196 const profile = readJson (path. join (pluginsDir, fileName));
1197 for ( const entity of profile.entities || []) {
1198 const route = entityRouteOf (entity);
1199 if (route && entity.responseEnvelope) {
1200 envelopes. set (route, entity.responseEnvelope);
1201 }
1202 }
1203 }
1204 return envelopes;
1205 }
1206
1207 // Route -> { method, body } for entities whose only read path is not a plain GET
1208 // collection (e.g. a plugin that implements "list" as a POST with a JSON body instead of
1209 // query params). Built from profiles, like buildResponseEnvelopes, so any
1210 // future waitlist-shaped plugin needs only a profile entry, never a sampler change.
1211 // `body` may contain the placeholder string "$SAMPLED_IDS:<route>" on any leaf value,
1212 // resolved at sample time (wp-discovery.js resolveRequestBody) to the ids already
1213 // collected for that route earlier in the same discovery run — generic across any
1214 // entity/route pair, not hardcoded to one plugin's id field.
1215 function buildRequestOverrides ( pluginsDir ) {
1216 const overrides = new Map ();
1217 for ( const fileName of listProfileFiles (pluginsDir)) {
1218 const profile = readJson (path. join (pluginsDir, fileName));
1219 for ( const entity of profile.entities || []) {
1220 const route = entityRouteOf (entity);
1221 if (route && entity.requestMethod && entity.requestMethod !== 'GET' ) {
1222 overrides. set (route, { method: entity.requestMethod, body: entity.requestBody || {} });
1223 }
1224 }
1225 }
1226 return overrides;
1227 }
1228
1229 // Route -> N for entities whose profile declares that each logical record arrives as N
1230 // separate flat array entries instead of one object (e.g. Back In Stock Notifier's
1231 // list_subscriber, verified live 2026-08-19: 4 single-key entries per subscriber). Built
1232 // from profiles, like buildResponseEnvelopes/buildRequestOverrides, so any future
1233 // fragmented-response plugin needs only a profile entry, never a sampler change.
1234 // Route -> recordKeyField for entities that declare one. Used to dedupe merged batch results
1235 // in the $SAMPLED_IDS pagination/batching mechanism — a profile whose records key
1236 // on something other than `id` (e.g. subscriptions-for-woocommerce's `subscription_id`) would
1237 // otherwise silently dedupe on the wrong field, or not at all. Built from profiles, like the
1238 // other route-level lookups in this file.
1239 function buildRecordKeyFields ( pluginsDir ) {
1240 const keyFields = new Map ();
1241 for ( const fileName of listProfileFiles (pluginsDir)) {
1242 const profile = readJson (path. join (pluginsDir, fileName));
1243 for ( const entity of profile.entities || []) {
1244 const route = entityRouteOf (entity);
1245 if (route && entity.recordKeyField) {
1246 keyFields. set (route, entity.recordKeyField);
1247 }
1248 }
1249 }
1250 return keyFields;
1251 }
1252
1253 function buildResponseFragmentGroups ( pluginsDir ) {
1254 const groups = new Map ();
1255 for ( const fileName of listProfileFiles (pluginsDir)) {
1256 const profile = readJson (path. join (pluginsDir, fileName));
1257 for ( const entity of profile.entities || []) {
1258 const route = entityRouteOf (entity);
1259 if (route && entity.responseFragmentGroupSize) {
1260 groups. set (route, entity.responseFragmentGroupSize);
1261 }
1262 }
1263 }
1264 return groups;
1265 }
1266
1267 module . exports = {
1268 CHANNELS,
1269 ROUTE_CHANNELS,
1270 CHILD_ROUTE_PLACEHOLDER,
1271 // Exported so a runtime consumer (e.g. wp-discovery.js's sampleStructureBridgeEntities /
1272 // resolveSampleStructureRequest) can apply the identical rule at query-build time, not only
1273 // at authoring-time validation (otherwise this is a build/CI-time-only
1274 // guard with no live backstop if a profile is hand-edited after its last validate run).
1275 SHARED_CORE_TABLES_REQUIRING_WHERE,
1276 validateEntity,
1277 validateProfile,
1278 validateBlockedEntries,
1279 blockedEntriesOf,
1280 validateNoMigrationCapabilities,
1281 validateCmsFallbackEligible,
1282 loadCmsFallbackEligible,
1283 pluginsRoot,
1284 listProfileFiles,
1285 loadProfiles,
1286 loadRequiresDevelopment,
1287 loadFingerprintAliases,
1288 loadPendingDecisions,
1289 loadNoMigrationNeeded,
1290 loadNoMigrationCapabilities,
1291 loadCutoverTasks,
1292 validateSlugRegisters,
1293 CUTOVER_WORK_TYPES,
1294 generateIndex,
1295 validateKnowledge,
1296 writeJson,
1297 loadIndex,
1298 listPlugins,
1299 readProfile,
1300 resolveRoute,
1301 resolveFromIndexKey,
1302 listCapabilities,
1303 buildRouteRules,
1304 buildResponseEnvelopes,
1305 buildResponseFragmentGroups,
1306 buildRecordKeyFields,
1307 buildCouponRules,
1308 buildAppliedOfferMarkers,
1309 validateCouponRules,
1310 COUPON_RULE_MEANINGS,
1311 buildRequestOverrides,
1312 patternMatchesRoute,
1313 wildcardKeepsScope,
1314 entityRouteOf,
1315 };