Setting the file. One moment.
Wp Discovery · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 43.3
Position 3 of 46
Type JavaScript
Size 66 KB
Lines 1,600 scripts/ wp-discovery.js
JavaScript · 1,600 lines · 66 KB
);
7
8 // Transport, auth, throttling, and pagination-header parsing live in the shared
9 // adapter lib so the bulk-extract reader generated by rp-import-codegen inherits
10 // the exact same discipline instead of re-deriving it.
11 const {
12 DEFAULT_TIMEOUT_MS ,
13 DEFAULT_RATE_LIMIT_RPM ,
14 DEFAULT_MAX_RETRIES ,
15 configureRateLimit ,
16 buildHeaders ,
17 normalizeBaseUrl ,
18 fetchJson ,
19 parseTotalHeader ,
20 } = require ( '../lib/wp-http.js' );
21 const {
22 classifyRoutes ,
23 summarizeSkippedByCategory ,
24 buildRegisteredRestBases ,
25 defaultPluginRules ,
26 defaultQueryFor ,
27 defaultQueryReasonFor ,
28 } = require ( '../lib/wp-route-classifier.js' );
29 // Plugin awareness: detection runs before classification so profile routes are
30 // in scope; embedded detection and Tier-B derivation run after sampling because they need
31 // real record payloads and the accepted route set.
32 const { gatherInventory , inventoryPayload } = require ( './wp-plugin-inventory.js' );
33 const {
34 pluginsRoot ,
35 loadProfiles ,
36 loadNoMigrationNeeded ,
37 loadRequiresDevelopment ,
38 loadPendingDecisions ,
39 loadFingerprintAliases ,
40 CHILD_ROUTE_PLACEHOLDER ,
41 buildResponseEnvelopes ,
42 buildRequestOverrides ,
43 buildResponseFragmentGroups ,
44 buildRecordKeyFields ,
45 } = require ( '../lib/plugin-knowledge.js' );
46 const {
47 buildDispositionRows ,
48 summarizeDispositions ,
49 } = require ( '../lib/plugin-disposition.js' );
50 const {
51 detectPlugins ,
52 collectRecordProperties ,
53 collectUnprofiledRoutes ,
54 collectCandidateNamespaces ,
55 deriveGenericEntities ,
56 classifyCoverage ,
57 coverageSummary ,
58 } = require ( '../lib/wp-plugin-detect.js' );
59 const {
60 collectAllIds ,
61 queryInBatches ,
62 DEFAULT_BATCH_SIZE ,
63 } = require ( '../lib/sampled-ids-batch.js' );
64
65 const DEFAULT_SAMPLE_LIMIT = 3 ;
66 let progress;
67
68 function printUsage () {
69 console. log ( `Usage:
70 node wp-discovery.js --base-url <url> --out-dir <dir> [auth options]
71
72 Required:
73 --base-url <url> WordPress site base URL, e.g. https://example.com
74 --out-dir <dir> Directory to write discovery markdown files into
75 --decisions <path> orchestration/decisions.json to read batched-ask answers from
76 (default: <out-dir>/../../orchestration/decisions.json)
77 --env-file <path> Load defaults from a project-local env file
78
79 Authentication options:
80 --username <name> WordPress username for Application Password auth
81 --application-password <pw> WordPress Application Password
82 --api-key <token> API key/token for custom auth setups
83 --api-key-header <name> Header name for --api-key. Defaults to Authorization
84 --auth-header <'Name: Value'> Add a raw HTTP header. Can be repeated.
85
86 Optional:
87 --sample-limit <n> Number of sample records per entity. Default: 3
88 --timeout-ms <n> Request timeout in ms. Default: 60000
89 --rate-limit-rpm <n> Max requests per minute. Default: 120
90 --max-retries <n> Retries on 429/503 (honors Retry-After). Default: 3
91 --commerce-mode <mode> WooCommerce read mode: public | authenticated.
92 Defaults to public without auth, otherwise authenticated.
93 --no-plugin-inventory Skip plugin detection, Tier-B derivation, and coverage.
94 --no-html-fingerprint Skip the homepage fetch used for asset-path fingerprints.
95 --include-namespace <ns> Only inspect a namespace. Can be repeated.
96 --include-route <path> Force-sample a route. Can be repeated.
97 --include-excluded-category <category>
98 Force-sample an excluded category. Can be repeated.
99 --exclude-route <path> Skip a route even if otherwise sampled. Can be repeated.
100 --override-reason <text> Reason recorded for include/exclude overrides.
101 --progress-log <path> Append progress NDJSON records to this file.
102 --help Show this help text
103
104 Examples:
105 node wp-discovery.js \
106 --base-url https://example.com \
107 --out-dir migrations/acme/data/wp-discovery \
108 --username admin \
109 --application-password 'abcd efgh ijkl mnop'
110
111 node wp-discovery.js \
112 --base-url https://example.com \
113 --out-dir migrations/acme/data/wp-discovery \
114 --api-key $WP_API_KEY \
115 --api-key-header X-API-Key
116 ` );
117 }
118
119 function parseArgs ( argv ) {
120 const args = {
121 authHeaders: [],
122 includeNamespaces: [],
123 includeRoutes: [],
124 includeExcludedCategories: [],
125 excludeRoutes: [],
126 overrideReason: null ,
127 sampleLimit: DEFAULT_SAMPLE_LIMIT ,
128 timeoutMs: DEFAULT_TIMEOUT_MS ,
129 rateLimitRpm: DEFAULT_RATE_LIMIT_RPM ,
130 maxRetries: DEFAULT_MAX_RETRIES ,
131 commerceMode: null ,
132 pluginInventory: true ,
133 htmlFingerprint: true ,
134 };
135
136 for ( let i = 0 ; i < argv. length ; i += 1 ) {
137 const arg = argv[i];
138 const next = argv[i + 1 ];
139
140 switch (arg) {
141 case '--help' :
142 case '-h' :
143 args.help = true ;
144 break ;
145 case '--base-url' :
146 args.baseUrl = next;
147 i += 1 ;
148 break ;
149 case '--out-dir' :
150 args.outDir = next;
151 i += 1 ;
152 break ;
153 case '--decisions' :
154 args.decisions = next;
155 i += 1 ;
156 break ;
157 case '--env-file' :
158 args.envFile = next;
159 i += 1 ;
160 break ;
161 case '--username' :
162 args.username = next;
163 i += 1 ;
164 break ;
165 case '--application-password' :
166 args.applicationPassword = next;
167 i += 1 ;
168 break ;
169 case '--api-key' :
170 args.apiKey = next;
171 i += 1 ;
172 break ;
173 case '--api-key-header' :
174 args.apiKeyHeader = next;
175 i += 1 ;
176 break ;
177 case '--auth-header' :
178 args.authHeaders. push (next);
179 i += 1 ;
180 break ;
181 case '--sample-limit' :
182 args.sampleLimit = Number. parseInt (next, 10 );
183 i += 1 ;
184 break ;
185 case '--timeout-ms' :
186 args.timeoutMs = Number. parseInt (next, 10 );
187 i += 1 ;
188 break ;
189 case '--rate-limit-rpm' :
190 args.rateLimitRpm = Number. parseInt (next, 10 );
191 i += 1 ;
192 break ;
193 case '--max-retries' :
194 args.maxRetries = Number. parseInt (next, 10 );
195 i += 1 ;
196 break ;
197 case '--commerce-mode' :
198 args.commerceMode = next;
199 i += 1 ;
200 break ;
201 case '--include-namespace' :
202 args.includeNamespaces. push (next);
203 i += 1 ;
204 break ;
205 case '--include-route' :
206 args.includeRoutes. push (next);
207 i += 1 ;
208 break ;
209 case '--include-excluded-category' :
210 args.includeExcludedCategories. push (next);
211 i += 1 ;
212 break ;
213 case '--exclude-route' :
214 args.excludeRoutes. push (next);
215 i += 1 ;
216 break ;
217 case '--override-reason' :
218 args.overrideReason = next;
219 i += 1 ;
220 break ;
221 case '--no-plugin-inventory' :
222 args.pluginInventory = false ;
223 break ;
224 case '--no-html-fingerprint' :
225 args.htmlFingerprint = false ;
226 break ;
227 default :
228 if (arg. startsWith ( '--' )) {
229 throw new Error ( `Unknown argument: ${ arg }` );
230 }
231 }
232 }
233
234 if ( ! args.baseUrl) {
235 args.baseUrl = process.env. WP_BASE_URL || process.env. WP_SITE_URL ;
236 }
237 if ( ! args.outDir) {
238 args.outDir = process.env. WP_DISCOVERY_OUT_DIR ;
239 }
240 if ( ! args.apiKey) {
241 args.apiKey = process.env. WP_API_KEY ;
242 }
243 if ( ! args.apiKeyHeader) {
244 args.apiKeyHeader = process.env. WP_API_KEY_HEADER ;
245 }
246 if ( ! args.username) {
247 args.username = process.env. WP_USERNAME ;
248 }
249 if ( ! args.applicationPassword) {
250 args.applicationPassword = process.env. WP_APPLICATION_PASSWORD ;
251 }
252 if (args.authHeaders. length === 0 && process.env. WP_AUTH_HEADER ) {
253 args.authHeaders. push (process.env. WP_AUTH_HEADER );
254 }
255
256 if ( ! Number. isFinite (args.sampleLimit) || args.sampleLimit < 1 ) {
257 args.sampleLimit = DEFAULT_SAMPLE_LIMIT ;
258 }
259 if ( ! Number. isFinite (args.timeoutMs) || args.timeoutMs < 1000 ) {
260 args.timeoutMs = DEFAULT_TIMEOUT_MS ;
261 }
262 if ( ! Number. isFinite (args.rateLimitRpm) || args.rateLimitRpm < 1 ) {
263 args.rateLimitRpm = DEFAULT_RATE_LIMIT_RPM ;
264 }
265 if ( ! Number. isFinite (args.maxRetries) || args.maxRetries < 0 ) {
266 args.maxRetries = DEFAULT_MAX_RETRIES ;
267 }
268 if (args.commerceMode !== 'public' && args.commerceMode !== 'authenticated' ) {
269 args.commerceMode = (args.username && args.applicationPassword) || args.apiKey || args.authHeaders. length > 0
270 ? 'authenticated'
271 : 'public' ;
272 }
273
274 return args;
275 }
276
277 async function hydrateArgsFromEnvFile ( args ) {
278 if ( ! args.envFile) {
279 return args;
280 }
281
282 const envValues = await readEnvFile (path. resolve (args.envFile));
283 if ( ! args.baseUrl && envValues. WP_BASE_URL ) {
284 args.baseUrl = envValues. WP_BASE_URL ;
285 }
286 if ( ! args.username && envValues. WP_USERNAME ) {
287 args.username = envValues. WP_USERNAME ;
288 }
289 if ( ! args.applicationPassword && envValues. WP_APPLICATION_PASSWORD ) {
290 args.applicationPassword = envValues. WP_APPLICATION_PASSWORD ;
291 }
292 if ( ! args.apiKey && envValues. WP_API_KEY ) {
293 args.apiKey = envValues. WP_API_KEY ;
294 }
295 if ( ! args.apiKeyHeader && envValues. WP_API_KEY_HEADER ) {
296 args.apiKeyHeader = envValues. WP_API_KEY_HEADER ;
297 }
298 if (args.authHeaders. length === 0 && envValues. WP_AUTH_HEADER ) {
299 args.authHeaders. push (envValues. WP_AUTH_HEADER );
300 }
301
302 return args;
303 }
304
305 function endpointMethods ( endpoint ) {
306 const raw = endpoint?.methods;
307 if (Array. isArray (raw)) {
308 return raw. map (String);
309 }
310 if ( typeof raw === 'string' ) {
311 return raw. split ( ',' ). map (( value ) => value. trim ()). filter (Boolean);
312 }
313 if (raw && typeof raw === 'object' ) {
314 return Object. keys (raw);
315 }
316 return [];
317 }
318
319 function routeSegments ( routePath ) {
320 return routePath. split ( '/' ). filter (Boolean);
321 }
322
323 function summarizeRelationships ( record ) {
324 const links = record?._links;
325 if ( ! links || typeof links !== 'object' ) {
326 return [];
327 }
328 // HAL housekeeping rels carry no entity relationship signal.
329 const ignored = new Set ([ 'self' , 'collection' , 'about' , 'curies' ]);
330 const relationships = [];
331
332 for ( const [ rel , entries ] of Object. entries (links)) {
333 if (ignored. has (rel)) {
334 continue ;
335 }
336 const list = Array. isArray (entries) ? entries : [entries];
337 const hrefs = list. map (( entry ) => entry?.href). filter (Boolean);
338 if (hrefs. length === 0 ) {
339 continue ;
340 }
341 relationships. push ({
342 rel,
343 embeddable: list. some (( entry ) => entry?.embeddable === true ),
344 hrefs,
345 });
346 }
347
348 return relationships;
349 }
350
351 function isParameterizedRoute ( routePath ) {
352 return routePath. includes ( '(?P<' );
353 }
354
355 function slugify ( value ) {
356 return value
357 . toLowerCase ()
358 . replace ( / [ ^ a-z0-9] + / g , '-' )
359 . replace ( / ^ - +| - +$ / g , '' ) || 'entity' ;
360 }
361
362 function summarizeAuthMode ( args ) {
363 if (args.username && args.applicationPassword) {
364 return 'basic-application-password' ;
365 }
366 if (args.apiKey) {
367 return `api-key:${ args . apiKeyHeader || 'Authorization'}` ;
368 }
369 if (args.authHeaders. length > 0 ) {
370 return 'custom-header' ;
371 }
372 return 'none' ;
373 }
374
375 function summarizeOverrides ( args ) {
376 return {
377 includeRoutes: args.includeRoutes,
378 includeNamespaces: args.includeNamespaces,
379 includeExcludedCategories: args.includeExcludedCategories,
380 excludeRoutes: args.excludeRoutes,
381 overrideReason: args.overrideReason,
382 commerceMode: args.commerceMode,
383 };
384 }
385
386 function deriveEntityCandidates ( indexJson , includeNamespaces , requestOverrides = new Map ()) {
387 const routes = indexJson?.routes || {};
388 const candidates = [];
389
390 for ( const [ routePath , routeDefinition ] of Object. entries (routes)) {
391 if ( ! routePath. startsWith ( '/' ) || routePath === '/' || isParameterizedRoute (routePath)) {
392 continue ;
393 }
394
395 const segments = routeSegments (routePath);
396 if (segments. length < 2 ) {
397 continue ;
398 }
399
400 const namespace = segments. slice ( 0 , 2 ). join ( '/' );
401 if (includeNamespaces. length > 0 && ! includeNamespaces. includes (namespace)) {
402 continue ;
403 }
404
405 const endpoints = Array. isArray (routeDefinition?.endpoints) ? routeDefinition.endpoints : [];
406 // A profile may declare that this entity's real read path is not GET (spec 0044,
407 // plugin-knowledge.js buildRequestOverrides) — the declared method then stands in for
408 // the GET-collection requirement below, generically for any plugin/entity.
409 const requestOverride = requestOverrides. get (routePath) || null ;
410 const getEndpoint = requestOverride
411 ? endpoints. find (( endpoint ) => endpointMethods (endpoint). includes (requestOverride.method))
412 : endpoints. find (( endpoint ) => endpointMethods (endpoint). includes ( 'GET' ));
413 if ( ! getEndpoint) {
414 continue ;
415 }
416
417 const entityName = segments[segments. length - 1 ];
418 const supportsPagination = Boolean (getEndpoint?.args?.page || getEndpoint?.args?.per_page);
419 const hasSchema = Boolean (getEndpoint?.schema || routeDefinition?.schema);
420
421 // The generic shape heuristic (schema or pagination or 3+ path segments) exists to
422 // filter out config/dashboard noise when nothing else vouches for a route. A profile
423 // explicitly declaring a request override already is that vouching — same principle as
424 // profile-declared routes outranking the classifier's shape rules (Appendix E, spec 0013).
425 if ( ! requestOverride && ! hasSchema && ! supportsPagination && segments. length < 3 ) {
426 continue ;
427 }
428
429 // Derive the file name from the full path after the namespace so distinct
430 // routes that share a last segment (e.g. /wp/v2/categories vs.
431 // /wp/v2/block-patterns/categories) do not collide onto one file.
432 const pathSlug = slugify (segments. slice ( 2 ). join ( '-' )) || slugify (entityName);
433
434 candidates. push ({
435 entityName,
436 namespace,
437 routePath,
438 endpoints,
439 getEndpoint,
440 routeDefinition,
441 supportsPagination,
442 requestOverride,
443 fileName: `${ slugify ( namespace ) }--${ pathSlug }.md` ,
444 });
445 }
446
447 const unique = new Map ();
448 for ( const candidate of candidates) {
449 const key = `${ candidate . namespace }:${ candidate . routePath }` ;
450 if ( ! unique. has (key)) {
451 unique. set (key, candidate);
452 }
453 }
454
455 return [ ... unique. values ()]. sort (( a , b ) => a.routePath. localeCompare (b.routePath));
456 }
457
458 // Finds every route named by a "$SAMPLED_IDS:<route>" placeholder (spec 0044) anywhere
459 // inside a requestOverride body, however deeply nested. A body may reference more than one
460 // route (e.g. two independent id lists); collecting all of them, not just the first match, is
461 // what lets the pagination+batch mechanism below (lib/sampled-ids-batch.js) discover every
462 // route it must paginate to exhaustion rather than resolving one placeholder to []. Generic
463 // body-walk, no field names baked in.
464 function findSampledIdsDependencies ( value , found = new Set ()) {
465 if (Array. isArray (value)) {
466 for ( const item of value) {
467 findSampledIdsDependencies (item, found);
468 }
469 } else if (value && typeof value === 'object' ) {
470 for ( const nested of Object. values (value)) {
471 findSampledIdsDependencies (nested, found);
472 }
473 } else if ( typeof value === 'string' ) {
474 const match = value. match ( / ^ \$ SAMPLED_IDS:( . + ) $ / );
475 if (match) found. add (match[ 1 ]);
476 }
477 return found;
478 }
479
480 // Resolves a dot-path ("data.items", "data.meta.count") against a response body for a
481 // responseEnvelope-declaring entity. Returns undefined on any missing/non-object segment
482 // rather than throwing, so an envelope path that stops matching (a plugin update, a stale
483 // profile) degrades to the safe fallback in inspectEntity instead of crashing the run.
484 function getAtPath ( value , dotPath ) {
485 return String (dotPath). split ( '.' ). reduce (
486 ( acc , key ) => (acc && typeof acc === 'object' ? acc[key] : undefined ),
487 value,
488 );
489 }
490
491 // Deep-resolves a profile-declared requestBody template against a batch's ids. `idsByRoute` is
492 // a plain `{ route: string[] }` map — for the $SAMPLED_IDS mechanism (spec 0044/0046) this is
493 // always the CURRENT BATCH's ids for the route being paginated, resolved fresh per batch, never
494 // a fixed full list baked into one oversized request. Generic across any entity/route pair —
495 // the placeholder names the OTHER route's ids it wants, nothing plugin-specific lives here.
496 function resolveRequestBody ( template , idsByRoute ) {
497 if (Array. isArray (template)) {
498 return template. map (( value ) => resolveRequestBody (value, idsByRoute));
499 }
500 if (template && typeof template === 'object' ) {
501 const resolved = {};
502 for ( const [ key , value ] of Object. entries (template)) {
503 resolved[key] = resolveRequestBody (value, idsByRoute);
504 }
505 return resolved;
506 }
507 if ( typeof template === 'string' ) {
508 const match = template. match ( / ^ \$ SAMPLED_IDS:( . + ) $ / );
509 if (match) {
510 return (idsByRoute && idsByRoute[match[ 1 ]]) || [];
511 }
512 }
513 return template;
514 }
515
516 // Envelope resolution + fragment-group reassembly, shared by the single-fetch path below and
517 // by every batch of the paginate+batch path (lib/sampled-ids-batch.js) — one profile's response
518 // shape must normalize the same way regardless of which path fetched it. Pushes discovery
519 // notes only when `notes` is provided (batched callers pass it for the first batch only, since
520 // the shape characteristic belongs to the endpoint, not to any one batch). Returns `{ ok,
521 // records }`: `ok: false` means the payload did not resolve to an array after envelope
522 // resolution (when declared) and fragment reassembly (when declared) — a genuine shape
523 // mismatch, not "zero records" — and callers on the batched path must treat that as a failure
524 // to defer on, never silently as an empty batch (an enveloped response whose itemsPath
525 // mismatches would otherwise look like an authoritative, exact zero).
526 function normalizeResponseRecords ( payload , { envelope , fragmentGroupSize , notes } = {}) {
527 let effectivePayload = payload;
528 if (envelope) {
529 const items = getAtPath (payload, envelope.itemsPath);
530 if (Array. isArray (items)) {
531 effectivePayload = items;
532 } else if (notes) {
533 notes. push ( `responseEnvelope.itemsPath="${ envelope . itemsPath }" did not resolve to an array on this response; falling back to the raw payload shape. The profile may be stale or the plugin version differs.` );
534 }
535 }
536 if (fragmentGroupSize && Array. isArray (effectivePayload)) {
537 const completeGroups = Math. floor (effectivePayload. length / fragmentGroupSize);
538 const reassembled = [];
539 for ( let i = 0 ; i < completeGroups; i += 1 ) {
540 reassembled. push (Object. assign ({}, ... effectivePayload. slice (i * fragmentGroupSize, (i + 1 ) * fragmentGroupSize)));
541 }
542 if (notes && effectivePayload. length % fragmentGroupSize !== 0 ) {
543 notes. push ( `Response length ${ effectivePayload . length } is not a multiple of the declared responseFragmentGroupSize=${ fragmentGroupSize }; the trailing ${ effectivePayload . length % fragmentGroupSize } fragment(s) were dropped rather than emitted as a broken record.` );
544 }
545 if (notes) {
546 notes. push ( `Response records were flattened into groups of ${ fragmentGroupSize } single-key fragments (per profile responseFragmentGroupSize) and reassembled into one object per record.` );
547 }
548 effectivePayload = reassembled;
549 }
550 const ok = Array. isArray (effectivePayload);
551 return { ok, records: ok ? effectivePayload : [] };
552 }
553
554 async function inspectEntity ( baseUrl , headers , candidate , options ) {
555 const details = {
556 entityName: candidate.entityName,
557 namespace: candidate.namespace,
558 routePath: candidate.routePath,
559 fileName: candidate.fileName,
560 classification: candidate.classification || null ,
561 methods: [ ...new Set (candidate.endpoints. flatMap (endpointMethods))]. sort (),
562 supportsPagination: candidate.supportsPagination,
563 discoveryNotes: [],
564 requestErrors: [],
565 collectionArgs: candidate.getEndpoint?.args || null ,
566 schema: candidate.getEndpoint?.schema || candidate.routeDefinition?.schema || null ,
567 optionsSchema: null ,
568 sampleRecords: [],
569 sampleRecordCount: 0 ,
570 recordCount: null ,
571 inUse: null ,
572 relationships: [],
573 responseShape: 'unknown' ,
574 };
575
576 const optionsResponse = await fetchJson (baseUrl, candidate.routePath, {
577 headers,
578 method: 'OPTIONS' ,
579 timeoutMs: options.timeoutMs,
580 progress: options.progress,
581 progressContext: {
582 step: 'inspect-endpoint' ,
583 entity: candidate.routePath,
584 },
585 });
586
587 if (optionsResponse.ok && optionsResponse.json) {
588 const optionEndpoints = Array. isArray (optionsResponse.json?.endpoints) ? optionsResponse.json.endpoints : [];
589 const getEndpoint = optionEndpoints. find (( endpoint ) => endpointMethods (endpoint). includes ( 'GET' ));
590 if (getEndpoint?.args) {
591 details.collectionArgs = getEndpoint.args;
592 }
593 if (optionsResponse.json?.schema) {
594 details.optionsSchema = optionsResponse.json.schema;
595 details.schema = optionsResponse.json.schema;
596 } else if (getEndpoint?.schema) {
597 details.optionsSchema = getEndpoint.schema;
598 details.schema = getEndpoint.schema;
599 }
600 } else if (optionsResponse.status !== 404 && optionsResponse.status !== 405 ) {
601 details.requestErrors. push ({
602 request: 'OPTIONS' ,
603 routePath: candidate.routePath,
604 status: optionsResponse.status,
605 statusText: optionsResponse.statusText,
606 url: optionsResponse.url,
607 });
608 }
609
610 // A profile may declare that this entity's real read path is a non-GET request with a
611 // JSON body (spec 0044) — generic across any plugin/entity, not special-cased here.
612 const requestOverride = options.requestOverride || null ;
613 const dependencyRoutes = requestOverride ? [ ... findSampledIdsDependencies (requestOverride.body)] : [];
614
615 if (requestOverride && dependencyRoutes. length > 0 ) {
616 // The $SAMPLED_IDS mechanism always means "paginate the dependency route to exhaustion,
617 // then batch-query this route" (spec 0044) — the dependency's record count is unknown, so
618 // there is no bounded "small sample" variant that is safe to assume complete. This is the
619 // ENTIRE meaning of a $SAMPLED_IDS placeholder now, in both discovery and any generated
620 // reader (see rp-import-codegen's sourceMeta contract).
621 return inspectDependentEntity (baseUrl, headers, candidate, options, details, requestOverride, dependencyRoutes);
622 }
623
624 let sampleResponse;
625 if (requestOverride) {
626 // A fixed-body override with no $SAMPLED_IDS placeholder — single request, unbounded by
627 // definition since nothing here scales with another route's record count.
628 const resolvedBody = resolveRequestBody (requestOverride.body, {});
629 details.discoveryNotes. push (
630 `Sampled via profile-declared ${ requestOverride . method } request with a JSON body, not a plain GET collection (spec 0044 request override).` ,
631 );
632 sampleResponse = await fetchJson (baseUrl, candidate.routePath, {
633 // `headers` is a real Headers instance (wp-http.js buildHeaders) — spreading it
634 // would silently drop every entry including Authorization, since Headers is not a
635 // plain object. Clone it properly instead.
636 headers: (() => {
637 const withBody = new Headers (headers);
638 withBody. set ( 'content-type' , 'application/json' );
639 return withBody;
640 })(),
641 method: requestOverride.method,
642 body: JSON . stringify (resolvedBody),
643 timeoutMs: options.timeoutMs,
644 progress: options.progress,
645 progressContext: {
646 step: 'inspect-endpoint' ,
647 entity: candidate.routePath,
648 },
649 });
650 } else {
651 // Per-route default query (see ROUTE_DEFAULT_QUERY_RULES): some collection routes apply
652 // a default filter when the caller sends none, so sampling with `per_page` alone reads a
653 // subset and the plan under-counts. Paging is layered on top; a route with no rule is
654 // unchanged.
655 const defaultQuery = options.defaultQuery || {};
656 const query = {
657 ... defaultQuery,
658 ... (candidate.supportsPagination ? { per_page: options.sampleLimit } : {}),
659 };
660 if (Object. keys (defaultQuery). length > 0 ) {
661 const pairs = Object. entries (defaultQuery). map (([ key , value ]) => `${ key }=${ value }` ). join ( '&' );
662 const reason = options.defaultQueryReason ? ` — ${ options . defaultQueryReason }` : '' ;
663 details.discoveryNotes. push ( `Sampled with route default query ${ pairs } so the full collection is counted${ reason }. Generated readers must send the same parameters.` );
664 }
665
666 sampleResponse = await fetchJson (baseUrl, candidate.routePath, {
667 headers,
668 method: 'GET' ,
669 query,
670 timeoutMs: options.timeoutMs,
671 progress: options.progress,
672 progressContext: {
673 step: 'inspect-endpoint' ,
674 entity: candidate.routePath,
675 },
676 });
677 }
678
679 if (sampleResponse.ok) {
680 const payload = sampleResponse.json;
681 const totalFromHeader = parseTotalHeader (sampleResponse.headers);
682
683 // responseEnvelope support (finding #28): some plugin REST APIs (MailPoet's
684 // /mailpoet/v1/* namespace, verified live 2026-08-11) wrap their records in a body path
685 // instead of returning a flat array, and carry no X-WP-Total/X-WP-TotalPages headers —
686 // the total lives inside the body too. Unwrap to the declared path and otherwise treat it
687 // exactly like a flat array; a path that no longer resolves (stale profile, plugin
688 // version drift) falls back to the raw payload rather than failing the run.
689 const envelope = options.responseEnvelope || null ;
690 let effectivePayload = payload;
691 let envelopeCount = null ;
692 if (envelope) {
693 const items = getAtPath (payload, envelope.itemsPath);
694 if (Array. isArray (items)) {
695 effectivePayload = items;
696 envelopeCount = envelope.countPath ? getAtPath (payload, envelope.countPath) : null ;
697 } else {
698 details.discoveryNotes. push ( `responseEnvelope.itemsPath="${ envelope . itemsPath }" did not resolve to an array on this response; falling back to the raw payload shape. The profile may be stale or the plugin version differs.` );
699 }
700 }
701
702 // responseFragmentGroupSize support: some plugin REST APIs (Back In Stock Notifier's
703 // list_subscriber, verified live 2026-08-19) emit each logical record as N separate
704 // single-key array entries in sequence instead of one merged object. Reassemble every N
705 // entries into one record via Object.assign before anything downstream counts or samples
706 // — a reader built from a stale sample would otherwise see 4x the real record count and a
707 // schema missing whichever key happened to be sampled out of order. A trailing partial
708 // group (malformed response, plugin bug) is dropped and noted rather than emitted broken.
709 const fragmentGroupSize = options.responseFragmentGroupSize || null ;
710 if (fragmentGroupSize && Array. isArray (effectivePayload)) {
711 effectivePayload = normalizeResponseRecords (effectivePayload, { fragmentGroupSize, notes: details.discoveryNotes }).records;
712 }
713
714 if (Array. isArray (effectivePayload)) {
715 details.responseShape = envelope && effectivePayload !== payload ? 'enveloped-array' : 'array' ;
716 details.sampleRecords = effectivePayload. slice ( 0 , options.sampleLimit);
717 details.sampleRecordCount = effectivePayload. length ;
718 const hasEnvelopeCount = typeof envelopeCount === 'number' ;
719 details.recordCount = hasEnvelopeCount ? envelopeCount : (totalFromHeader !== null ? totalFromHeader : effectivePayload. length );
720 details.inUse = details.recordCount > 0 ;
721 if (details.responseShape === 'enveloped-array' && ! hasEnvelopeCount) {
722 details.discoveryNotes. push ( `responseEnvelope has no working countPath (itemsPath="${ envelope . itemsPath }"); recordCount reflects only the sampled page and may undercount the true total.` );
723 } else if (details.responseShape === 'array' && totalFromHeader === null ) {
724 details.discoveryNotes. push ( 'No X-WP-Total header returned; recordCount reflects only the sampled page and may undercount the true total.' );
725 }
726 if (candidate.routePath. startsWith ( '/wc/store/v1/' ) && ! Object. keys (sampleResponse.headers || {}). some (( key ) => key. toLowerCase () === 'x-wp-totalpages' )) {
727 details.discoveryNotes. push ( 'WooCommerce Store API route does not advertise X-WP-TotalPages; generated readers must stop on a short page when bulk-extracting this entity.' );
728 }
729 if (effectivePayload. length === 0 ) {
730 details.discoveryNotes. push ( 'Endpoint returned an empty array. The entity is advertised but appears unused (no records).' );
731 }
732 } else if (effectivePayload && typeof effectivePayload === 'object' ) {
733 details.responseShape = 'object' ;
734 details.sampleRecords = [effectivePayload];
735 details.sampleRecordCount = 1 ;
736 details.recordCount = totalFromHeader !== null ? totalFromHeader : 1 ;
737 details.inUse = details.recordCount > 0 ;
738 } else {
739 details.responseShape = typeof effectivePayload;
740 details.discoveryNotes. push ( `Endpoint returned a non-object payload of type ${ typeof effectivePayload }.` );
741 }
742
743 const firstRecord = details.sampleRecords[ 0 ];
744 if (firstRecord && typeof firstRecord === 'object' ) {
745 details.relationships = summarizeRelationships (firstRecord);
746 }
747 } else {
748 details.requestErrors. push ({
749 request: requestOverride ? requestOverride.method : 'GET' ,
750 routePath: candidate.routePath,
751 status: sampleResponse.status,
752 statusText: sampleResponse.statusText,
753 url: sampleResponse.url,
754 body: sampleResponse.text ? sampleResponse.text. slice ( 0 , 1000 ) : '' ,
755 });
756 }
757
758 return details;
759 }
760
761 // Paginates every $SAMPLED_IDS dependency route to exhaustion, then batch-queries `candidate`'s
762 // route (spec 0044) — split out of inspectEntity because it is a fundamentally different shape
763 // (two routes, many requests, an explicit fail/defer path) from a single-fetch inspection.
764 // Assumes a single batching axis: when a body references more than one dependency route, only
765 // the first drives batching (noted below) — no known profile needs two independent id lists
766 // batched together, and this stays honest about that boundary rather than guessing a policy.
767 async function inspectDependentEntity ( baseUrl , headers , candidate , options , details , requestOverride , dependencyRoutes ) {
768 const batchRoute = dependencyRoutes[ 0 ];
769 if (dependencyRoutes. length > 1 ) {
770 details.discoveryNotes. push (
771 `requestBody references multiple $SAMPLED_IDS routes (${ dependencyRoutes . join ( ', ' ) }); only the first, ${ batchRoute }, drives pagination/batching — a profile needing more than one independent id list is not yet supported generically.` ,
772 );
773 }
774
775 const paged = await collectAllIds ({
776 baseUrl,
777 headers,
778 route: batchRoute,
779 timeoutMs: options.timeoutMs,
780 progress: options.progress,
781 });
782 if ( ! paged.ok) {
783 // Fail/defer explicitly (spec 0044): recordCount/inUse stay at their initial `null`, which
784 // is this codebase's existing "unknown, not zero" signal, backed by a requestErrors entry
785 // explaining exactly which page of which route failed — never a false empty or a silent
786 // undercount from whatever ids were collected before the failure.
787 details.requestErrors. push ({
788 request: 'GET' ,
789 routePath: paged.failure.route,
790 status: paged.failure.status,
791 statusText: paged.failure.statusText,
792 url: null ,
793 body: `Failed while paginating the $SAMPLED_IDS dependency route ${ paged . failure . route } at page ${ paged . failure . page }. ${ candidate . routePath }'s record count and schema are unknown and must be treated as deferred, not reported as empty.` ,
794 });
795 return details;
796 }
797
798 details.discoveryNotes. push (
799 `Sampled via profile-declared ${ requestOverride . method } request with a JSON body (spec 0044 request override), against the FULL paginated id list of ${ batchRoute } (${ paged . ids . length } ids across ${ Math . ceil ( paged . ids . length / DEFAULT_BATCH_SIZE ) } batches of up to ${ DEFAULT_BATCH_SIZE }) rather than a fixed small sample.` ,
800 );
801
802 let shapeNotesEmitted = false ;
803 const batched = await queryInBatches ({
804 baseUrl,
805 headers,
806 route: candidate.routePath,
807 method: requestOverride.method,
808 ids: paged.ids,
809 recordKeyField: options.recordKeyField || 'id' ,
810 buildBody : ( batchIds ) => resolveRequestBody (requestOverride.body, { [batchRoute]: batchIds. map (String) }),
811 // Pass the RAW batch JSON through — response.json may be an enveloped object, not an
812 // array, and normalizeResponseRecords (not this callback) is what unwraps it. Returning
813 // its {ok, records} result unchanged is what lets queryInBatches distinguish "this batch's
814 // response didn't match the declared shape" from "this batch legitimately matched zero
815 // subscribers" and defer on the former instead of counting it as an exact zero.
816 normalizeBatch : ( rawJson ) => {
817 const normalized = normalizeResponseRecords (rawJson, {
818 envelope: options.responseEnvelope,
819 fragmentGroupSize: options.responseFragmentGroupSize,
820 notes: shapeNotesEmitted ? null : details.discoveryNotes,
821 });
822 shapeNotesEmitted = true ;
823 return normalized;
824 },
825 timeoutMs: options.timeoutMs,
826 progress: options.progress,
827 });
828 if ( ! batched.ok) {
829 const shapeFailure = batched.failure.reason === 'invalid-shape' ;
830 details.requestErrors. push ({
831 request: requestOverride.method,
832 routePath: batched.failure.route,
833 status: batched.failure.status,
834 statusText: batched.failure.statusText,
835 url: null ,
836 body: shapeFailure
837 ? `Batch ${ batched . failure . batchIndex + 1 } (${ batched . failure . batchSize } ids) returned a ${ batched . failure . status } response that did not match the declared response shape (responseEnvelope/responseFragmentGroupSize). Batches already merged before this failure are discarded — ${ candidate . routePath }'s record count and schema are unknown and must be treated as deferred, not reported as an exact zero.`
838 : `Failed on batch ${ batched . failure . batchIndex + 1 } (${ batched . failure . batchSize } ids). Batches already merged before this failure are discarded — ${ candidate . routePath }'s record count and schema are unknown and must be treated as deferred, not reported as a partial undercount.` ,
839 });
840 return details;
841 }
842
843 details.responseShape = 'array' ;
844 details.sampleRecords = batched.records. slice ( 0 , options.sampleLimit);
845 details.sampleRecordCount = batched.records. length ;
846 // Exact, not an estimate: every dependency id was queried and every batch's records were
847 // deduplicated, unlike the sampled-page count elsewhere in this function.
848 details.recordCount = batched.records. length ;
849 details.inUse = details.recordCount > 0 ;
850 details.discoveryNotes. push ( 'recordCount reflects the full deduplicated merge across every batch of the complete dependency id list, not a sampled page — this total is exact.' );
851 if (batched.records. length === 0 ) {
852 details.discoveryNotes. push ( 'No records matched any batch across the full paginated dependency id list. The entity is advertised but appears unused (no records).' );
853 }
854 const firstRecord = details.sampleRecords[ 0 ];
855 if (firstRecord && typeof firstRecord === 'object' ) {
856 details.relationships = summarizeRelationships (firstRecord);
857 }
858 return details;
859 }
860
861 function markdownJsonBlock ( value ) {
862 if (value === null || value === undefined ) {
863 return ' \n\n `Unavailable`' ;
864 }
865
866 return ` \n\n\`\`\` json \n ${ JSON . stringify ( value , null , 2 ) } \n\`\`\` ` ;
867 }
868
869 function renderEntityFile ( details ) {
870 const errorLines = details.requestErrors. length > 0
871 ? details.requestErrors. map (( error ) => `- ${ error . request } ${ error . routePath }: ${ error . status } ${ error . statusText }` ). join ( ' \n ' )
872 : '- None' ;
873
874 const notes = details.discoveryNotes. length > 0
875 ? details.discoveryNotes. map (( note ) => `- ${ note }` ). join ( ' \n ' )
876 : '- None' ;
877
878 const relationshipLines = details.relationships. length > 0
879 ? details.relationships
880 . map (( relationship ) => `- \` ${ relationship . rel } \` ${ relationship . embeddable ? ' (embeddable)' : ''} → ${ relationship . hrefs . join ( ', ' ) }` )
881 . join ( ' \n ' )
882 : '- None detected (no `_links` in sample record)' ;
883
884 const recordCountLabel = details.recordCount === null ? 'unknown' : String (details.recordCount);
885 const inUseLabel = details.inUse === null ? 'unknown' : details.inUse ? 'yes' : 'no (advertised but empty)' ;
886 const classificationLines = details.classification
887 ? `- Discovery category: \` ${ details . classification . category } \`\n ` +
888 `- Discovery rule: \` ${ details . classification . ruleId } \`\n ` +
889 `- Discovery reason: ${ details . classification . reason } \n ` +
890 `- Included by override: \` ${ details . classification . includedByOverride ? 'yes' : 'no'} \`\n ` +
891 `- Excluded by override: \` ${ details . classification . excludedByOverride ? 'yes' : 'no'} \`\n `
892 : '' ;
893
894 return `# ${ details . entityName } \n\n ` +
895 `- Namespace: \` ${ details . namespace } \`\n ` +
896 `- Route: \` ${ details . routePath } \`\n ` +
897 classificationLines +
898 `- Methods: ${ details . methods . map (( method ) => ` \` ${ method } \` ` ). join ( ', ' ) || '`unknown`'} \n ` +
899 `- Response shape: \` ${ details . responseShape } \`\n ` +
900 `- Record count: \` ${ recordCountLabel } \`\n ` +
901 `- In use: \` ${ inUseLabel } \`\n ` +
902 `- Sample records captured: ${ details . sampleRecords . length } \n\n ` +
903 `## Notes \n ${ notes } \n\n ` +
904 `## Relationships \n ${ relationshipLines } \n\n ` +
905 `## Request Errors \n ${ errorLines } \n\n ` +
906 `## Schema${ markdownJsonBlock ( details . schema ) } \n\n ` +
907 `## Collection Args${ markdownJsonBlock ( details . collectionArgs ) } \n\n ` +
908 `## Sample Records${ markdownJsonBlock ( details . sampleRecords ) } \n ` ;
909 }
910
911 function renderPluginSection ( context ) {
912 if ( ! context.plugins) {
913 return `## Plugin Coverage \n\n Plugin inventory was not run for this capture. \n\n ` ;
914 }
915
916 const { detection , coverage , summary , unprofiled } = context.plugins;
917 const rows = coverage
918 . map (( row ) => {
919 const via = row.status === 'migration-planned' ? `${ row . via } · ${ row . confidence }` : '-' ;
920 const blocked = (row.blocked || []). length > 0
921 ? row.blocked. map (( blocker ) => `${ blocker . kind }${ blocker . declined ? ' (declined)' : ''}` ). join ( ', ' )
922 : '-' ;
923 return `| ${ row . capability } | \` ${ row . status } \` | ${ via } | ${ row . recognized ? 'yes' : 'no'} | ${ row . plugins . join ( ', ' ) || '-'} | \` ${ row . channel || 'none'} \` | ${ blocked } | ${ row . action ? row . action : '-'} |` ;
924 })
925 . join ( ' \n ' );
926
927 const authNote = detection.pluginListAvailable
928 ? ''
929 : `** \` GET /wp/v2/plugins \` was unavailable**, so installed-but-unprofiled plugins could not be enumerated. ` +
930 `Detection used REST namespaces, declared routes, registered types/taxonomies, and public asset paths only. ` +
931 `Re-run with an administrator credential for a complete plugin list. \n\n ` ;
932
933 const installedRows = detection.installedButUnprofiled
934 . map (( entry ) => `- \` ${ entry . plugin } \` ${ entry . name ? ` (${ entry . name })` : ''}${ entry . active ? '' : ' — inactive'}` )
935 . join ( ' \n ' ) || '- None' ;
936
937 const unprofiledRows = unprofiled
938 . map (( entry ) => `- \` ${ entry . namespace } \` : ${ entry . routes . length } route(s) accepted by shape` )
939 . join ( ' \n ' ) || '- None' ;
940
941 return `## Plugin Coverage \n\n ` +
942 authNote +
943 `- Recognized plugins detected: ${ detection . detected . length } \n ` +
944 `- Derived entities (no profile): ${ context . plugins . genericEntities . length } \n ` +
945 `- Unprofiled namespaces: ${ unprofiled . length } \n ` +
946 `- Installed but unrecognized: ${ detection . installedButUnprofiled . length } \n ` +
947 `- Publicly fingerprinted (named, not enumerated): ${ ( detection . fingerprinted || []). length } \n ` +
948 `- Capabilities by status: ${ Object . entries ( summary . byStatus ). map (([ status , count ]) => ` \` ${ status } \` : ${ count }` ). join ( ', ' ) || 'none'} \n\n ` +
949 `| Capability | Status | Via | Recognized | Plugins | Channel | Blocked | Action needed | \n ` +
950 `| --- | --- | --- | --- | --- | --- | --- | --- | \n ` +
951 `${ rows || '| None | - | - | - | - | - | - | - |'} \n\n ` +
952 `### Installed but unprofiled \n\n ${ installedRows } \n\n ` +
953 `### Unprofiled namespaces accepted by shape \n\n ${ unprofiledRows } \n\n ` +
954 `Full coverage evidence: [plugin-coverage.json](./plugin-coverage.json) · ` +
955 `detection evidence: [plugin-inventory.json](./plugin-inventory.json) \n\n ` ;
956 }
957
958 function renderIndexFile ( context ) {
959 const entityRows = context.entities. map (( entity ) => {
960 const status = entity.requestErrors. length > 0 ? 'partial' : 'ok' ;
961 const recordCountLabel = entity.recordCount === null ? '?' : String (entity.recordCount);
962 const inUseLabel = entity.inUse === null ? '?' : entity.inUse ? 'yes' : 'no' ;
963 return `| ${ entity . entityName } | \` ${ entity . namespace } \` | \` ${ entity . routePath } \` | ${ recordCountLabel } | ${ inUseLabel } | ${ entity . sampleRecords . length } | ${ status } | [${ entity . fileName }](./${ entity . fileName }) |` ;
964 }). join ( ' \n ' );
965
966 const errorLines = context.entities
967 . flatMap (( entity ) => entity.requestErrors. map (( error ) => `- ${ entity . entityName }: ${ error . request } ${ error . routePath } -> ${ error . status } ${ error . statusText }` ));
968 const skippedByCategoryLines = Object. entries (context.skippedByCategory || {})
969 . map (([ category , count ]) => `- \` ${ category } \` : ${ count }` )
970 . join ( ' \n ' ) || '- None' ;
971 const overrideLines = [
972 `- Include routes: ${ context . overrides . includeRoutes . map (( route ) => ` \` ${ route } \` ` ). join ( ', ' ) || '`none`'}` ,
973 `- Include namespaces: ${ context . overrides . includeNamespaces . map (( namespace ) => ` \` ${ namespace } \` ` ). join ( ', ' ) || '`none`'}` ,
974 `- Include excluded categories: ${ context . overrides . includeExcludedCategories . map (( category ) => ` \` ${ category } \` ` ). join ( ', ' ) || '`none`'}` ,
975 `- Exclude routes: ${ context . overrides . excludeRoutes . map (( route ) => ` \` ${ route } \` ` ). join ( ', ' ) || '`none`'}` ,
976 `- Override reason: ${ context . overrides . overrideReason ? context . overrides . overrideReason : '`none`'}` ,
977 ]. join ( ' \n ' );
978
979 const authGatedEntities = context.entities. filter (( entity ) =>
980 entity.requestErrors. some (( error ) => error.status === 401 || error.status === 403 ));
981 const authWarning = authGatedEntities. length > 0
982 ? `## ⚠️ Incomplete Capture (Authentication) \n\n ` +
983 `${ authGatedEntities . length } entit${ authGatedEntities . length === 1 ? 'y' : 'ies'} returned 401/403 and ` +
984 `could not be captured` +
985 `${ context . authMode === 'none' ? ' — this run used **no credentials**' : ` with auth mode \` ${ context . authMode } \` (insufficient scope)`}. \n ` +
986 `Their \` recordCount \` / \` inUse \` are unreliable: an auth-gated entity can look empty or errored even when the site uses it heavily (e.g. WooCommerce orders/customers, drafts, private fields). \n ` +
987 `Re-run with credentials for a complete and trustworthy capture. \n\n ` +
988 `Auth-gated entities: ${ authGatedEntities . map (( entity ) => ` \` ${ entity . entityName } \` ` ). join ( ', ' ) } \n\n `
989 : '' ;
990
991 const pluginSection = renderPluginSection (context);
992
993 return `# WordPress Discovery \n\n ` +
994 `- Generated at: \` ${ context . generatedAt } \`\n ` +
995 `- Base URL: \` ${ context . baseUrl } \`\n ` +
996 `- REST root: \` ${ context . restRoot } \`\n ` +
997 `- Auth mode: \` ${ context . authMode } \`\n ` +
998 `- Advertised auth providers: ${ context . authProviders . map (( provider ) => ` \` ${ provider } \` ` ). join ( ', ' ) || '`none`'} \n ` +
999 `- Namespaces advertised: ${ context . namespaces . map (( namespace ) => ` \` ${ namespace } \` ` ). join ( ', ' ) || '`none`'} \n ` +
1000 `- Advertised routes: ${ context . totalAdvertisedRoutes } \n ` +
1001 `- Candidate routes after generic filtering: ${ context . totalCandidateRoutes } \n ` +
1002 `- Sampled backend data/metadata routes: ${ context . sampledRoutes } \n ` +
1003 `- Skipped routes: ${ context . skippedRoutes } \n ` +
1004 `- Entities documented: ${ context . entities . length } \n\n ` +
1005 authWarning +
1006 `## Route Scope \n\n ` +
1007 `### Skipped Routes by Category \n\n ${ skippedByCategoryLines } \n\n ` +
1008 `### Overrides \n\n ${ overrideLines } \n\n ` +
1009 `Skipped route evidence: [skipped-routes.json](./skipped-routes.json) \n\n ` +
1010 pluginSection +
1011 `## Discovery Summary \n\n ` +
1012 `| Entity | Namespace | Route | Records | In use | Samples | Status | File | \n ` +
1013 `| --- | --- | --- | ---: | --- | ---: | --- | --- | \n ` +
1014 `${ entityRows || '| None | - | - | - | - | 0 | - | - |'} \n\n ` +
1015 `## Route Index Sample${ markdownJsonBlock ( context . routeIndexSample ) } \n\n ` +
1016 `## Errors \n ${ errorLines . length > 0 ? errorLines . join ( ' \n ' ) : '- None'} \n ` ;
1017 }
1018
1019 function skippedRoutesPayload ( context ) {
1020 return {
1021 generatedAt: context.generatedAt,
1022 totalAdvertisedRoutes: context.totalAdvertisedRoutes,
1023 totalCandidateRoutes: context.totalCandidateRoutes,
1024 sampledRoutes: context.sampledRoutes,
1025 skippedRoutes: context.skippedRoutes,
1026 overrides: context.overrides,
1027 routes: context.classifications
1028 . filter (( classification ) => classification.effectiveAction === 'skip' )
1029 . map (( classification ) => ({
1030 routePath: classification.routePath,
1031 namespace: classification.namespace,
1032 category: classification.category,
1033 reason: classification.reason,
1034 ruleId: classification.ruleId,
1035 sampleByDefault: classification.sampleByDefault,
1036 canIncludeByOverride: classification.canIncludeByOverride,
1037 includedByOverride: classification.includedByOverride,
1038 excludedByOverride: classification.excludedByOverride,
1039 effectiveAction: classification.effectiveAction,
1040 duplicateOf: classification.duplicateOf,
1041 overrideReason: classification.overrideReason,
1042 })),
1043 };
1044 }
1045
1046 // orchestration/decisions.json sits two levels above the discovery out-dir in the standard
1047 // layout (migrations/<project>/data/wp-discovery); --decisions overrides. Missing or
1048 // unparseable files mean "no answers yet", never an error — the ask may not have run.
1049 function loadDecisions ( args ) {
1050 const filePath = args.decisions
1051 || (args.outDir ? path. resolve (args.outDir, '..' , '..' , 'orchestration' , 'decisions.json' ) : null );
1052 if ( ! filePath) return {};
1053 try {
1054 return JSON . parse ( require ( 'node:fs' ). readFileSync (filePath, 'utf8' ));
1055 } catch {
1056 return {};
1057 }
1058 }
1059
1060 // The batched ask (J1 step 9) records one decision per blocker under the key
1061 // `pluginBlocker:<capability>:<kind>`; a value of `declined` (or `skipped`) marks it declined.
1062 function applyBlockerDecisions ( rows , decisions ) {
1063 for ( const row of rows) {
1064 for ( const blocker of row.blocked || []) {
1065 const entry = decisions[ `pluginBlocker:${ row . capability }:${ blocker . kind }` ];
1066 if (entry && [ 'declined' , 'skipped' ]. includes (entry.value)) blocker.declined = true ;
1067 }
1068 }
1069 }
1070
1071 function loadTargetKnowledge () {
1072 try {
1073 const {
1074 knowledgeRoot ,
1075 knowledgeSummary ,
1076 } = require ( '../../rp-target-wix/lib/domain-knowledge.js' );
1077 return knowledgeSummary ( knowledgeRoot (path. resolve (__dirname, '..' , '..' , 'rp-target-wix' )));
1078 } catch (error) {
1079 // A partial install must not break discovery; coverage then reports CMS/native-gap
1080 // conservatively instead of resolving target refs.
1081 return { knownRefs: new Set (), capabilityRefs: new Map (), verificationByRef: new Map (), loadError: error.message };
1082 }
1083 }
1084
1085 // Plugin detection, Tier-B derivation, and coverage classification, run after sampling so
1086 // record payloads are available for core-embedded plugins that add no REST route.
1087 function buildPluginCoverage ({ args , inventory , classifications , entities , allCandidates = [] }) {
1088 const profiles = loadProfiles ( pluginsRoot (path. resolve (__dirname, '..' )));
1089 const sampledRecordProperties = collectRecordProperties (entities);
1090
1091 const detection = detectPlugins ({
1092 profiles,
1093 restIndex: inventory.restIndex,
1094 pluginList: inventory.detection.pluginListAvailable ? inventory.pluginList : null ,
1095 types: inventory.types,
1096 taxonomies: inventory.taxonomies,
1097 htmlSources: inventory.htmlSources || [],
1098 sampledRecordProperties,
1099 fingerprintAliases: loadFingerprintAliases ( pluginsRoot (path. resolve (__dirname, '..' ))),
1100 });
1101
1102 const sampledByRoute = new Map (entities. map (( entity ) => [entity.routePath, entity]));
1103 const unprofiled = [
1104 ... collectUnprofiledRoutes ({ classifications, detection }),
1105 ... collectCandidateNamespaces ({ classifications, candidates: allCandidates, detection }),
1106 ];
1107 const genericEntities = deriveGenericEntities ({
1108 types: inventory.types,
1109 taxonomies: inventory.taxonomies,
1110 classifications,
1111 detection,
1112 sampledByRoute,
1113 });
1114
1115 const targetKnowledge = loadTargetKnowledge ();
1116 const knowledgeDir = pluginsRoot (path. resolve (__dirname, '..' ));
1117 const hints = loadNoMigrationNeeded (knowledgeDir);
1118 const rows = classifyCoverage ({
1119 detection,
1120 genericEntities,
1121 unprofiledRoutes: unprofiled,
1122 targetKnowledge,
1123 entityStatsByRoute: sampledByRoute,
1124 // Only human-signed verdicts may produce a requires-development status.
1125 requiresDevelopmentEntries: loadRequiresDevelopment (knowledgeDir).capabilities || [],
1126 // Working notes for profiled capabilities we have not placed yet.
1127 pendingNotes: loadPendingDecisions (knowledgeDir).capabilities || [],
1128 // Nothing-to-move verdicts, both tiers: the slug list resolves unprofiled plugins into
1129 // rows (basis: list), and the signed capabilities[] register resolves profiled
1130 // capabilities a human decided need no migration (basis: decision).
1131 noMigrationNeeded: hints,
1132 });
1133
1134 // Read back the batched-ask answers so a declined blocker renders as declined, never as
1135 // unanswered. A declined blocker stays on the row — skipping is an answer, not silence.
1136 applyBlockerDecisions (rows, loadDecisions (args));
1137
1138 // One row per installed plugin, for customer review and for debugging this run later.
1139 const dispositionRows = buildDispositionRows ({
1140 detection,
1141 coverage: rows,
1142 profiles,
1143 hints,
1144 });
1145
1146 return {
1147 detection,
1148 genericEntities,
1149 unprofiled,
1150 sampledRecordProperties,
1151 coverage: rows,
1152 summary: coverageSummary (rows),
1153 disposition: dispositionRows,
1154 dispositionSummary: summarizeDispositions (dispositionRows),
1155 targetKnowledgeError: targetKnowledge.loadError || null ,
1156 };
1157 }
1158
1159 // plugin-rest-child support (finding #21): a parent-scoped sub-resource (e.g.
1160 // /wc/v3/orders/{parentId}/notes) cannot be listed on its own, so it is checked by
1161 // substituting real ids from the already-sampled parent collection. Pure and exported so the
1162 // id-picking and route-building logic is fixture-testable with no live site.
1163
1164 // Candidate parent ids for a plugin-rest-child entity, drawn from its parent collection's
1165 // already-sampled records and capped at a small representative count — this is a check, not
1166 // an attempt at full coverage.
1167 function pickChildSampleParentIds ( parentEntity , limit ) {
1168 const records = parentEntity?.sampleRecords || [];
1169 return records
1170 . map (( record ) => (record && typeof record === 'object' ? record.id : undefined ))
1171 . filter (( id ) => id !== undefined && id !== null )
1172 . slice ( 0 , limit);
1173 }
1174
1175 function buildChildRoutePath ( template , parentId ) {
1176 return template. replace ( CHILD_ROUTE_PLACEHOLDER , encodeURIComponent ( String (parentId)));
1177 }
1178
1179 function describeChildSample ( sample ) {
1180 const label = `${ sample . plugin } · ${ sample . entity }` ;
1181 if (sample.parentsChecked === 0 ) {
1182 return `${ label }: no sampled ${ sample . parentRoute } records were available to check ${ sample . route } against.` ;
1183 }
1184 return `${ label }: checked ${ sample . parentsChecked } sampled ${ sample . parentRoute } record(s)' `
1185 + `${ sample . route } — ${ sample . parentsWithRecords } of ${ sample . parentsChecked } returned matching records `
1186 + `(${ sample . sampleRecords . length } sample record(s) total). Representative check only, not a full count.` ;
1187 }
1188
1189 // Live per-parent sampling for every detected plugin-rest-child entity. Runs after the main
1190 // sampling loop so `sampledByRoute` already holds real parent records to draw ids from.
1191 async function sampleChildEntities ({ baseUrl , headers , timeoutMs , sampleLimit , progress , detection , sampledByRoute }) {
1192 const results = [];
1193 const parentSampleLimit = Math. min ( 3 , sampleLimit);
1194
1195 for ( const detected of detection?.detected || []) {
1196 for ( const entity of detected.entities) {
1197 if (entity.channel !== 'plugin-rest-child' || entity.channelStatus !== 'available' ) continue ;
1198
1199 const parentEntity = sampledByRoute. get (entity.parentRoute);
1200 const parentIds = pickChildSampleParentIds (parentEntity, parentSampleLimit);
1201 if (parentIds. length === 0 ) {
1202 results. push ({
1203 plugin: detected.plugin,
1204 entity: entity.entity,
1205 parentRoute: entity.parentRoute,
1206 route: entity.route,
1207 parentsChecked: 0 ,
1208 parentsWithRecords: 0 ,
1209 sampleRecords: [],
1210 });
1211 continue ;
1212 }
1213
1214 const sampleRecords = [];
1215 let parentsWithRecords = 0 ;
1216 for ( const parentId of parentIds) {
1217 const childRoute = buildChildRoutePath (entity.route, parentId);
1218 // eslint-disable-next-line no-await-in-loop -- each parent's sub-resource must be
1219 // fetched under the shared rate limiter, one request at a time, like inspectEntity.
1220 const response = await fetchJson (baseUrl, childRoute, {
1221 headers,
1222 method: 'GET' ,
1223 timeoutMs,
1224 progress,
1225 progressContext: { step: 'sample-child-entity' , entity: entity.entity },
1226 });
1227 if (response.ok && Array. isArray (response.json) && response.json. length > 0 ) {
1228 parentsWithRecords += 1 ;
1229 sampleRecords. push ( ... response.json. slice ( 0 , Math. max ( 0 , sampleLimit - sampleRecords. length )));
1230 }
1231 }
1232
1233 results. push ({
1234 plugin: detected.plugin,
1235 entity: entity.entity,
1236 parentRoute: entity.parentRoute,
1237 route: entity.route,
1238 parentsChecked: parentIds. length ,
1239 parentsWithRecords,
1240 sampleRecords,
1241 });
1242 }
1243 }
1244
1245 return results;
1246 }
1247
1248 function pluginCoveragePayload ( context ) {
1249 const plugins = context.plugins;
1250 return {
1251 generatedAt: context.generatedAt,
1252 baseUrl: context.baseUrl,
1253 authenticated: context.authMode !== 'none' ,
1254 pluginListAvailable: plugins.detection.pluginListAvailable,
1255 notes: [
1256 ... (context.pluginNotes || []),
1257 ... (plugins.targetKnowledgeError
1258 ? [ `Wix target knowledge could not be loaded (${ plugins . targetKnowledgeError }); target refs were not resolved.` ]
1259 : []),
1260 ... (plugins.childSamples || []). map (describeChildSample),
1261 ],
1262 childSamples: plugins.childSamples || [],
1263 summary: plugins.summary,
1264 perPluginSummary: plugins.dispositionSummary,
1265 // The status invariant: N installed plugins produce N per-plugin rows, recognized or not.
1266 // Recorded here so the check is inspectable in the artifact, not just in tests.
1267 statusInvariant: {
1268 detectedRecognized: plugins.detection.detected. length ,
1269 installedButUnrecognized: plugins.detection.installedButUnprofiled. length ,
1270 genericEntities: plugins.genericEntities. length ,
1271 unprofiledNamespaces: plugins.unprofiled. length ,
1272 pluginListAvailable: plugins.detection.pluginListAvailable,
1273 },
1274 capabilities: plugins.coverage,
1275 // One artifact, two views: the capability rows above are the contract;
1276 // this per-plugin projection answers the question a merchant actually asks.
1277 perPlugin: plugins.disposition,
1278 genericEntities: plugins.genericEntities,
1279 unprofiledRoutes: plugins.unprofiled,
1280 };
1281 }
1282
1283 async function ensureDir ( dirPath ) {
1284 await fs. mkdir (dirPath, { recursive: true });
1285 }
1286
1287 async function writeOutputs ( outDir , context , progress ) {
1288 await ensureDir (outDir);
1289 await fs. writeFile (path. join (outDir, 'README.md' ), renderIndexFile (context), 'utf8' );
1290 progress?. progress ( 'Wrote WordPress discovery index' , { phase: 'discovery' , step: 'write-artifact' , artifact: path. join (outDir, 'README.md' ) });
1291 await fs. writeFile (
1292 path. join (outDir, 'skipped-routes.json' ),
1293 `${ JSON . stringify ( skippedRoutesPayload ( context ), null , 2 ) } \n ` ,
1294 'utf8' ,
1295 );
1296 progress?. progress ( 'Wrote WordPress skipped-route index' , {
1297 phase: 'discovery' ,
1298 step: 'write-artifact' ,
1299 artifact: path. join (outDir, 'skipped-routes.json' ),
1300 });
1301
1302 if (context.plugins) {
1303 await fs. writeFile (
1304 path. join (outDir, 'plugin-inventory.json' ),
1305 `${ JSON . stringify ( inventoryPayload ({
1306 generatedAt: context.generatedAt ,
1307 baseUrl: context.baseUrl ,
1308 authenticated: context.authMode !== 'none' ,
1309 detection: context.plugins.detection ,
1310 notes: context.pluginNotes || [] ,
1311 unprofiled: context.plugins.unprofiled ,
1312 profileCount: context.pluginProfileCount ,
1313 }), null , 2 ) } \n ` ,
1314 'utf8' ,
1315 );
1316 progress?. progress ( 'Wrote WordPress plugin inventory' , {
1317 phase: 'discovery' ,
1318 step: 'write-artifact' ,
1319 artifact: path. join (outDir, 'plugin-inventory.json' ),
1320 });
1321 await fs. writeFile (
1322 path. join (outDir, 'plugin-coverage.json' ),
1323 `${ JSON . stringify ( pluginCoveragePayload ( context ), null , 2 ) } \n ` ,
1324 'utf8' ,
1325 );
1326 progress?. progress ( 'Wrote WordPress plugin coverage' , {
1327 phase: 'discovery' ,
1328 step: 'write-artifact' ,
1329 artifact: path. join (outDir, 'plugin-coverage.json' ),
1330 });
1331 // Deliberately no plugin-disposition.md: the per-plugin view lives inside
1332 // plugin-coverage.json (one coverage artifact, no second document).
1333 }
1334
1335 for ( const entity of context.entities) {
1336 await fs. writeFile (path. join (outDir, entity.fileName), renderEntityFile (entity), 'utf8' );
1337 progress?. progress ( `Wrote WordPress discovery artifact for ${ entity . entityName }` , {
1338 phase: 'discovery' ,
1339 step: 'write-artifact' ,
1340 entity: entity.routePath,
1341 artifact: path. join (outDir, entity.fileName),
1342 });
1343 }
1344 }
1345
1346 async function main () {
1347 const parsed = parseProgressArgs (process.argv. slice ( 2 ));
1348 progress = createProgressLogger ({
1349 script: 'skills/wix-replatform/resources/rp-source-wordpress/scripts/wp-discovery.js' ,
1350 ... parsed.progress,
1351 });
1352 progress. start ( 'WordPress discovery started' , { phase: 'discovery' });
1353
1354 const args = await hydrateArgsFromEnvFile ( parseArgs (parsed.args));
1355 if (args.help) {
1356 printUsage ();
1357 progress. complete ( 'WordPress discovery help shown' , { phase: 'discovery' , step: 'help' });
1358 return ;
1359 }
1360
1361 if ( ! args.baseUrl || ! args.outDir) {
1362 printUsage ();
1363 progress. error ( 'Missing required WordPress discovery arguments' , { phase: 'discovery' });
1364 throw new Error ( 'Missing required arguments: --base-url and --out-dir are required.' );
1365 }
1366
1367 configureRateLimit ({ rateLimitRpm: args.rateLimitRpm, maxRetries: args.maxRetries });
1368
1369 const headers = buildHeaders (args);
1370 const rootResponse = await fetchJson (args.baseUrl, '' , {
1371 headers,
1372 method: 'GET' ,
1373 timeoutMs: args.timeoutMs,
1374 progress,
1375 progressContext: { step: 'rest-index' },
1376 });
1377
1378 if ( ! rootResponse.ok || ! rootResponse.json) {
1379 throw new Error ( `Failed to fetch WordPress REST index from ${ rootResponse . url }: ${ rootResponse . status } ${ rootResponse . statusText }` );
1380 }
1381
1382 const indexJson = rootResponse.json;
1383 const namespaces = Array. isArray (indexJson?.namespaces) ? indexJson.namespaces : [];
1384 progress. progress ( 'WordPress namespace enumeration completed' , {
1385 phase: 'discovery' ,
1386 step: 'namespace-enumeration' ,
1387 count: namespaces. length ,
1388 unit: 'namespaces' ,
1389 });
1390 // Plugin inventory pre-pass: runs before classification so profile-declared routes are
1391 // already in scope when routes are classified.
1392 let inventory = null ;
1393 if (args.pluginInventory) {
1394 try {
1395 const gathered = await gatherInventory ({
1396 baseUrl: args.baseUrl,
1397 headers,
1398 timeoutMs: args.timeoutMs,
1399 htmlFingerprint: args.htmlFingerprint,
1400 restIndex: indexJson,
1401 logger: progress,
1402 });
1403 inventory = gathered;
1404 progress. progress ( 'WordPress plugin inventory completed' , {
1405 phase: 'discovery' ,
1406 step: 'plugin-inventory' ,
1407 count: gathered.detection.detected. length ,
1408 unit: 'plugins' ,
1409 details: {
1410 pluginListAvailable: gathered.detection.pluginListAvailable,
1411 installedButUnprofiled: gathered.detection.installedButUnprofiled. length ,
1412 },
1413 });
1414 } catch (error) {
1415 // Never fail discovery over the plugin pre-pass; record and continue.
1416 progress. progress ( `WordPress plugin inventory skipped: ${ error . message }` , {
1417 phase: 'discovery' ,
1418 step: 'plugin-inventory' ,
1419 });
1420 }
1421 }
1422
1423 const requestOverrides = buildRequestOverrides ( pluginsRoot (path. resolve (__dirname, '..' )));
1424 const candidates = deriveEntityCandidates (indexJson, args.includeNamespaces, requestOverrides);
1425 progress. progress ( 'WordPress endpoint candidates derived' , {
1426 phase: 'discovery' ,
1427 step: 'derive-endpoints' ,
1428 count: candidates. length ,
1429 unit: 'endpoints' ,
1430 });
1431 // Registered post types/taxonomies are positive evidence for classification, so the
1432 // inventory pre-pass must run before this point (it fetches /wp/v2/types+taxonomies).
1433 const classifications = classifyRoutes (candidates, {
1434 ... summarizeOverrides (args),
1435 registeredRestBases: inventory ? buildRegisteredRestBases ({ types: inventory.types, taxonomies: inventory.taxonomies }) : null ,
1436 });
1437 // classifyRoutes() loads the checked-in plugin profiles lazily via defaultPluginRules();
1438 // a failed load degrades to zero plugin rules with no exception, so it must be surfaced
1439 // here or every plugin-owned route silently falls through to generic/unsupported handling.
1440 const pluginRulesError = defaultPluginRules ().loadError || null ;
1441 if (pluginRulesError) {
1442 progress. progress ( `WordPress plugin route rules could not be loaded (${ pluginRulesError }); plugin-owned routes will not be recognized this run.` , {
1443 phase: 'discovery' ,
1444 step: 'classify-endpoints' ,
1445 });
1446 }
1447 const candidatesByRoute = new Map (candidates. map (( candidate ) => [candidate.routePath, candidate]));
1448 const candidatesToInspect = classifications
1449 . filter (( classification ) => [ 'sample' , 'metadata' ]. includes (classification.effectiveAction))
1450 . map (( classification ) => ({
1451 ... candidatesByRoute. get (classification.routePath),
1452 classification,
1453 }));
1454 const skippedByCategory = summarizeSkippedByCategory (classifications);
1455 const skippedRoutes = classifications. filter (( classification ) => classification.effectiveAction === 'skip' ). length ;
1456 progress. progress ( 'WordPress endpoint candidates classified' , {
1457 phase: 'discovery' ,
1458 step: 'classify-endpoints' ,
1459 count: candidatesToInspect. length ,
1460 total: candidates. length ,
1461 unit: 'endpoints' ,
1462 details: {
1463 sampledRoutes: candidatesToInspect. length ,
1464 skippedRoutes,
1465 skippedByCategory,
1466 },
1467 });
1468 const entities = [];
1469 const responseEnvelopes = buildResponseEnvelopes ( pluginsRoot (path. resolve (__dirname, '..' )));
1470 const responseFragmentGroups = buildResponseFragmentGroups ( pluginsRoot (path. resolve (__dirname, '..' )));
1471 const recordKeyFields = buildRecordKeyFields ( pluginsRoot (path. resolve (__dirname, '..' )));
1472
1473 for ( let i = 0 ; i < candidatesToInspect. length ; i += 1 ) {
1474 const candidate = candidatesToInspect[i];
1475 progress. progress ( `Inspecting WordPress endpoint ${ candidate . routePath }` , {
1476 phase: 'discovery' ,
1477 step: 'inspect-endpoint' ,
1478 entity: candidate.routePath,
1479 count: i + 1 ,
1480 total: candidatesToInspect. length ,
1481 unit: 'endpoints' ,
1482 percent: candidatesToInspect. length ? Math. round (((i + 1 ) / candidatesToInspect. length ) * 100 ) : 100 ,
1483 });
1484 const details = await inspectEntity (args.baseUrl, headers, candidate, {
1485 sampleLimit: args.sampleLimit,
1486 timeoutMs: args.timeoutMs,
1487 progress,
1488 responseEnvelope: responseEnvelopes. get (candidate.routePath) || null ,
1489 responseFragmentGroupSize: responseFragmentGroups. get (candidate.routePath) || null ,
1490 recordKeyField: recordKeyFields. get (candidate.routePath) || null ,
1491 requestOverride: candidate.requestOverride,
1492 defaultQuery: defaultQueryFor (candidate.routePath),
1493 defaultQueryReason: defaultQueryReasonFor (candidate.routePath),
1494 });
1495 entities. push (details);
1496 }
1497
1498 // Second detection pass + Tier-B derivation + coverage. This is where plugins that add
1499 // no REST route (Product Bundles, ACF, Yoast) become visible, because it looks at the
1500 // record payload keys the sampling loop just collected.
1501 let plugins = null ;
1502 if (inventory) {
1503 plugins = buildPluginCoverage ({ args, inventory, classifications, entities, allCandidates: candidates });
1504 progress. progress ( 'WordPress plugin coverage classified' , {
1505 phase: 'discovery' ,
1506 step: 'plugin-coverage' ,
1507 count: plugins.coverage. length ,
1508 unit: 'capabilities' ,
1509 details: plugins.summary.byStatus,
1510 });
1511
1512 // Third pass: plugin-rest-child entities (finding #21) — a parent-scoped sub-resource
1513 // (e.g. /wc/v3/orders/{parentId}/notes) cannot be listed on its own, so it is checked by
1514 // substituting real ids from the parent collection this run already sampled.
1515 const childSamples = await sampleChildEntities ({
1516 baseUrl: args.baseUrl,
1517 headers,
1518 timeoutMs: args.timeoutMs,
1519 sampleLimit: args.sampleLimit,
1520 progress,
1521 detection: plugins.detection,
1522 sampledByRoute: new Map (entities. map (( entity ) => [entity.routePath, entity])),
1523 });
1524 if (childSamples. length > 0 ) {
1525 progress. progress ( 'WordPress per-parent sub-resource sampling completed' , {
1526 phase: 'discovery' ,
1527 step: 'sample-child-entity' ,
1528 count: childSamples. length ,
1529 unit: 'entities' ,
1530 });
1531 }
1532 plugins = { ... plugins, childSamples };
1533 }
1534
1535 const context = {
1536 generatedAt: new Date (). toISOString (),
1537 baseUrl: normalizeBaseUrl (args.baseUrl),
1538 restRoot: `${ normalizeBaseUrl ( args . baseUrl ) }/wp-json` ,
1539 authMode: summarizeAuthMode (args),
1540 authProviders: Object. keys (indexJson?.authentication || {}),
1541 namespaces,
1542 totalAdvertisedRoutes: Object. keys (indexJson?.routes || {}). length ,
1543 totalCandidateRoutes: candidates. length ,
1544 sampledRoutes: candidatesToInspect. length ,
1545 skippedRoutes,
1546 skippedByCategory,
1547 overrides: summarizeOverrides (args),
1548 classifications,
1549 routeIndexSample: Object. keys (indexJson?.routes || {}). slice ( 0 , 50 ),
1550 entities,
1551 plugins,
1552 pluginNotes: [
1553 ... (inventory ? inventory.notes : [ 'Plugin inventory was disabled with --no-plugin-inventory.' ]),
1554 ... (pluginRulesError
1555 ? [ `WordPress plugin route rules could not be loaded (${ pluginRulesError }); plugin-owned routes were not recognized this run.` ]
1556 : []),
1557 ],
1558 pluginProfileCount: inventory ? inventory.profileCount : 0 ,
1559 };
1560
1561 await writeOutputs (args.outDir, context, progress);
1562
1563 console. log ( `Wrote ${ entities . length + 1 } markdown files and skipped-routes.json to ${ args . outDir }` );
1564 progress. complete ( 'WordPress discovery completed' , {
1565 phase: 'discovery' ,
1566 artifact: args.outDir,
1567 count: entities. length ,
1568 unit: 'entities' ,
1569 });
1570 }
1571
1572 if (require.main === module ) {
1573 main (). catch (( error ) => {
1574 console. error (error.stack || error.message);
1575 if (progress) {
1576 progress. error (error && error.message ? error.message : 'WordPress discovery failed' , { phase: 'discovery' });
1577 }
1578 process.exitCode = 1 ;
1579 });
1580 }
1581
1582 module . exports = {
1583 parseArgs,
1584 deriveEntityCandidates,
1585 renderIndexFile,
1586 renderPluginSection,
1587 skippedRoutesPayload,
1588 buildPluginCoverage,
1589 pluginCoveragePayload,
1590 loadTargetKnowledge,
1591 pickChildSampleParentIds,
1592 buildChildRoutePath,
1593 describeChildSample,
1594 sampleChildEntities,
1595 getAtPath,
1596 inspectEntity,
1597 resolveRequestBody,
1598 normalizeResponseRecords,
1599 findSampledIdsDependencies,
1600 };