Setting the file. One moment.
Plugin Knowledge · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 43.9
Position 9 of 46
Type JavaScript
Size 45 KB
Lines 939 lib/ plugin-knowledge.js
JavaScript · 939 lines · 45 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' , 'bridge-plugin' ]);
37 const FULFILLMENT_KINDS = new Set ([ 'csv-upload' , 'bridge-plugin' ]);
38
39 const RESERVED_FILES = new Set ([
40 'schema.json' ,
41 'index.json' ,
42 'requires-development.json' ,
43 'capabilities-pending-decision.json' ,
44 'no-migration-needed.json' ,
45 'fingerprint-aliases.json' ,
46 ]);
47 const NO_MIGRATION_REASONS = new Set ([ 'platform-does-it' , 'not-needed' , 'reconfigure-in-wix' ]);
48 // Namespaces every WordPress or WooCommerce site advertises. Using one as a detection
49 // signal makes a profile match every such site, which is a false positive, not a detection.
50 const SHARED_NAMESPACES = new Set ([ 'wp/v2' , 'wc/v3' , 'wc/v2' , 'wc/v1' , 'wc/store' , 'wc/store/v1' , 'oembed/1.0' ]);
51
52 function pluginsRoot ( rootDir = path. resolve (__dirname, '..' )) {
53 return path. join (rootDir, 'plugins' );
54 }
55
56 function readJson ( filePath ) {
57 return JSON . parse (fs. readFileSync (filePath, 'utf8' ));
58 }
59
60 function writeJson ( filePath , value ) {
61 fs. writeFileSync (filePath, `${ JSON . stringify ( value , null , 2 ) } \n ` );
62 }
63
64 function listProfileFiles ( pluginsDir ) {
65 if ( ! fs. existsSync (pluginsDir)) return [];
66 return fs
67 . readdirSync (pluginsDir, { withFileTypes: true })
68 . filter (( entry ) => entry. isFile () && entry.name. endsWith ( '.json' ) && ! RESERVED_FILES . has (entry.name))
69 . map (( entry ) => entry.name)
70 . sort ();
71 }
72
73 function loadProfiles ( pluginsDir ) {
74 return listProfileFiles (pluginsDir). map (( fileName ) => readJson (path. join (pluginsDir, fileName)));
75 }
76
77 // The no-migration-needed file: plugins with nothing to move. Two
78 // tiers live here — `hints[]`, the cheap slug lookup for plugins that never get a profile,
79 // and `capabilities[]`, the human-signed per-capability register below.
80 function loadNoMigrationNeeded ( pluginsDir ) {
81 const filePath = path. join (pluginsDir, 'no-migration-needed.json' );
82 if ( ! fs. existsSync (filePath)) return { hints: [], capabilities: [] };
83 const file = readJson (filePath);
84 return { hints: [], capabilities: [], ... file };
85 }
86
87 // The signed no-need-to-migrate register: per-CAPABILITY verdicts on PROFILED plugins, where
88 // the slug list cannot reach (a slug with a profile is rejected there, and rightly — the two
89 // would disagree). "Nothing to move" is not impossibility, but it is still a verdict about
90 // what the customer loses, so it is human-signed exactly like requires-development: without
91 // it, a decided capability re-lands as pending on every run and the decision has no home.
92 function loadNoMigrationCapabilities ( pluginsDir ) {
93 return loadNoMigrationNeeded (pluginsDir).capabilities || [];
94 }
95
96 function loadPendingDecisions ( pluginsDir ) {
97 const filePath = path. join (pluginsDir, 'capabilities-pending-decision.json' );
98 if ( ! fs. existsSync (filePath)) return { capabilities: [] };
99 return readJson (filePath);
100 }
101
102 // The Requires development register: human-signed verdicts only.
103 function loadRequiresDevelopment ( pluginsDir ) {
104 const filePath = path. join (pluginsDir, 'requires-development.json' );
105 if ( ! fs. existsSync (filePath)) return { capabilities: [] };
106 return readJson (filePath);
107 }
108
109 // The fingerprint alias map: public evidence token -> plugin name. Names only —
110 // no entities, no channels, no target refs.
111 function loadFingerprintAliases ( pluginsDir ) {
112 const filePath = path. join (pluginsDir, 'fingerprint-aliases.json' );
113 if ( ! fs. existsSync (filePath)) return { aliases: {} };
114 return readJson (filePath);
115 }
116
117 // A wildcard route pattern must keep at least two concrete leading segments. This is what
118 // stops a profile from writing "/yoast/*" and silently reopening a whole excluded route
119 // family, while still allowing the legitimate "/ssp/v1/*" namespace-wide data case.
120 function wildcardKeepsScope ( pattern ) {
121 if ( ! pattern. includes ( '*' )) return true ;
122 const concrete = pattern. slice ( 0 , pattern. indexOf ( '*' )). split ( '/' ). filter (Boolean);
123 return concrete. length >= 2 ;
124 }
125
126 function entityRouteOf ( entity ) {
127 return ROUTE_CHANNELS . has (entity.channel) ? entity.route : null ;
128 }
129
130 function generateIndex ( pluginsDir ) {
131 const index = {
132 schemaVersion: 1 ,
133 plugins: {},
134 routeIndex: {},
135 namespaceIndex: {},
136 restBaseIndex: {},
137 recordPropertyIndex: {},
138 capabilityIndex: {},
139 channels: {},
140 };
141
142 for ( const fileName of listProfileFiles (pluginsDir)) {
143 const profile = readJson (path. join (pluginsDir, fileName));
144 const slug = fileName. replace ( / \. json $ / , '' );
145
146 index.plugins[slug] = {
147 path: `plugins/${ fileName }` ,
148 displayName: profile.displayName,
149 profileVersion: profile.profileVersion,
150 distribution: profile.distribution || 'unknown' ,
151 capabilities: [ ... (profile.capabilities || [])]. sort (),
152 entityCount: (profile.entities || []). length ,
153 channels: Array. from ( new Set ((profile.entities || []). map (( entity ) => entity.channel))). sort (),
154 requiresCredentials: (profile.credentials || []). length > 0 ,
155 };
156
157 const push = ( collection , key , value ) => {
158 if ( ! key) return ;
159 if ( ! collection[key]) collection[key] = [];
160 collection[key]. push (value);
161 };
162
163 for ( const route of profile.detect?.routes || []) push (index.routeIndex, route, slug);
164 for ( const entity of profile.entities || []) {
165 push (index.routeIndex, entityRouteOf (entity), slug);
166 push (index.channels, entity.channel, slug);
167 for ( const property of entity.channel === 'core-embedded' && entity.propertyPath ? [entity.propertyPath] : []) {
168 push (index.recordPropertyIndex, property, slug);
169 }
170 }
171 for ( const pattern of (profile.dataRoutePatterns || []). map (( rule ) => rule.pattern)) {
172 push (index.routeIndex, pattern, slug);
173 }
174 for ( const namespace of profile.detect?.restNamespaces || []) push (index.namespaceIndex, namespace, slug);
175 for ( const restBase of profile.detect?.restBases || []) push (index.restBaseIndex, restBase, slug);
176 for ( const property of profile.detect?.recordProperties || []) push (index.recordPropertyIndex, property, slug);
177 for ( const capability of profile.capabilities || []) push (index.capabilityIndex, capability, slug);
178 }
179
180 for ( const collection of [
181 index.routeIndex,
182 index.namespaceIndex,
183 index.restBaseIndex,
184 index.recordPropertyIndex,
185 index.capabilityIndex,
186 index.channels,
187 ]) {
188 for ( const key of Object. keys (collection)) {
189 collection[key] = Array. from ( new Set (collection[key])). sort ();
190 }
191 }
192
193 return index;
194 }
195
196 function requireFields ( value , fields , label , errors ) {
197 for ( const field of fields) {
198 if (value[field] === undefined ) errors. push ( `${ label }: missing required field ${ field }` );
199 }
200 }
201
202 function validateEntity ( entity , label , errors , seenEntities ) {
203 requireFields (entity, [ 'entity' , 'channel' , 'candidateTargetRefs' ], label, errors);
204 if ( ! CHANNELS . has (entity.channel)) {
205 errors. push ( `${ label }: invalid channel ${ entity . channel }` );
206 }
207 if (entity.entity) {
208 if (seenEntities. has (entity.entity)) errors. push ( `${ label }: duplicate entity id ${ entity . entity }` );
209 seenEntities. add (entity.entity);
210 }
211 if ( ROUTE_CHANNELS . has (entity.channel)) {
212 if ( ! entity.route) {
213 errors. push ( `${ label }: channel ${ entity . channel } requires route` );
214 } else if ( ! entity.route. startsWith ( '/' )) {
215 errors. push ( `${ label }: route must start with /` );
216 } else if ( ! wildcardKeepsScope (entity.route)) {
217 errors. push ( `${ label }: route pattern ${ entity . route } is too broad; keep at least two concrete leading segments before a wildcard` );
218 }
219 }
220 if (entity.channel === 'core-embedded' ) {
221 if ( ! Array. isArray (entity.embeddedIn) || entity.embeddedIn. length === 0 ) {
222 errors. push ( `${ label }: core-embedded requires a non-empty embeddedIn` );
223 }
224 if ( ! entity.propertyPath) errors. push ( `${ label }: core-embedded requires propertyPath` );
225 }
226 if (entity.channel === 'core-meta' && ! entity.propertyPath) {
227 errors. push ( `${ label }: core-meta requires propertyPath` );
228 }
229 if (entity.channel === 'plugin-rest-child' ) {
230 if ( ! entity.route || ! entity.route. startsWith ( '/' )) {
231 errors. push ( `${ label }: plugin-rest-child requires route` );
232 } else if ( ! entity.route. includes ( CHILD_ROUTE_PLACEHOLDER )) {
233 errors. push ( `${ label }: plugin-rest-child route must contain a ${ CHILD_ROUTE_PLACEHOLDER } placeholder, e.g. /wc/v3/orders/${ CHILD_ROUTE_PLACEHOLDER }/notes` );
234 }
235 if ( ! entity.parentRoute) {
236 errors. push ( `${ label }: plugin-rest-child requires parentRoute — the already-sampled collection route that supplies parent ids (e.g. /wc/v3/orders)` );
237 } else if ( ! entity.parentRoute. startsWith ( '/' ) || entity.parentRoute. includes ( CHILD_ROUTE_PLACEHOLDER )) {
238 errors. push ( `${ label }: parentRoute must be a literal collection route, not a template` );
239 }
240 }
241 if (entity.responseEnvelope !== undefined ) {
242 if ( ! ROUTE_CHANNELS . has (entity.channel)) {
243 errors. push ( `${ label }: responseEnvelope only applies to route-bearing channels (${ [ ... ROUTE_CHANNELS ]. join ( ', ' ) })` );
244 }
245 const itemsPath = entity.responseEnvelope && entity.responseEnvelope.itemsPath;
246 if ( typeof itemsPath !== 'string' || itemsPath. length === 0 ) {
247 errors. push ( `${ label }: responseEnvelope requires itemsPath — the dot-path to the array of records within the response body, e.g. "data.items"` );
248 }
249 const countPath = entity.responseEnvelope && entity.responseEnvelope.countPath;
250 if (countPath !== undefined && ( typeof countPath !== 'string' || countPath. length === 0 )) {
251 errors. push ( `${ label }: responseEnvelope.countPath must be a non-empty string dot-path when present` );
252 }
253 }
254 if (entity.responseFragmentGroupSize !== undefined ) {
255 if ( ! ROUTE_CHANNELS . has (entity.channel)) {
256 errors. push ( `${ label }: responseFragmentGroupSize only applies to route-bearing channels (${ [ ... ROUTE_CHANNELS ]. join ( ', ' ) })` );
257 }
258 if ( ! Number. isInteger (entity.responseFragmentGroupSize) || entity.responseFragmentGroupSize < 2 ) {
259 errors. push ( `${ label }: responseFragmentGroupSize must be an integer >= 2 — the number of flat array entries that reassemble into one record` );
260 }
261 }
262 if (entity.requestMethod !== undefined || entity.requestBody !== undefined ) {
263 if ( ! ROUTE_CHANNELS . has (entity.channel)) {
264 errors. push ( `${ label }: requestMethod/requestBody only apply to route-bearing channels (${ [ ... ROUTE_CHANNELS ]. join ( ', ' ) })` );
265 }
266 if (entity.requestMethod !== undefined && ! REQUEST_METHODS . has (entity.requestMethod)) {
267 errors. push ( `${ label }: invalid requestMethod ${ entity . requestMethod } (expected one of ${ [ ... REQUEST_METHODS ]. join ( ', ' ) })` );
268 }
269 if (entity.requestMethod === 'GET' && entity.requestBody !== undefined ) {
270 errors. push ( `${ label }: requestBody is not meaningful with requestMethod GET` );
271 }
272 if (entity.requestMethod && entity.requestMethod !== 'GET' && entity.requestBody === undefined ) {
273 errors. push ( `${ label }: requestMethod ${ entity . requestMethod } requires requestBody` );
274 }
275 if (entity.requestBody !== undefined && entity.requestMethod === undefined ) {
276 errors. push ( `${ label }: requestBody requires an explicit non-GET requestMethod — a body with no declared method is not a real request override` );
277 }
278 if (entity.requestBody !== undefined && ( typeof entity.requestBody !== 'object' || entity.requestBody === null || Array. isArray (entity.requestBody))) {
279 errors. push ( `${ label }: requestBody must be a plain JSON object` );
280 }
281 }
282 if (entity.context !== undefined && ! CONTEXTS . has (entity.context)) {
283 errors. push ( `${ label }: invalid context ${ entity . context }` );
284 }
285 if ( ! Array. isArray (entity.candidateTargetRefs)) {
286 errors. push ( `${ label }: candidateTargetRefs must be an array` );
287 } else if (entity.candidateTargetRefs. length === 0 && (entity.pitfalls || []). length === 0 ) {
288 errors. push ( `${ label }: an entity with no candidateTargetRefs must record a pitfall explaining the gap` );
289 }
290 for ( const pitfall of entity.pitfalls || []) {
291 if ( ! SEVERITIES . has (pitfall.severity)) errors. push ( `${ label }: invalid pitfall severity ${ pitfall . severity }` );
292 if ( ! pitfall.code || ! pitfall.summary) errors. push ( `${ label }: pitfalls need code and summary` );
293 }
294 validateBlockedEntries (entity.blocked, label, errors);
295 for ( const [ index , pitfall ] of (entity.pitfalls || []). entries ()) {
296 validateBlockedEntries (pitfall.blocked, `${ label } pitfalls[${ index }]` , errors);
297 }
298 }
299
300 function validateBlockedEntries ( blocked , label , errors ) {
301 if (blocked === undefined ) return ;
302 if ( ! Array. isArray (blocked) || blocked. length === 0 ) {
303 errors. push ( `${ label }: blocked must be a non-empty array when present` );
304 return ;
305 }
306 blocked. forEach (( entry , index ) => {
307 const entryLabel = `${ label } blocked[${ index }]` ;
308 if ( ! entry || typeof entry !== 'object' || Array. isArray (entry)) {
309 errors. push ( `${ entryLabel } must be an object` );
310 return ;
311 }
312 if ( ! BLOCKED_KINDS . has (entry.kind)) errors. push ( `${ entryLabel }.kind must be one of ${ [ ... BLOCKED_KINDS ]. join ( ', ' ) }` );
313 if ( ! entry.resolution || typeof entry.resolution !== 'string' ) errors. push ( `${ entryLabel }.resolution must be a non-empty string` );
314 if ( typeof entry.declined !== 'boolean' ) errors. push ( `${ entryLabel }.declined must be boolean` );
315 if (entry.fulfillment === undefined ) return ;
316 const fulfillment = entry.fulfillment;
317 if ( ! fulfillment || typeof fulfillment !== 'object' || Array. isArray (fulfillment)) {
318 errors. push ( `${ entryLabel }.fulfillment must be an object` );
319 return ;
320 }
321 if ( ! FULFILLMENT_KINDS . has (fulfillment.kind)) errors. push ( `${ entryLabel }.fulfillment.kind must be csv-upload or bridge-plugin` );
322 if ( ! fulfillment.handlerId || typeof fulfillment.handlerId !== 'string' ) errors. push ( `${ entryLabel }.fulfillment.handlerId must be a non-empty string` );
323 if (fulfillment.freshnessWindowHours !== undefined && ( ! Number. isFinite (fulfillment.freshnessWindowHours) || fulfillment.freshnessWindowHours < 0 )) {
324 errors. push ( `${ entryLabel }.fulfillment.freshnessWindowHours must be a non-negative number` );
325 }
326 if (fulfillment.kind === 'csv-upload' && ( ! fulfillment.expectedInputPath || typeof fulfillment.expectedInputPath !== 'string' )) {
327 errors. push ( `${ entryLabel }.fulfillment.expectedInputPath is required for csv-upload` );
328 }
329 if (fulfillment.kind === 'bridge-plugin' ) {
330 for ( const field of [ 'manifestCaseId' , 'expectedNamespace' , 'extractionRoute' ]) {
331 if ( ! fulfillment[field] || typeof fulfillment[field] !== 'string' ) errors. push ( `${ entryLabel }.fulfillment.${ field } is required for bridge-plugin` );
332 }
333 if (fulfillment.extractionRoute && ! fulfillment.extractionRoute. startsWith ( '/' )) errors. push ( `${ entryLabel }.fulfillment.extractionRoute must start with /` );
334 }
335 });
336 }
337
338 function blockedEntriesOf ( entity ) {
339 return [
340 ... (entity.blocked || []),
341 ... (entity.pitfalls || []). flatMap (( pitfall ) => pitfall.blocked || []),
342 ];
343 }
344
345 function validateProfile ( profile , slug , label , errors ) {
346 requireFields (
347 profile,
348 [ 'schemaVersion' , 'plugin' , 'displayName' , 'profileVersion' , 'sourceOfTruth' , 'capabilities' , 'detect' , 'entities' ],
349 label,
350 errors,
351 );
352 if (profile.schemaVersion !== 1 ) errors. push ( `${ label }: schemaVersion must be 1` );
353 if (profile.plugin !== slug) errors. push ( `${ label }: plugin must match the filename slug (${ slug })` );
354 if (profile.distribution !== undefined && ! DISTRIBUTIONS . has (profile.distribution)) {
355 errors. push ( `${ label }: invalid distribution ${ profile . distribution }` );
356 }
357 if ( ! Array. isArray (profile.capabilities) || profile.capabilities. length === 0 ) {
358 errors. push ( `${ label }: capabilities must be a non-empty array` );
359 }
360 if (profile.dispositionHint !== undefined ) {
361 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` );
362 }
363
364 const detect = profile.detect || {};
365 for ( const namespace of detect.restNamespaces || []) {
366 if ( SHARED_NAMESPACES . has (namespace)) {
367 errors. push ( `${ label }: detect.restNamespaces must not contain the shared namespace ${ namespace } — it identifies WordPress/WooCommerce, not this plugin. Use a specific route instead.` );
368 }
369 }
370 const signalCount = [ 'pluginFileIds' , 'routes' , 'restNamespaces' , 'restBases' , 'recordProperties' , 'assetPathSlugs' ]
371 . reduce (( total , key ) => total + (Array. isArray (detect[key]) ? detect[key]. length : 0 ), 0 );
372 if (signalCount === 0 ) errors. push ( `${ label }: detect must declare at least one signal` );
373
374 if ( ! Array. isArray (profile.entities) || profile.entities. length === 0 ) {
375 errors. push ( `${ label }: entities must be a non-empty array` );
376 } else {
377 const seenEntities = new Set ();
378 profile.entities. forEach (( entity , i ) => {
379 validateEntity (entity, `${ label } entities[${ i }]` , errors, seenEntities);
380 });
381 for ( const entity of profile.entities) {
382 if (entity.requiresParent && ! seenEntities. has (entity.requiresParent)) {
383 errors. push ( `${ label }: entity ${ entity . entity } requiresParent ${ entity . requiresParent } which is not declared in this profile` );
384 }
385 }
386
387 const declaredCapabilities = Array. isArray (profile.capabilities) ? profile.capabilities : [];
388 const attributedEntities = profile.entities. filter (( entity ) => entity.capability !== undefined );
389 if (attributedEntities. length > 0 && attributedEntities. length !== profile.entities. length ) {
390 errors. push ( `${ label }: when one entity declares capability, every entity must declare capability so coverage cannot leak targets across rows` );
391 }
392 for ( const entity of attributedEntities) {
393 if ( ! declaredCapabilities. includes (entity.capability)) {
394 errors. push ( `${ label }: entity ${ entity . entity } capability ${ entity . capability } is not declared in profile.capabilities` );
395 }
396 }
397 if (attributedEntities. length > 0 ) {
398 for ( const capability of declaredCapabilities) {
399 if ( ! attributedEntities. some (( entity ) => entity.capability === capability)) {
400 errors. push ( `${ label }: capability ${ capability } has no attributed entity` );
401 }
402 }
403 }
404 }
405
406 for ( const rule of profile.dataRoutePatterns || []) {
407 if ( ! rule.pattern || ! rule.reason) errors. push ( `${ label }: dataRoutePatterns entries need pattern and reason` );
408 if (rule.pattern && ! wildcardKeepsScope (rule.pattern)) {
409 errors. push ( `${ label }: dataRoutePatterns pattern ${ rule . pattern } is too broad; keep at least two concrete leading segments before a wildcard` );
410 }
411 }
412 for ( const rule of profile.excludeRoutes || []) {
413 if ( ! rule.route || ! rule.reason) errors. push ( `${ label }: excludeRoutes entries need route and reason` );
414 }
415 for ( const credential of profile.credentials || []) {
416 if ( ! credential.key || ! credential.description) {
417 errors. push ( `${ label }: credentials entries need key and description` );
418 }
419 }
420 }
421
422 // Cross-adapter integrity: candidateTargetRefs must resolve in the Wix target knowledge
423 // base, and every capability must either be claimed by a target entity or be explicitly
424 // allowlisted. This is the check that keeps the two knowledge homes from drifting apart.
425 //
426 // $bridgeManifest is caller-supplied (already-parsed JSON), never resolved from a path by this
427 // function itself: the wix-migration-helper plugin lives at repo-root plugins/, outside this
428 // skill's own published bundle (skills/wix-replatform/), and a skill lib must never hardcode a
429 // path reaching outside its own folder -- see tests/lib/paths.js's documented rule. Passing
430 // `null`/omitting it simply skips the bridge-manifest-specific checks below; the two-of-three
431 // tests that need them (tests/source-wordpress/blocked-data-validation-test.js) load and pass
432 // the real manifest themselves.
433 function validateAgainstTargets ( profiles , pluginsDir , targetKnowledge , errors , bridgeManifest = null , bridgeManifestRoot = null ) {
434 if ( ! targetKnowledge) return ;
435 const { knownRefs , capabilityRefs } = targetKnowledge;
436 const allowlist = new Set (
437 ( loadRequiresDevelopment (pluginsDir).capabilities || []). map (( entry ) => entry.capability),
438 );
439 const pendingFile = loadPendingDecisions (pluginsDir);
440 const pending = new Set ((pendingFile.capabilities || []). map (( entry ) => entry.capability));
441 const profileEntityByRef = new Map ();
442 for ( const profile of profiles) {
443 for ( const entity of profile.entities || []) profileEntityByRef. set ( `plugin.${ profile . plugin }.${ entity . entity }` , entity);
444 }
445
446 let handlerRegistry = {};
447 try { handlerRegistry = require ( './blocked-data-handlers.js' ).handlers; }
448 catch (error) { errors. push ( `blocked data handler registry could not be loaded: ${ error . message }` ); }
449
450 // schemaVersion 2 added `area`-keyed cases alongside the original bridge-plugin shape: they
451 // are not (yet) cross-referenced against a plugin profile's blocked[].fulfillment (that wiring
452 // is deliberately deferred to whoever builds the Wix-side signing service), so they carry no
453 // sourceEntityRef; an area with no shipped adapter yet also carries no handlerId/module.
454 if (bridgeManifest) {
455 if ( ! [ 1 , 2 ]. includes (bridgeManifest.schemaVersion)) errors. push ( 'bridge manifest: schemaVersion must be 1 or 2' );
456 if ( ! Array. isArray (bridgeManifest.cases)) errors. push ( 'bridge manifest: cases must be an array' );
457 const seenManifestCases = new Set ();
458 for ( const [ index , manifestCase ] of (bridgeManifest.cases || []). entries ()) {
459 const label = `bridge manifest cases[${ index }]` ;
460 const isAreaCase = typeof manifestCase.area === 'string' && manifestCase.area !== '' ;
461 const requiredFields = isAreaCase ? [ 'caseId' ] : [ 'caseId' , 'sourceEntityRef' , 'handlerId' , 'module' ];
462 for ( const field of requiredFields) {
463 if ( ! manifestCase[field] || typeof manifestCase[field] !== 'string' ) errors. push ( `${ label }.${ field } must be a non-empty string` );
464 }
465 if (isAreaCase) {
466 // An unshipped area (e.g. blocked on an open issue) has no adapter yet: both fields are
467 // null together, never just one — a handlerId with no module (or vice versa) is a real
468 // inconsistency, not a legitimate "not built yet" state.
469 const handlerIdPresent = manifestCase.handlerId !== null && manifestCase.handlerId !== undefined ;
470 const modulePresent = manifestCase.module !== null && manifestCase.module !== undefined ;
471 if (handlerIdPresent !== modulePresent) errors. push ( `${ label }: handlerId and module must both be set, or both be null` );
472 if (handlerIdPresent && typeof manifestCase.handlerId !== 'string' ) errors. push ( `${ label }.handlerId must be a non-empty string or null` );
473 if (modulePresent && typeof manifestCase.module !== 'string' ) errors. push ( `${ label }.module must be a non-empty string or null` );
474 }
475 if (seenManifestCases. has (manifestCase.caseId)) errors. push ( `${ label }.caseId ${ manifestCase . caseId } is duplicated` );
476 seenManifestCases. add (manifestCase.caseId);
477 if ( typeof manifestCase.productionReady !== 'boolean' ) errors. push ( `${ label }.productionReady must be boolean` );
478 if (manifestCase.module && bridgeManifestRoot && ! fs. existsSync (path. resolve (bridgeManifestRoot, manifestCase.module))) {
479 errors. push ( `${ label }.module ${ manifestCase . module } does not exist` );
480 }
481 }
482 }
483 const manifestCases = new Map (((bridgeManifest && bridgeManifest.cases) || []). map (( entry ) => [entry.caseId, entry]));
484 const referencedSourceRefs = new Set ();
485 for ( const [ targetRef , dependencies ] of targetKnowledge.blockedSourceDependenciesByRef || []) {
486 for ( const dependency of dependencies) {
487 referencedSourceRefs. add (dependency.sourceEntityRef);
488 const sourceEntity = profileEntityByRef. get (dependency.sourceEntityRef);
489 if ( ! sourceEntity) {
490 errors. push ( `${ targetRef }: blockedSourceDependencies sourceEntityRef ${ dependency . sourceEntityRef } does not resolve to a plugin profile entity` );
491 continue ;
492 }
493 const fulfillments = blockedEntriesOf (sourceEntity). map (( entry ) => entry.fulfillment). filter (Boolean);
494 if (fulfillments. length === 0 ) {
495 errors. push ( `${ targetRef }: ${ dependency . sourceEntityRef } has no matching blocked[].fulfillment` );
496 }
497 }
498 }
499 for ( const [ sourceEntityRef , sourceEntity ] of profileEntityByRef) {
500 const fulfillments = blockedEntriesOf (sourceEntity). map (( entry ) => entry.fulfillment). filter (Boolean);
501 if (fulfillments. length > 0 && ! referencedSourceRefs. has (sourceEntityRef)) {
502 errors. push ( `${ sourceEntityRef }: blocked[].fulfillment is not referenced by any target blockedSourceDependencies entry` );
503 }
504 for ( const fulfillment of fulfillments) {
505 const handler = handlerRegistry[fulfillment.handlerId];
506 if ( ! handler) errors. push ( `${ sourceEntityRef }: fulfillment handlerId ${ fulfillment . handlerId } is not registered` );
507 else if (handler.kind !== fulfillment.kind) errors. push ( `${ sourceEntityRef }: handler ${ fulfillment . handlerId } kind ${ handler . kind } does not match ${ fulfillment . kind }` );
508 // Only checked when the caller actually supplied a bridge manifest -- without one, this
509 // plugin-knowledge validation still checks handler registration above, just not the
510 // manifest-specific fields (see this function's own doc comment on `bridgeManifest`).
511 if (fulfillment.kind === 'bridge-plugin' && bridgeManifest) {
512 const manifestCase = manifestCases. get (fulfillment.manifestCaseId);
513 if ( ! manifestCase) errors. push ( `${ sourceEntityRef }: manifestCaseId ${ fulfillment . manifestCaseId } is missing from the bridge manifest` );
514 else {
515 if (manifestCase.sourceEntityRef !== sourceEntityRef) errors. push ( `${ sourceEntityRef }: manifest case sourceEntityRef does not match` );
516 if (manifestCase.handlerId !== fulfillment.handlerId) errors. push ( `${ sourceEntityRef }: manifest case handlerId does not match fulfillment` );
517 }
518 }
519 }
520 }
521
522 // "Wix cannot do this" is a human-only verdict. Automation may say profiled or pending and
523 // nothing else, because a wrong impossibility tells a customer to abandon migratable data.
524 for ( const entry of loadRequiresDevelopment (pluginsDir).capabilities || []) {
525 const label = `requires-development.json[${ entry . capability }]` ;
526 if ( ! entry.reason) errors. push ( `${ label }: needs a reason` );
527 if ( ! entry.searched) errors. push ( `${ label }: needs the recorded search` );
528 if ( ! entry.decidedBy) {
529 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.` );
530 }
531 if ( ! entry.decidedOn) errors. push ( `${ label }: needs decidedOn` );
532 if (capabilityRefs. has (entry.capability)) {
533 errors. push ( `${ label }: a Wix target entity now claims this capability; remove this entry` );
534 }
535 if (pending. has (entry.capability)) {
536 errors. push ( `${ label }: also listed as pending — a capability is either human-decided or pending, never both` );
537 }
538 }
539 for ( const entry of pendingFile.capabilities || []) {
540 const label = `capabilities-pending-decision.json[${ entry . capability }]` ;
541 if ( ! entry.capability) errors. push ( 'capabilities-pending-decision.json: every entry needs a capability' );
542 if ( ! entry.reason) errors. push ( `${ label }: needs a reason` );
543 if ( ! entry.searched) errors. push ( `${ label }: needs the recorded search` );
544 if ( ! [ 'target-exists' , 'no-target' ]. includes (entry.suspected)) {
545 errors. push ( `${ label }: suspected must be target-exists or no-target` );
546 }
547 if (entry.suspected === 'target-exists' && ! entry.plannedTarget) {
548 errors. push ( `${ label }: suspected target-exists requires plannedTarget — the ref a human should author` );
549 }
550 if (capabilityRefs. has (entry.capability)) {
551 errors. push ( `${ label }: a Wix target entity now claims this capability; remove the pending entry and let it resolve` );
552 }
553 }
554
555 for ( const profile of profiles) {
556 const label = `plugins/${ profile . plugin }.json` ;
557 for ( const entity of profile.entities || []) {
558 for ( const ref of entity.candidateTargetRefs || []) {
559 if ( ! knownRefs. has (ref)) {
560 errors. push ( `${ label }: candidateTargetRefs ${ ref } does not resolve in rp-target-wix/domains/index.json` );
561 }
562 }
563 }
564 for ( const capability of profile.capabilities || []) {
565 // Deliberately NOT an error: a capability with no target is PENDING, which is a valid
566 // default state. Coverage reports it and a human decides; blocking here would push
567 // authors toward inventing a verdict just to make validation pass.
568 void capability;
569 }
570 }
571
572 for ( const entry of loadRequiresDevelopment (pluginsDir).capabilities || []) {
573 if ( ! entry.reason) {
574 errors. push ( `requires-development.json: ${ entry . capability } needs a reason` );
575 }
576 if (capabilityRefs. has (entry.capability)) {
577 errors. push ( `requires-development.json: ${ entry . capability } is allowlisted but a Wix target entity now claims it; remove the allowlist entry` );
578 }
579 }
580 }
581
582 // The signed per-capability tier of no-migration-needed.json. Pure over its inputs, like
583 // validateEntity, so every rule is directly testable without staging a whole KB on disk.
584 function validateNoMigrationCapabilities ({
585 entries = [],
586 requiresDevelopmentCapabilities = new Set (),
587 pendingCapabilities = new Set (),
588 // The Wix target KB's capability -> refs map, or null when it could not be loaded.
589 capabilityRefs = null ,
590 } = {}, errors ) {
591 const seen = new Set ();
592
593 for ( const entry of entries) {
594 const label = `no-migration-needed.json capabilities[${ entry && entry . capability }]` ;
595 if ( ! entry || ! entry.capability) {
596 errors. push ( 'no-migration-needed.json: every capabilities[] entry needs a capability' );
597 continue ;
598 }
599 if (seen. has (entry.capability)) errors. push ( `${ label }: duplicate capability` );
600 seen. add (entry.capability);
601 if ( ! NO_MIGRATION_REASONS . has (entry.reason)) {
602 errors. push ( `${ label }: invalid reason ${ entry . reason } — one of ${ [ ... NO_MIGRATION_REASONS ]. join ( ', ' ) }` );
603 }
604 if ( ! entry.replacedBy) errors. push ( `${ label }: needs replacedBy so the customer is told what covers it` );
605 if ( ! entry.rationale) errors. push ( `${ label }: needs a rationale — every no-need-to-migrate row requires one` );
606 // The signature is the whole point of this register. Unsigned, it would let automation
607 // decide there is nothing to move, which is a decision about what the customer loses.
608 if ( ! entry.decidedBy) {
609 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.` );
610 }
611 if ( ! entry.decidedOn) errors. push ( `${ label }: needs decidedOn` );
612 // Three capability registers, mutually exclusive: a capability is decided
613 // no-need, decided requires-development, or pending — never two of the three.
614 if (requiresDevelopmentCapabilities. has (entry.capability)) {
615 errors. push ( `${ label }: also listed in requires-development.json — "nothing to move" and "Wix has no surface" are contradictory verdicts; keep one` );
616 }
617 if (pendingCapabilities. has (entry.capability)) {
618 errors. push ( `${ label }: also listed as pending — a capability is either human-decided or pending, never both` );
619 }
620 // A signed "nothing to move" verdict and a Wix target entity claiming the same capability
621 // are a direct contradiction: the target says the data has somewhere to go. The register
622 // entry is the one that loses — the KB grew, so the verdict is stale and must be re-taken.
623 if (capabilityRefs && capabilityRefs. has (entry.capability)) {
624 errors. push ( `${ label }: a Wix target entity now claims this capability; the signed no-need verdict contradicts it and must be re-decided` );
625 }
626 }
627 }
628
629 // The no-migration-needed list is the cheap tier: nothing-to-move plugins that do not warrant
630 // a full profile. A slug must not appear in both tiers, or the two could disagree.
631 function validateNoMigrationNeeded ( pluginsDir , profiles , errors ) {
632 const file = loadNoMigrationNeeded (pluginsDir);
633 const seen = new Set ();
634 const profileSlugs = new Set (profiles. map (( profile ) => profile.plugin));
635 for ( const hint of file.hints || []) {
636 const label = `no-migration-needed.json[${ hint && hint . slug }]` ;
637 if ( ! hint || ! hint.slug) {
638 errors. push ( 'no-migration-needed.json: every entry needs a slug' );
639 continue ;
640 }
641 if (seen. has (hint.slug)) errors. push ( `${ label }: duplicate slug` );
642 seen. add (hint.slug);
643 if ( ! NO_MIGRATION_REASONS . has (hint.reason)) {
644 errors. push ( `${ label }: invalid reason ${ hint . reason } — one of ${ [ ... NO_MIGRATION_REASONS ]. join ( ', ' ) }` );
645 }
646 if ( ! hint.does) errors. push ( `${ label }: needs a plain-language "does"` );
647 if ( ! hint.replacedBy) errors. push ( `${ label }: needs replacedBy so the customer is told what covers it` );
648 if ( ! hint.provenance) errors. push ( `${ label }: needs provenance (observed:<host>-<date> or expected:not-yet-observed)` );
649 if (profileSlugs. has (hint.slug)) {
650 errors. push ( `${ label }: this slug also has a full profile; keep the verdict in one place` );
651 }
652 }
653 }
654
655 // The alias map carries names only: a token maps to a displayName + wordpress.org
656 // slug, nothing more — an alias must never smuggle in detection or mapping behavior.
657 function validateFingerprintAliases ( pluginsDir , errors ) {
658 const file = loadFingerprintAliases (pluginsDir);
659 for ( const [ token , alias ] of Object. entries (file.aliases || {})) {
660 const label = `fingerprint-aliases.json[${ token }]` ;
661 if ( ! alias || typeof alias.displayName !== 'string' || alias.displayName. length === 0 ) {
662 errors. push ( `${ label }: needs a displayName` );
663 continue ;
664 }
665 if ( ! alias.slug) errors. push ( `${ label }: needs the plugin directory slug` );
666 const extra = Object. keys (alias). filter (( key ) => ! [ 'displayName' , 'slug' ]. includes (key));
667 if (extra. length > 0 ) {
668 errors. push ( `${ label }: carries ${ extra . join ( ', ' ) } — the alias map is names only; entities, channels, and target refs belong in a profile` );
669 }
670 }
671 }
672
673 function validateKnowledge ( pluginsDir , { targetKnowledge = null , bridgeManifest = null , bridgeManifestRoot = null } = {}) {
674 const errors = [];
675 const profiles = [];
676
677 let corePatterns = new Set ();
678 try {
679 corePatterns = require ( './wp-route-classifier.js' ). coreRulePatterns ();
680 } catch (error) {
681 errors. push ( `could not load classifier core rule patterns for collision checks: ${ error . message }` );
682 }
683
684 const routeOwners = new Map ();
685 for ( const fileName of listProfileFiles (pluginsDir)) {
686 const slug = fileName. replace ( / \. json $ / , '' );
687 const label = `plugins/${ fileName }` ;
688 let profile;
689 try {
690 profile = readJson (path. join (pluginsDir, fileName));
691 } catch (error) {
692 errors. push ( `${ label }: not valid JSON (${ error . message })` );
693 continue ;
694 }
695 profiles. push (profile);
696 validateProfile (profile, slug, label, errors);
697
698 const routes = [
699 ... (profile.entities || []). map (entityRouteOf). filter (Boolean),
700 ... (profile.dataRoutePatterns || []). map (( rule ) => rule.pattern),
701 ];
702 const overridingEntities = new Set (
703 (profile.entities || []). filter (( entity ) => entity.overridesCoreRule === true ). map (entityRouteOf),
704 );
705 for ( const route of routes) {
706 if (routeOwners. has (route) && routeOwners. get (route) !== slug) {
707 errors. push ( `${ label }: route ${ route } is already claimed by plugin ${ routeOwners . get ( route ) }` );
708 }
709 routeOwners. set (route, slug);
710 // A profile route that shadows a classifier-owned pattern must say so explicitly,
711 // so an accidental shadow cannot silently change core scope.
712 if (corePatterns. has (route) && ! overridingEntities. has (route)) {
713 errors. push ( `${ label }: route ${ route } shadows a classifier core rule; set "overridesCoreRule": true on that entity if this is intended` );
714 }
715 }
716 }
717
718 validateNoMigrationNeeded (pluginsDir, profiles, errors);
719 validateNoMigrationCapabilities ({
720 entries: loadNoMigrationCapabilities (pluginsDir),
721 requiresDevelopmentCapabilities: new Set (
722 ( loadRequiresDevelopment (pluginsDir).capabilities || []). map (( entry ) => entry.capability),
723 ),
724 pendingCapabilities: new Set (
725 ( loadPendingDecisions (pluginsDir).capabilities || []). map (( entry ) => entry.capability),
726 ),
727 capabilityRefs: targetKnowledge?.capabilityRefs || null ,
728 }, errors);
729 validateFingerprintAliases (pluginsDir, errors);
730 validateAgainstTargets (profiles, pluginsDir, targetKnowledge, errors, bridgeManifest, bridgeManifestRoot);
731
732 const generated = generateIndex (pluginsDir);
733 const indexPath = path. join (pluginsDir, 'index.json' );
734 if ( ! fs. existsSync (indexPath)) {
735 errors. push ( 'plugins/index.json is missing; run plugin-knowledge-validate.js --write-index' );
736 } else if ( JSON . stringify ( readJson (indexPath)) !== JSON . stringify (generated)) {
737 errors. push ( 'plugins/index.json is stale; run plugin-knowledge-validate.js --write-index' );
738 }
739
740 return { ok: errors. length === 0 , errors, generatedIndex: generated, profileCount: profiles. length };
741 }
742
743 function loadIndex ( pluginsDir ) {
744 return readJson (path. join (pluginsDir, 'index.json' ));
745 }
746
747 function listPlugins ( pluginsDir ) {
748 const index = loadIndex (pluginsDir);
749 return Object. entries (index.plugins || {}). map (([ plugin , info ]) => ({ plugin, ... info }));
750 }
751
752 function readProfile ( pluginsDir , slug ) {
753 const filePath = path. join (pluginsDir, `${ slug }.json` );
754 if ( ! fs. existsSync (filePath)) throw new Error ( `Unknown plugin profile: ${ slug }` );
755 return readJson (filePath);
756 }
757
758 function patternMatchesRoute ( pattern , routePath ) {
759 if (pattern. endsWith ( '/*' )) return routePath. startsWith (pattern. slice ( 0 , - 1 ));
760 if (pattern. endsWith ( '*' )) return routePath. startsWith (pattern. slice ( 0 , - 1 ));
761 return pattern === routePath;
762 }
763
764 function resolveRoute ( pluginsDir , routePath ) {
765 const index = loadIndex (pluginsDir);
766 const matches = [];
767 for ( const [ pattern , slugs ] of Object. entries (index.routeIndex || {})) {
768 if ( patternMatchesRoute (pattern, routePath)) {
769 for ( const slug of slugs) matches. push ({ plugin: slug, pattern });
770 }
771 }
772 return matches. sort (( a , b ) => a.plugin. localeCompare (b.plugin) || a.pattern. localeCompare (b.pattern));
773 }
774
775 function resolveFromIndexKey ( pluginsDir , collectionName , key ) {
776 const index = loadIndex (pluginsDir);
777 return [ ... ((index[collectionName] || {})[key] || [])];
778 }
779
780 function listCapabilities ( pluginsDir ) {
781 const index = loadIndex (pluginsDir);
782 const allowlist = new Set (
783 ( loadRequiresDevelopment (pluginsDir).capabilities || []). map (( entry ) => entry.capability),
784 );
785 return Object. entries (index.capabilityIndex || {}). map (([ capability , plugins ]) => ({
786 capability,
787 plugins,
788 hasNativeTarget: ! allowlist. has (capability),
789 }));
790 }
791
792 // Route rules consumed by wp-route-classifier.js. Built from profiles so adding a plugin
793 // never means editing classifier code.
794 function buildRouteRules ( pluginsDir ) {
795 const dataRules = [];
796 const excludeRules = [];
797
798 for ( const fileName of listProfileFiles (pluginsDir)) {
799 const profile = readJson (path. join (pluginsDir, fileName));
800 const slug = profile.plugin;
801
802 for ( const rule of profile.excludeRoutes || []) {
803 excludeRules. push ([rule.route, `plugin.${ slug }.exclude` , rule.reason]);
804 }
805 for ( const entity of profile.entities || []) {
806 const route = entityRouteOf (entity);
807 if (route) {
808 dataRules. push ([
809 route,
810 `plugin.${ slug }.${ entity . entity }` ,
811 `${ profile . displayName } ${ entity . entity } records are durable plugin data` ,
812 ]);
813 }
814 }
815 for ( const rule of profile.dataRoutePatterns || []) {
816 dataRules. push ([rule.pattern, `plugin.${ slug }.namespace` , rule.reason]);
817 }
818 }
819
820 // Longest pattern first so a specific route beats a namespace-wide pattern from the same
821 // or another profile.
822 dataRules. sort (( a , b ) => b[ 0 ]. length - a[ 0 ]. length || a[ 0 ]. localeCompare (b[ 0 ]));
823 excludeRules. sort (( a , b ) => b[ 0 ]. length - a[ 0 ]. length || a[ 0 ]. localeCompare (b[ 0 ]));
824 return { dataRules, excludeRules };
825 }
826
827 // Route -> { itemsPath, countPath } for entities whose profile declares a non-standard
828 // response envelope (e.g. MailPoet's `{ data: { items: [...], meta: { count } } }` instead of
829 // a flat array + X-WP-Total header). Built from profiles, like buildRouteRules, so adding a
830 // plugin with a wrapped response never means editing the sampler.
831 function buildResponseEnvelopes ( pluginsDir ) {
832 const envelopes = new Map ();
833 for ( const fileName of listProfileFiles (pluginsDir)) {
834 const profile = readJson (path. join (pluginsDir, fileName));
835 for ( const entity of profile.entities || []) {
836 const route = entityRouteOf (entity);
837 if (route && entity.responseEnvelope) {
838 envelopes. set (route, entity.responseEnvelope);
839 }
840 }
841 }
842 return envelopes;
843 }
844
845 // Route -> { method, body } for entities whose only read path is not a plain GET
846 // collection (e.g. a plugin that implements "list" as a POST with a JSON body instead of
847 // query params — spec 0044). Built from profiles, like buildResponseEnvelopes, so any
848 // future waitlist-shaped plugin needs only a profile entry, never a sampler change.
849 // `body` may contain the placeholder string "$SAMPLED_IDS:<route>" on any leaf value,
850 // resolved at sample time (wp-discovery.js resolveRequestBody) to the ids already
851 // collected for that route earlier in the same discovery run — generic across any
852 // entity/route pair, not hardcoded to one plugin's id field.
853 function buildRequestOverrides ( pluginsDir ) {
854 const overrides = new Map ();
855 for ( const fileName of listProfileFiles (pluginsDir)) {
856 const profile = readJson (path. join (pluginsDir, fileName));
857 for ( const entity of profile.entities || []) {
858 const route = entityRouteOf (entity);
859 if (route && entity.requestMethod && entity.requestMethod !== 'GET' ) {
860 overrides. set (route, { method: entity.requestMethod, body: entity.requestBody || {} });
861 }
862 }
863 }
864 return overrides;
865 }
866
867 // Route -> N for entities whose profile declares that each logical record arrives as N
868 // separate flat array entries instead of one object (e.g. Back In Stock Notifier's
869 // list_subscriber, verified live 2026-08-19: 4 single-key entries per subscriber). Built
870 // from profiles, like buildResponseEnvelopes/buildRequestOverrides, so any future
871 // fragmented-response plugin needs only a profile entry, never a sampler change.
872 // Route -> recordKeyField for entities that declare one. Used to dedupe merged batch results
873 // in the $SAMPLED_IDS pagination/batching mechanism (spec 0044) — a profile whose records key
874 // on something other than `id` (e.g. subscriptions-for-woocommerce's `subscription_id`) would
875 // otherwise silently dedupe on the wrong field, or not at all. Built from profiles, like the
876 // other route-level lookups in this file.
877 function buildRecordKeyFields ( pluginsDir ) {
878 const keyFields = new Map ();
879 for ( const fileName of listProfileFiles (pluginsDir)) {
880 const profile = readJson (path. join (pluginsDir, fileName));
881 for ( const entity of profile.entities || []) {
882 const route = entityRouteOf (entity);
883 if (route && entity.recordKeyField) {
884 keyFields. set (route, entity.recordKeyField);
885 }
886 }
887 }
888 return keyFields;
889 }
890
891 function buildResponseFragmentGroups ( pluginsDir ) {
892 const groups = new Map ();
893 for ( const fileName of listProfileFiles (pluginsDir)) {
894 const profile = readJson (path. join (pluginsDir, fileName));
895 for ( const entity of profile.entities || []) {
896 const route = entityRouteOf (entity);
897 if (route && entity.responseFragmentGroupSize) {
898 groups. set (route, entity.responseFragmentGroupSize);
899 }
900 }
901 }
902 return groups;
903 }
904
905 module . exports = {
906 CHANNELS,
907 ROUTE_CHANNELS,
908 CHILD_ROUTE_PLACEHOLDER,
909 validateEntity,
910 validateProfile,
911 validateBlockedEntries,
912 blockedEntriesOf,
913 validateNoMigrationCapabilities,
914 pluginsRoot,
915 listProfileFiles,
916 loadProfiles,
917 loadRequiresDevelopment,
918 loadFingerprintAliases,
919 loadPendingDecisions,
920 loadNoMigrationNeeded,
921 loadNoMigrationCapabilities,
922 generateIndex,
923 validateKnowledge,
924 writeJson,
925 loadIndex,
926 listPlugins,
927 readProfile,
928 resolveRoute,
929 resolveFromIndexKey,
930 listCapabilities,
931 buildRouteRules,
932 buildResponseEnvelopes,
933 buildResponseFragmentGroups,
934 buildRecordKeyFields,
935 buildRequestOverrides,
936 patternMatchesRoute,
937 wildcardKeepsScope,
938 entityRouteOf,
939 };