#!/usr/bin/env node const fs = require('node:fs/promises'); const path = require('node:path'); const { createProgressLogger, parseProgressArgs } = require('../../../lib/progress-log.js'); const { readEnvFile } = require('../../../lib/config-env.js'); // Transport, auth, throttling, and pagination-header parsing live in the shared // adapter lib so the bulk-extract reader generated by rp-import-codegen inherits // the exact same discipline instead of re-deriving it. const { DEFAULT_TIMEOUT_MS, DEFAULT_RATE_LIMIT_RPM, DEFAULT_MAX_RETRIES, configureRateLimit, buildHeaders, normalizeBaseUrl, fetchJson, parseTotalHeader, } = require('../lib/wp-http.js'); const { classifyRoutes, summarizeSkippedByCategory, buildRegisteredRestBases, defaultPluginRules, defaultQueryFor, defaultQueryReasonFor, } = require('../lib/wp-route-classifier.js'); // Plugin awareness: detection runs before classification so profile routes are // in scope; embedded detection and Tier-B derivation run after sampling because they need // real record payloads and the accepted route set. const { gatherInventory, inventoryPayload } = require('./wp-plugin-inventory.js'); const { pluginsRoot, loadProfiles, loadNoMigrationNeeded, loadRequiresDevelopment, loadPendingDecisions, loadFingerprintAliases, CHILD_ROUTE_PLACEHOLDER, buildResponseEnvelopes, buildRequestOverrides, buildResponseFragmentGroups, buildRecordKeyFields, } = require('../lib/plugin-knowledge.js'); const { buildDispositionRows, summarizeDispositions, } = require('../lib/plugin-disposition.js'); const { detectPlugins, collectRecordProperties, collectUnprofiledRoutes, collectCandidateNamespaces, deriveGenericEntities, classifyCoverage, coverageSummary, } = require('../lib/wp-plugin-detect.js'); const { collectAllIds, queryInBatches, DEFAULT_BATCH_SIZE, } = require('../lib/sampled-ids-batch.js'); const DEFAULT_SAMPLE_LIMIT = 3; let progress; function printUsage() { console.log(`Usage: node wp-discovery.js --base-url --out-dir [auth options] Required: --base-url WordPress site base URL, e.g. https://example.com --out-dir Directory to write discovery markdown files into --decisions orchestration/decisions.json to read batched-ask answers from (default: /../../orchestration/decisions.json) --env-file Load defaults from a project-local env file Authentication options: --username WordPress username for Application Password auth --application-password WordPress Application Password --api-key API key/token for custom auth setups --api-key-header Header name for --api-key. Defaults to Authorization --auth-header <'Name: Value'> Add a raw HTTP header. Can be repeated. Optional: --sample-limit Number of sample records per entity. Default: 3 --timeout-ms Request timeout in ms. Default: 60000 --rate-limit-rpm Max requests per minute. Default: 120 --max-retries Retries on 429/503 (honors Retry-After). Default: 3 --commerce-mode WooCommerce read mode: public | authenticated. Defaults to public without auth, otherwise authenticated. --no-plugin-inventory Skip plugin detection, Tier-B derivation, and coverage. --no-html-fingerprint Skip the homepage fetch used for asset-path fingerprints. --include-namespace Only inspect a namespace. Can be repeated. --include-route Force-sample a route. Can be repeated. --include-excluded-category Force-sample an excluded category. Can be repeated. --exclude-route Skip a route even if otherwise sampled. Can be repeated. --override-reason Reason recorded for include/exclude overrides. --progress-log Append progress NDJSON records to this file. --help Show this help text Examples: node wp-discovery.js \ --base-url https://example.com \ --out-dir migrations/acme/data/wp-discovery \ --username admin \ --application-password 'abcd efgh ijkl mnop' node wp-discovery.js \ --base-url https://example.com \ --out-dir migrations/acme/data/wp-discovery \ --api-key $WP_API_KEY \ --api-key-header X-API-Key `); } function parseArgs(argv) { const args = { authHeaders: [], includeNamespaces: [], includeRoutes: [], includeExcludedCategories: [], excludeRoutes: [], overrideReason: null, sampleLimit: DEFAULT_SAMPLE_LIMIT, timeoutMs: DEFAULT_TIMEOUT_MS, rateLimitRpm: DEFAULT_RATE_LIMIT_RPM, maxRetries: DEFAULT_MAX_RETRIES, commerceMode: null, pluginInventory: true, htmlFingerprint: true, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; const next = argv[i + 1]; switch (arg) { case '--help': case '-h': args.help = true; break; case '--base-url': args.baseUrl = next; i += 1; break; case '--out-dir': args.outDir = next; i += 1; break; case '--decisions': args.decisions = next; i += 1; break; case '--env-file': args.envFile = next; i += 1; break; case '--username': args.username = next; i += 1; break; case '--application-password': args.applicationPassword = next; i += 1; break; case '--api-key': args.apiKey = next; i += 1; break; case '--api-key-header': args.apiKeyHeader = next; i += 1; break; case '--auth-header': args.authHeaders.push(next); i += 1; break; case '--sample-limit': args.sampleLimit = Number.parseInt(next, 10); i += 1; break; case '--timeout-ms': args.timeoutMs = Number.parseInt(next, 10); i += 1; break; case '--rate-limit-rpm': args.rateLimitRpm = Number.parseInt(next, 10); i += 1; break; case '--max-retries': args.maxRetries = Number.parseInt(next, 10); i += 1; break; case '--commerce-mode': args.commerceMode = next; i += 1; break; case '--include-namespace': args.includeNamespaces.push(next); i += 1; break; case '--include-route': args.includeRoutes.push(next); i += 1; break; case '--include-excluded-category': args.includeExcludedCategories.push(next); i += 1; break; case '--exclude-route': args.excludeRoutes.push(next); i += 1; break; case '--override-reason': args.overrideReason = next; i += 1; break; case '--no-plugin-inventory': args.pluginInventory = false; break; case '--no-html-fingerprint': args.htmlFingerprint = false; break; default: if (arg.startsWith('--')) { throw new Error(`Unknown argument: ${arg}`); } } } if (!args.baseUrl) { args.baseUrl = process.env.WP_BASE_URL || process.env.WP_SITE_URL; } if (!args.outDir) { args.outDir = process.env.WP_DISCOVERY_OUT_DIR; } if (!args.apiKey) { args.apiKey = process.env.WP_API_KEY; } if (!args.apiKeyHeader) { args.apiKeyHeader = process.env.WP_API_KEY_HEADER; } if (!args.username) { args.username = process.env.WP_USERNAME; } if (!args.applicationPassword) { args.applicationPassword = process.env.WP_APPLICATION_PASSWORD; } if (args.authHeaders.length === 0 && process.env.WP_AUTH_HEADER) { args.authHeaders.push(process.env.WP_AUTH_HEADER); } if (!Number.isFinite(args.sampleLimit) || args.sampleLimit < 1) { args.sampleLimit = DEFAULT_SAMPLE_LIMIT; } if (!Number.isFinite(args.timeoutMs) || args.timeoutMs < 1000) { args.timeoutMs = DEFAULT_TIMEOUT_MS; } if (!Number.isFinite(args.rateLimitRpm) || args.rateLimitRpm < 1) { args.rateLimitRpm = DEFAULT_RATE_LIMIT_RPM; } if (!Number.isFinite(args.maxRetries) || args.maxRetries < 0) { args.maxRetries = DEFAULT_MAX_RETRIES; } if (args.commerceMode !== 'public' && args.commerceMode !== 'authenticated') { args.commerceMode = (args.username && args.applicationPassword) || args.apiKey || args.authHeaders.length > 0 ? 'authenticated' : 'public'; } return args; } async function hydrateArgsFromEnvFile(args) { if (!args.envFile) { return args; } const envValues = await readEnvFile(path.resolve(args.envFile)); if (!args.baseUrl && envValues.WP_BASE_URL) { args.baseUrl = envValues.WP_BASE_URL; } if (!args.username && envValues.WP_USERNAME) { args.username = envValues.WP_USERNAME; } if (!args.applicationPassword && envValues.WP_APPLICATION_PASSWORD) { args.applicationPassword = envValues.WP_APPLICATION_PASSWORD; } if (!args.apiKey && envValues.WP_API_KEY) { args.apiKey = envValues.WP_API_KEY; } if (!args.apiKeyHeader && envValues.WP_API_KEY_HEADER) { args.apiKeyHeader = envValues.WP_API_KEY_HEADER; } if (args.authHeaders.length === 0 && envValues.WP_AUTH_HEADER) { args.authHeaders.push(envValues.WP_AUTH_HEADER); } return args; } function endpointMethods(endpoint) { const raw = endpoint?.methods; if (Array.isArray(raw)) { return raw.map(String); } if (typeof raw === 'string') { return raw.split(',').map((value) => value.trim()).filter(Boolean); } if (raw && typeof raw === 'object') { return Object.keys(raw); } return []; } function routeSegments(routePath) { return routePath.split('/').filter(Boolean); } function summarizeRelationships(record) { const links = record?._links; if (!links || typeof links !== 'object') { return []; } // HAL housekeeping rels carry no entity relationship signal. const ignored = new Set(['self', 'collection', 'about', 'curies']); const relationships = []; for (const [rel, entries] of Object.entries(links)) { if (ignored.has(rel)) { continue; } const list = Array.isArray(entries) ? entries : [entries]; const hrefs = list.map((entry) => entry?.href).filter(Boolean); if (hrefs.length === 0) { continue; } relationships.push({ rel, embeddable: list.some((entry) => entry?.embeddable === true), hrefs, }); } return relationships; } function isParameterizedRoute(routePath) { return routePath.includes('(?P<'); } function slugify(value) { return value .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || 'entity'; } function summarizeAuthMode(args) { if (args.username && args.applicationPassword) { return 'basic-application-password'; } if (args.apiKey) { return `api-key:${args.apiKeyHeader || 'Authorization'}`; } if (args.authHeaders.length > 0) { return 'custom-header'; } return 'none'; } function summarizeOverrides(args) { return { includeRoutes: args.includeRoutes, includeNamespaces: args.includeNamespaces, includeExcludedCategories: args.includeExcludedCategories, excludeRoutes: args.excludeRoutes, overrideReason: args.overrideReason, commerceMode: args.commerceMode, }; } function deriveEntityCandidates(indexJson, includeNamespaces, requestOverrides = new Map()) { const routes = indexJson?.routes || {}; const candidates = []; for (const [routePath, routeDefinition] of Object.entries(routes)) { if (!routePath.startsWith('/') || routePath === '/' || isParameterizedRoute(routePath)) { continue; } const segments = routeSegments(routePath); if (segments.length < 2) { continue; } const namespace = segments.slice(0, 2).join('/'); if (includeNamespaces.length > 0 && !includeNamespaces.includes(namespace)) { continue; } const endpoints = Array.isArray(routeDefinition?.endpoints) ? routeDefinition.endpoints : []; // A profile may declare that this entity's real read path is not GET (spec 0044, // plugin-knowledge.js buildRequestOverrides) — the declared method then stands in for // the GET-collection requirement below, generically for any plugin/entity. const requestOverride = requestOverrides.get(routePath) || null; const getEndpoint = requestOverride ? endpoints.find((endpoint) => endpointMethods(endpoint).includes(requestOverride.method)) : endpoints.find((endpoint) => endpointMethods(endpoint).includes('GET')); if (!getEndpoint) { continue; } const entityName = segments[segments.length - 1]; const supportsPagination = Boolean(getEndpoint?.args?.page || getEndpoint?.args?.per_page); const hasSchema = Boolean(getEndpoint?.schema || routeDefinition?.schema); // The generic shape heuristic (schema or pagination or 3+ path segments) exists to // filter out config/dashboard noise when nothing else vouches for a route. A profile // explicitly declaring a request override already is that vouching — same principle as // profile-declared routes outranking the classifier's shape rules (Appendix E, spec 0013). if (!requestOverride && !hasSchema && !supportsPagination && segments.length < 3) { continue; } // Derive the file name from the full path after the namespace so distinct // routes that share a last segment (e.g. /wp/v2/categories vs. // /wp/v2/block-patterns/categories) do not collide onto one file. const pathSlug = slugify(segments.slice(2).join('-')) || slugify(entityName); candidates.push({ entityName, namespace, routePath, endpoints, getEndpoint, routeDefinition, supportsPagination, requestOverride, fileName: `${slugify(namespace)}--${pathSlug}.md`, }); } const unique = new Map(); for (const candidate of candidates) { const key = `${candidate.namespace}:${candidate.routePath}`; if (!unique.has(key)) { unique.set(key, candidate); } } return [...unique.values()].sort((a, b) => a.routePath.localeCompare(b.routePath)); } // Finds every route named by a "$SAMPLED_IDS:" placeholder (spec 0044) anywhere // inside a requestOverride body, however deeply nested. A body may reference more than one // route (e.g. two independent id lists); collecting all of them, not just the first match, is // what lets the pagination+batch mechanism below (lib/sampled-ids-batch.js) discover every // route it must paginate to exhaustion rather than resolving one placeholder to []. Generic // body-walk, no field names baked in. function findSampledIdsDependencies(value, found = new Set()) { if (Array.isArray(value)) { for (const item of value) { findSampledIdsDependencies(item, found); } } else if (value && typeof value === 'object') { for (const nested of Object.values(value)) { findSampledIdsDependencies(nested, found); } } else if (typeof value === 'string') { const match = value.match(/^\$SAMPLED_IDS:(.+)$/); if (match) found.add(match[1]); } return found; } // Resolves a dot-path ("data.items", "data.meta.count") against a response body for a // responseEnvelope-declaring entity. Returns undefined on any missing/non-object segment // rather than throwing, so an envelope path that stops matching (a plugin update, a stale // profile) degrades to the safe fallback in inspectEntity instead of crashing the run. function getAtPath(value, dotPath) { return String(dotPath).split('.').reduce( (acc, key) => (acc && typeof acc === 'object' ? acc[key] : undefined), value, ); } // Deep-resolves a profile-declared requestBody template against a batch's ids. `idsByRoute` is // a plain `{ route: string[] }` map — for the $SAMPLED_IDS mechanism (spec 0044/0046) this is // always the CURRENT BATCH's ids for the route being paginated, resolved fresh per batch, never // a fixed full list baked into one oversized request. Generic across any entity/route pair — // the placeholder names the OTHER route's ids it wants, nothing plugin-specific lives here. function resolveRequestBody(template, idsByRoute) { if (Array.isArray(template)) { return template.map((value) => resolveRequestBody(value, idsByRoute)); } if (template && typeof template === 'object') { const resolved = {}; for (const [key, value] of Object.entries(template)) { resolved[key] = resolveRequestBody(value, idsByRoute); } return resolved; } if (typeof template === 'string') { const match = template.match(/^\$SAMPLED_IDS:(.+)$/); if (match) { return (idsByRoute && idsByRoute[match[1]]) || []; } } return template; } // Envelope resolution + fragment-group reassembly, shared by the single-fetch path below and // by every batch of the paginate+batch path (lib/sampled-ids-batch.js) — one profile's response // shape must normalize the same way regardless of which path fetched it. Pushes discovery // notes only when `notes` is provided (batched callers pass it for the first batch only, since // the shape characteristic belongs to the endpoint, not to any one batch). Returns `{ ok, // records }`: `ok: false` means the payload did not resolve to an array after envelope // resolution (when declared) and fragment reassembly (when declared) — a genuine shape // mismatch, not "zero records" — and callers on the batched path must treat that as a failure // to defer on, never silently as an empty batch (an enveloped response whose itemsPath // mismatches would otherwise look like an authoritative, exact zero). function normalizeResponseRecords(payload, { envelope, fragmentGroupSize, notes } = {}) { let effectivePayload = payload; if (envelope) { const items = getAtPath(payload, envelope.itemsPath); if (Array.isArray(items)) { effectivePayload = items; } else if (notes) { 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.`); } } if (fragmentGroupSize && Array.isArray(effectivePayload)) { const completeGroups = Math.floor(effectivePayload.length / fragmentGroupSize); const reassembled = []; for (let i = 0; i < completeGroups; i += 1) { reassembled.push(Object.assign({}, ...effectivePayload.slice(i * fragmentGroupSize, (i + 1) * fragmentGroupSize))); } if (notes && effectivePayload.length % fragmentGroupSize !== 0) { 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.`); } if (notes) { notes.push(`Response records were flattened into groups of ${fragmentGroupSize} single-key fragments (per profile responseFragmentGroupSize) and reassembled into one object per record.`); } effectivePayload = reassembled; } const ok = Array.isArray(effectivePayload); return { ok, records: ok ? effectivePayload : [] }; } async function inspectEntity(baseUrl, headers, candidate, options) { const details = { entityName: candidate.entityName, namespace: candidate.namespace, routePath: candidate.routePath, fileName: candidate.fileName, classification: candidate.classification || null, methods: [...new Set(candidate.endpoints.flatMap(endpointMethods))].sort(), supportsPagination: candidate.supportsPagination, discoveryNotes: [], requestErrors: [], collectionArgs: candidate.getEndpoint?.args || null, schema: candidate.getEndpoint?.schema || candidate.routeDefinition?.schema || null, optionsSchema: null, sampleRecords: [], sampleRecordCount: 0, recordCount: null, inUse: null, relationships: [], responseShape: 'unknown', }; const optionsResponse = await fetchJson(baseUrl, candidate.routePath, { headers, method: 'OPTIONS', timeoutMs: options.timeoutMs, progress: options.progress, progressContext: { step: 'inspect-endpoint', entity: candidate.routePath, }, }); if (optionsResponse.ok && optionsResponse.json) { const optionEndpoints = Array.isArray(optionsResponse.json?.endpoints) ? optionsResponse.json.endpoints : []; const getEndpoint = optionEndpoints.find((endpoint) => endpointMethods(endpoint).includes('GET')); if (getEndpoint?.args) { details.collectionArgs = getEndpoint.args; } if (optionsResponse.json?.schema) { details.optionsSchema = optionsResponse.json.schema; details.schema = optionsResponse.json.schema; } else if (getEndpoint?.schema) { details.optionsSchema = getEndpoint.schema; details.schema = getEndpoint.schema; } } else if (optionsResponse.status !== 404 && optionsResponse.status !== 405) { details.requestErrors.push({ request: 'OPTIONS', routePath: candidate.routePath, status: optionsResponse.status, statusText: optionsResponse.statusText, url: optionsResponse.url, }); } // A profile may declare that this entity's real read path is a non-GET request with a // JSON body (spec 0044) — generic across any plugin/entity, not special-cased here. const requestOverride = options.requestOverride || null; const dependencyRoutes = requestOverride ? [...findSampledIdsDependencies(requestOverride.body)] : []; if (requestOverride && dependencyRoutes.length > 0) { // The $SAMPLED_IDS mechanism always means "paginate the dependency route to exhaustion, // then batch-query this route" (spec 0044) — the dependency's record count is unknown, so // there is no bounded "small sample" variant that is safe to assume complete. This is the // ENTIRE meaning of a $SAMPLED_IDS placeholder now, in both discovery and any generated // reader (see rp-import-codegen's sourceMeta contract). return inspectDependentEntity(baseUrl, headers, candidate, options, details, requestOverride, dependencyRoutes); } let sampleResponse; if (requestOverride) { // A fixed-body override with no $SAMPLED_IDS placeholder — single request, unbounded by // definition since nothing here scales with another route's record count. const resolvedBody = resolveRequestBody(requestOverride.body, {}); details.discoveryNotes.push( `Sampled via profile-declared ${requestOverride.method} request with a JSON body, not a plain GET collection (spec 0044 request override).`, ); sampleResponse = await fetchJson(baseUrl, candidate.routePath, { // `headers` is a real Headers instance (wp-http.js buildHeaders) — spreading it // would silently drop every entry including Authorization, since Headers is not a // plain object. Clone it properly instead. headers: (() => { const withBody = new Headers(headers); withBody.set('content-type', 'application/json'); return withBody; })(), method: requestOverride.method, body: JSON.stringify(resolvedBody), timeoutMs: options.timeoutMs, progress: options.progress, progressContext: { step: 'inspect-endpoint', entity: candidate.routePath, }, }); } else { // Per-route default query (see ROUTE_DEFAULT_QUERY_RULES): some collection routes apply // a default filter when the caller sends none, so sampling with `per_page` alone reads a // subset and the plan under-counts. Paging is layered on top; a route with no rule is // unchanged. const defaultQuery = options.defaultQuery || {}; const query = { ...defaultQuery, ...(candidate.supportsPagination ? { per_page: options.sampleLimit } : {}), }; if (Object.keys(defaultQuery).length > 0) { const pairs = Object.entries(defaultQuery).map(([key, value]) => `${key}=${value}`).join('&'); const reason = options.defaultQueryReason ? ` — ${options.defaultQueryReason}` : ''; details.discoveryNotes.push(`Sampled with route default query ${pairs} so the full collection is counted${reason}. Generated readers must send the same parameters.`); } sampleResponse = await fetchJson(baseUrl, candidate.routePath, { headers, method: 'GET', query, timeoutMs: options.timeoutMs, progress: options.progress, progressContext: { step: 'inspect-endpoint', entity: candidate.routePath, }, }); } if (sampleResponse.ok) { const payload = sampleResponse.json; const totalFromHeader = parseTotalHeader(sampleResponse.headers); // responseEnvelope support (finding #28): some plugin REST APIs (MailPoet's // /mailpoet/v1/* namespace, verified live 2026-08-11) wrap their records in a body path // instead of returning a flat array, and carry no X-WP-Total/X-WP-TotalPages headers — // the total lives inside the body too. Unwrap to the declared path and otherwise treat it // exactly like a flat array; a path that no longer resolves (stale profile, plugin // version drift) falls back to the raw payload rather than failing the run. const envelope = options.responseEnvelope || null; let effectivePayload = payload; let envelopeCount = null; if (envelope) { const items = getAtPath(payload, envelope.itemsPath); if (Array.isArray(items)) { effectivePayload = items; envelopeCount = envelope.countPath ? getAtPath(payload, envelope.countPath) : null; } else { 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.`); } } // responseFragmentGroupSize support: some plugin REST APIs (Back In Stock Notifier's // list_subscriber, verified live 2026-08-19) emit each logical record as N separate // single-key array entries in sequence instead of one merged object. Reassemble every N // entries into one record via Object.assign before anything downstream counts or samples // — a reader built from a stale sample would otherwise see 4x the real record count and a // schema missing whichever key happened to be sampled out of order. A trailing partial // group (malformed response, plugin bug) is dropped and noted rather than emitted broken. const fragmentGroupSize = options.responseFragmentGroupSize || null; if (fragmentGroupSize && Array.isArray(effectivePayload)) { effectivePayload = normalizeResponseRecords(effectivePayload, { fragmentGroupSize, notes: details.discoveryNotes }).records; } if (Array.isArray(effectivePayload)) { details.responseShape = envelope && effectivePayload !== payload ? 'enveloped-array' : 'array'; details.sampleRecords = effectivePayload.slice(0, options.sampleLimit); details.sampleRecordCount = effectivePayload.length; const hasEnvelopeCount = typeof envelopeCount === 'number'; details.recordCount = hasEnvelopeCount ? envelopeCount : (totalFromHeader !== null ? totalFromHeader : effectivePayload.length); details.inUse = details.recordCount > 0; if (details.responseShape === 'enveloped-array' && !hasEnvelopeCount) { details.discoveryNotes.push(`responseEnvelope has no working countPath (itemsPath="${envelope.itemsPath}"); recordCount reflects only the sampled page and may undercount the true total.`); } else if (details.responseShape === 'array' && totalFromHeader === null) { details.discoveryNotes.push('No X-WP-Total header returned; recordCount reflects only the sampled page and may undercount the true total.'); } if (candidate.routePath.startsWith('/wc/store/v1/') && !Object.keys(sampleResponse.headers || {}).some((key) => key.toLowerCase() === 'x-wp-totalpages')) { 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.'); } if (effectivePayload.length === 0) { details.discoveryNotes.push('Endpoint returned an empty array. The entity is advertised but appears unused (no records).'); } } else if (effectivePayload && typeof effectivePayload === 'object') { details.responseShape = 'object'; details.sampleRecords = [effectivePayload]; details.sampleRecordCount = 1; details.recordCount = totalFromHeader !== null ? totalFromHeader : 1; details.inUse = details.recordCount > 0; } else { details.responseShape = typeof effectivePayload; details.discoveryNotes.push(`Endpoint returned a non-object payload of type ${typeof effectivePayload}.`); } const firstRecord = details.sampleRecords[0]; if (firstRecord && typeof firstRecord === 'object') { details.relationships = summarizeRelationships(firstRecord); } } else { details.requestErrors.push({ request: requestOverride ? requestOverride.method : 'GET', routePath: candidate.routePath, status: sampleResponse.status, statusText: sampleResponse.statusText, url: sampleResponse.url, body: sampleResponse.text ? sampleResponse.text.slice(0, 1000) : '', }); } return details; } // Paginates every $SAMPLED_IDS dependency route to exhaustion, then batch-queries `candidate`'s // route (spec 0044) — split out of inspectEntity because it is a fundamentally different shape // (two routes, many requests, an explicit fail/defer path) from a single-fetch inspection. // Assumes a single batching axis: when a body references more than one dependency route, only // the first drives batching (noted below) — no known profile needs two independent id lists // batched together, and this stays honest about that boundary rather than guessing a policy. async function inspectDependentEntity(baseUrl, headers, candidate, options, details, requestOverride, dependencyRoutes) { const batchRoute = dependencyRoutes[0]; if (dependencyRoutes.length > 1) { details.discoveryNotes.push( `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.`, ); } const paged = await collectAllIds({ baseUrl, headers, route: batchRoute, timeoutMs: options.timeoutMs, progress: options.progress, }); if (!paged.ok) { // Fail/defer explicitly (spec 0044): recordCount/inUse stay at their initial `null`, which // is this codebase's existing "unknown, not zero" signal, backed by a requestErrors entry // explaining exactly which page of which route failed — never a false empty or a silent // undercount from whatever ids were collected before the failure. details.requestErrors.push({ request: 'GET', routePath: paged.failure.route, status: paged.failure.status, statusText: paged.failure.statusText, url: null, 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.`, }); return details; } details.discoveryNotes.push( `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.`, ); let shapeNotesEmitted = false; const batched = await queryInBatches({ baseUrl, headers, route: candidate.routePath, method: requestOverride.method, ids: paged.ids, recordKeyField: options.recordKeyField || 'id', buildBody: (batchIds) => resolveRequestBody(requestOverride.body, { [batchRoute]: batchIds.map(String) }), // Pass the RAW batch JSON through — response.json may be an enveloped object, not an // array, and normalizeResponseRecords (not this callback) is what unwraps it. Returning // its {ok, records} result unchanged is what lets queryInBatches distinguish "this batch's // response didn't match the declared shape" from "this batch legitimately matched zero // subscribers" and defer on the former instead of counting it as an exact zero. normalizeBatch: (rawJson) => { const normalized = normalizeResponseRecords(rawJson, { envelope: options.responseEnvelope, fragmentGroupSize: options.responseFragmentGroupSize, notes: shapeNotesEmitted ? null : details.discoveryNotes, }); shapeNotesEmitted = true; return normalized; }, timeoutMs: options.timeoutMs, progress: options.progress, }); if (!batched.ok) { const shapeFailure = batched.failure.reason === 'invalid-shape'; details.requestErrors.push({ request: requestOverride.method, routePath: batched.failure.route, status: batched.failure.status, statusText: batched.failure.statusText, url: null, body: shapeFailure ? `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.` : `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.`, }); return details; } details.responseShape = 'array'; details.sampleRecords = batched.records.slice(0, options.sampleLimit); details.sampleRecordCount = batched.records.length; // Exact, not an estimate: every dependency id was queried and every batch's records were // deduplicated, unlike the sampled-page count elsewhere in this function. details.recordCount = batched.records.length; details.inUse = details.recordCount > 0; 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.'); if (batched.records.length === 0) { details.discoveryNotes.push('No records matched any batch across the full paginated dependency id list. The entity is advertised but appears unused (no records).'); } const firstRecord = details.sampleRecords[0]; if (firstRecord && typeof firstRecord === 'object') { details.relationships = summarizeRelationships(firstRecord); } return details; } function markdownJsonBlock(value) { if (value === null || value === undefined) { return '\n\n`Unavailable`'; } return `\n\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``; } function renderEntityFile(details) { const errorLines = details.requestErrors.length > 0 ? details.requestErrors.map((error) => `- ${error.request} ${error.routePath}: ${error.status} ${error.statusText}`).join('\n') : '- None'; const notes = details.discoveryNotes.length > 0 ? details.discoveryNotes.map((note) => `- ${note}`).join('\n') : '- None'; const relationshipLines = details.relationships.length > 0 ? details.relationships .map((relationship) => `- \`${relationship.rel}\`${relationship.embeddable ? ' (embeddable)' : ''} → ${relationship.hrefs.join(', ')}`) .join('\n') : '- None detected (no `_links` in sample record)'; const recordCountLabel = details.recordCount === null ? 'unknown' : String(details.recordCount); const inUseLabel = details.inUse === null ? 'unknown' : details.inUse ? 'yes' : 'no (advertised but empty)'; const classificationLines = details.classification ? `- Discovery category: \`${details.classification.category}\`\n` + `- Discovery rule: \`${details.classification.ruleId}\`\n` + `- Discovery reason: ${details.classification.reason}\n` + `- Included by override: \`${details.classification.includedByOverride ? 'yes' : 'no'}\`\n` + `- Excluded by override: \`${details.classification.excludedByOverride ? 'yes' : 'no'}\`\n` : ''; return `# ${details.entityName}\n\n` + `- Namespace: \`${details.namespace}\`\n` + `- Route: \`${details.routePath}\`\n` + classificationLines + `- Methods: ${details.methods.map((method) => `\`${method}\``).join(', ') || '`unknown`'}\n` + `- Response shape: \`${details.responseShape}\`\n` + `- Record count: \`${recordCountLabel}\`\n` + `- In use: \`${inUseLabel}\`\n` + `- Sample records captured: ${details.sampleRecords.length}\n\n` + `## Notes\n${notes}\n\n` + `## Relationships\n${relationshipLines}\n\n` + `## Request Errors\n${errorLines}\n\n` + `## Schema${markdownJsonBlock(details.schema)}\n\n` + `## Collection Args${markdownJsonBlock(details.collectionArgs)}\n\n` + `## Sample Records${markdownJsonBlock(details.sampleRecords)}\n`; } function renderPluginSection(context) { if (!context.plugins) { return `## Plugin Coverage\n\nPlugin inventory was not run for this capture.\n\n`; } const { detection, coverage, summary, unprofiled } = context.plugins; const rows = coverage .map((row) => { const via = row.status === 'migration-planned' ? `${row.via} · ${row.confidence}` : '-'; const blocked = (row.blocked || []).length > 0 ? row.blocked.map((blocker) => `${blocker.kind}${blocker.declined ? ' (declined)' : ''}`).join(', ') : '-'; return `| ${row.capability} | \`${row.status}\` | ${via} | ${row.recognized ? 'yes' : 'no'} | ${row.plugins.join(', ') || '-'} | \`${row.channel || 'none'}\` | ${blocked} | ${row.action ? row.action : '-'} |`; }) .join('\n'); const authNote = detection.pluginListAvailable ? '' : `**\`GET /wp/v2/plugins\` was unavailable**, so installed-but-unprofiled plugins could not be enumerated. ` + `Detection used REST namespaces, declared routes, registered types/taxonomies, and public asset paths only. ` + `Re-run with an administrator credential for a complete plugin list.\n\n`; const installedRows = detection.installedButUnprofiled .map((entry) => `- \`${entry.plugin}\`${entry.name ? ` (${entry.name})` : ''}${entry.active ? '' : ' — inactive'}`) .join('\n') || '- None'; const unprofiledRows = unprofiled .map((entry) => `- \`${entry.namespace}\`: ${entry.routes.length} route(s) accepted by shape`) .join('\n') || '- None'; return `## Plugin Coverage\n\n` + authNote + `- Recognized plugins detected: ${detection.detected.length}\n` + `- Derived entities (no profile): ${context.plugins.genericEntities.length}\n` + `- Unprofiled namespaces: ${unprofiled.length}\n` + `- Installed but unrecognized: ${detection.installedButUnprofiled.length}\n` + `- Publicly fingerprinted (named, not enumerated): ${(detection.fingerprinted || []).length}\n` + `- Capabilities by status: ${Object.entries(summary.byStatus).map(([status, count]) => `\`${status}\`: ${count}`).join(', ') || 'none'}\n\n` + `| Capability | Status | Via | Recognized | Plugins | Channel | Blocked | Action needed |\n` + `| --- | --- | --- | --- | --- | --- | --- | --- |\n` + `${rows || '| None | - | - | - | - | - | - | - |'}\n\n` + `### Installed but unprofiled\n\n${installedRows}\n\n` + `### Unprofiled namespaces accepted by shape\n\n${unprofiledRows}\n\n` + `Full coverage evidence: [plugin-coverage.json](./plugin-coverage.json) · ` + `detection evidence: [plugin-inventory.json](./plugin-inventory.json)\n\n`; } function renderIndexFile(context) { const entityRows = context.entities.map((entity) => { const status = entity.requestErrors.length > 0 ? 'partial' : 'ok'; const recordCountLabel = entity.recordCount === null ? '?' : String(entity.recordCount); const inUseLabel = entity.inUse === null ? '?' : entity.inUse ? 'yes' : 'no'; return `| ${entity.entityName} | \`${entity.namespace}\` | \`${entity.routePath}\` | ${recordCountLabel} | ${inUseLabel} | ${entity.sampleRecords.length} | ${status} | [${entity.fileName}](./${entity.fileName}) |`; }).join('\n'); const errorLines = context.entities .flatMap((entity) => entity.requestErrors.map((error) => `- ${entity.entityName}: ${error.request} ${error.routePath} -> ${error.status} ${error.statusText}`)); const skippedByCategoryLines = Object.entries(context.skippedByCategory || {}) .map(([category, count]) => `- \`${category}\`: ${count}`) .join('\n') || '- None'; const overrideLines = [ `- Include routes: ${context.overrides.includeRoutes.map((route) => `\`${route}\``).join(', ') || '`none`'}`, `- Include namespaces: ${context.overrides.includeNamespaces.map((namespace) => `\`${namespace}\``).join(', ') || '`none`'}`, `- Include excluded categories: ${context.overrides.includeExcludedCategories.map((category) => `\`${category}\``).join(', ') || '`none`'}`, `- Exclude routes: ${context.overrides.excludeRoutes.map((route) => `\`${route}\``).join(', ') || '`none`'}`, `- Override reason: ${context.overrides.overrideReason ? context.overrides.overrideReason : '`none`'}`, ].join('\n'); const authGatedEntities = context.entities.filter((entity) => entity.requestErrors.some((error) => error.status === 401 || error.status === 403)); const authWarning = authGatedEntities.length > 0 ? `## ⚠️ Incomplete Capture (Authentication)\n\n` + `${authGatedEntities.length} entit${authGatedEntities.length === 1 ? 'y' : 'ies'} returned 401/403 and ` + `could not be captured` + `${context.authMode === 'none' ? ' — this run used **no credentials**' : ` with auth mode \`${context.authMode}\` (insufficient scope)`}.\n` + `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` + `Re-run with credentials for a complete and trustworthy capture.\n\n` + `Auth-gated entities: ${authGatedEntities.map((entity) => `\`${entity.entityName}\``).join(', ')}\n\n` : ''; const pluginSection = renderPluginSection(context); return `# WordPress Discovery\n\n` + `- Generated at: \`${context.generatedAt}\`\n` + `- Base URL: \`${context.baseUrl}\`\n` + `- REST root: \`${context.restRoot}\`\n` + `- Auth mode: \`${context.authMode}\`\n` + `- Advertised auth providers: ${context.authProviders.map((provider) => `\`${provider}\``).join(', ') || '`none`'}\n` + `- Namespaces advertised: ${context.namespaces.map((namespace) => `\`${namespace}\``).join(', ') || '`none`'}\n` + `- Advertised routes: ${context.totalAdvertisedRoutes}\n` + `- Candidate routes after generic filtering: ${context.totalCandidateRoutes}\n` + `- Sampled backend data/metadata routes: ${context.sampledRoutes}\n` + `- Skipped routes: ${context.skippedRoutes}\n` + `- Entities documented: ${context.entities.length}\n\n` + authWarning + `## Route Scope\n\n` + `### Skipped Routes by Category\n\n${skippedByCategoryLines}\n\n` + `### Overrides\n\n${overrideLines}\n\n` + `Skipped route evidence: [skipped-routes.json](./skipped-routes.json)\n\n` + pluginSection + `## Discovery Summary\n\n` + `| Entity | Namespace | Route | Records | In use | Samples | Status | File |\n` + `| --- | --- | --- | ---: | --- | ---: | --- | --- |\n` + `${entityRows || '| None | - | - | - | - | 0 | - | - |'}\n\n` + `## Route Index Sample${markdownJsonBlock(context.routeIndexSample)}\n\n` + `## Errors\n${errorLines.length > 0 ? errorLines.join('\n') : '- None'}\n`; } function skippedRoutesPayload(context) { return { generatedAt: context.generatedAt, totalAdvertisedRoutes: context.totalAdvertisedRoutes, totalCandidateRoutes: context.totalCandidateRoutes, sampledRoutes: context.sampledRoutes, skippedRoutes: context.skippedRoutes, overrides: context.overrides, routes: context.classifications .filter((classification) => classification.effectiveAction === 'skip') .map((classification) => ({ routePath: classification.routePath, namespace: classification.namespace, category: classification.category, reason: classification.reason, ruleId: classification.ruleId, sampleByDefault: classification.sampleByDefault, canIncludeByOverride: classification.canIncludeByOverride, includedByOverride: classification.includedByOverride, excludedByOverride: classification.excludedByOverride, effectiveAction: classification.effectiveAction, duplicateOf: classification.duplicateOf, overrideReason: classification.overrideReason, })), }; } // orchestration/decisions.json sits two levels above the discovery out-dir in the standard // layout (migrations//data/wp-discovery); --decisions overrides. Missing or // unparseable files mean "no answers yet", never an error — the ask may not have run. function loadDecisions(args) { const filePath = args.decisions || (args.outDir ? path.resolve(args.outDir, '..', '..', 'orchestration', 'decisions.json') : null); if (!filePath) return {}; try { return JSON.parse(require('node:fs').readFileSync(filePath, 'utf8')); } catch { return {}; } } // The batched ask (J1 step 9) records one decision per blocker under the key // `pluginBlocker::`; a value of `declined` (or `skipped`) marks it declined. function applyBlockerDecisions(rows, decisions) { for (const row of rows) { for (const blocker of row.blocked || []) { const entry = decisions[`pluginBlocker:${row.capability}:${blocker.kind}`]; if (entry && ['declined', 'skipped'].includes(entry.value)) blocker.declined = true; } } } function loadTargetKnowledge() { try { const { knowledgeRoot, knowledgeSummary, } = require('../../rp-target-wix/lib/domain-knowledge.js'); return knowledgeSummary(knowledgeRoot(path.resolve(__dirname, '..', '..', 'rp-target-wix'))); } catch (error) { // A partial install must not break discovery; coverage then reports CMS/native-gap // conservatively instead of resolving target refs. return { knownRefs: new Set(), capabilityRefs: new Map(), verificationByRef: new Map(), loadError: error.message }; } } // Plugin detection, Tier-B derivation, and coverage classification, run after sampling so // record payloads are available for core-embedded plugins that add no REST route. function buildPluginCoverage({ args, inventory, classifications, entities, allCandidates = [] }) { const profiles = loadProfiles(pluginsRoot(path.resolve(__dirname, '..'))); const sampledRecordProperties = collectRecordProperties(entities); const detection = detectPlugins({ profiles, restIndex: inventory.restIndex, pluginList: inventory.detection.pluginListAvailable ? inventory.pluginList : null, types: inventory.types, taxonomies: inventory.taxonomies, htmlSources: inventory.htmlSources || [], sampledRecordProperties, fingerprintAliases: loadFingerprintAliases(pluginsRoot(path.resolve(__dirname, '..'))), }); const sampledByRoute = new Map(entities.map((entity) => [entity.routePath, entity])); const unprofiled = [ ...collectUnprofiledRoutes({ classifications, detection }), ...collectCandidateNamespaces({ classifications, candidates: allCandidates, detection }), ]; const genericEntities = deriveGenericEntities({ types: inventory.types, taxonomies: inventory.taxonomies, classifications, detection, sampledByRoute, }); const targetKnowledge = loadTargetKnowledge(); const knowledgeDir = pluginsRoot(path.resolve(__dirname, '..')); const hints = loadNoMigrationNeeded(knowledgeDir); const rows = classifyCoverage({ detection, genericEntities, unprofiledRoutes: unprofiled, targetKnowledge, entityStatsByRoute: sampledByRoute, // Only human-signed verdicts may produce a requires-development status. requiresDevelopmentEntries: loadRequiresDevelopment(knowledgeDir).capabilities || [], // Working notes for profiled capabilities we have not placed yet. pendingNotes: loadPendingDecisions(knowledgeDir).capabilities || [], // Nothing-to-move verdicts, both tiers: the slug list resolves unprofiled plugins into // rows (basis: list), and the signed capabilities[] register resolves profiled // capabilities a human decided need no migration (basis: decision). noMigrationNeeded: hints, }); // Read back the batched-ask answers so a declined blocker renders as declined, never as // unanswered. A declined blocker stays on the row — skipping is an answer, not silence. applyBlockerDecisions(rows, loadDecisions(args)); // One row per installed plugin, for customer review and for debugging this run later. const dispositionRows = buildDispositionRows({ detection, coverage: rows, profiles, hints, }); return { detection, genericEntities, unprofiled, sampledRecordProperties, coverage: rows, summary: coverageSummary(rows), disposition: dispositionRows, dispositionSummary: summarizeDispositions(dispositionRows), targetKnowledgeError: targetKnowledge.loadError || null, }; } // plugin-rest-child support (finding #21): a parent-scoped sub-resource (e.g. // /wc/v3/orders/{parentId}/notes) cannot be listed on its own, so it is checked by // substituting real ids from the already-sampled parent collection. Pure and exported so the // id-picking and route-building logic is fixture-testable with no live site. // Candidate parent ids for a plugin-rest-child entity, drawn from its parent collection's // already-sampled records and capped at a small representative count — this is a check, not // an attempt at full coverage. function pickChildSampleParentIds(parentEntity, limit) { const records = parentEntity?.sampleRecords || []; return records .map((record) => (record && typeof record === 'object' ? record.id : undefined)) .filter((id) => id !== undefined && id !== null) .slice(0, limit); } function buildChildRoutePath(template, parentId) { return template.replace(CHILD_ROUTE_PLACEHOLDER, encodeURIComponent(String(parentId))); } function describeChildSample(sample) { const label = `${sample.plugin} · ${sample.entity}`; if (sample.parentsChecked === 0) { return `${label}: no sampled ${sample.parentRoute} records were available to check ${sample.route} against.`; } return `${label}: checked ${sample.parentsChecked} sampled ${sample.parentRoute} record(s)' ` + `${sample.route} — ${sample.parentsWithRecords} of ${sample.parentsChecked} returned matching records ` + `(${sample.sampleRecords.length} sample record(s) total). Representative check only, not a full count.`; } // Live per-parent sampling for every detected plugin-rest-child entity. Runs after the main // sampling loop so `sampledByRoute` already holds real parent records to draw ids from. async function sampleChildEntities({ baseUrl, headers, timeoutMs, sampleLimit, progress, detection, sampledByRoute }) { const results = []; const parentSampleLimit = Math.min(3, sampleLimit); for (const detected of detection?.detected || []) { for (const entity of detected.entities) { if (entity.channel !== 'plugin-rest-child' || entity.channelStatus !== 'available') continue; const parentEntity = sampledByRoute.get(entity.parentRoute); const parentIds = pickChildSampleParentIds(parentEntity, parentSampleLimit); if (parentIds.length === 0) { results.push({ plugin: detected.plugin, entity: entity.entity, parentRoute: entity.parentRoute, route: entity.route, parentsChecked: 0, parentsWithRecords: 0, sampleRecords: [], }); continue; } const sampleRecords = []; let parentsWithRecords = 0; for (const parentId of parentIds) { const childRoute = buildChildRoutePath(entity.route, parentId); // eslint-disable-next-line no-await-in-loop -- each parent's sub-resource must be // fetched under the shared rate limiter, one request at a time, like inspectEntity. const response = await fetchJson(baseUrl, childRoute, { headers, method: 'GET', timeoutMs, progress, progressContext: { step: 'sample-child-entity', entity: entity.entity }, }); if (response.ok && Array.isArray(response.json) && response.json.length > 0) { parentsWithRecords += 1; sampleRecords.push(...response.json.slice(0, Math.max(0, sampleLimit - sampleRecords.length))); } } results.push({ plugin: detected.plugin, entity: entity.entity, parentRoute: entity.parentRoute, route: entity.route, parentsChecked: parentIds.length, parentsWithRecords, sampleRecords, }); } } return results; } function pluginCoveragePayload(context) { const plugins = context.plugins; return { generatedAt: context.generatedAt, baseUrl: context.baseUrl, authenticated: context.authMode !== 'none', pluginListAvailable: plugins.detection.pluginListAvailable, notes: [ ...(context.pluginNotes || []), ...(plugins.targetKnowledgeError ? [`Wix target knowledge could not be loaded (${plugins.targetKnowledgeError}); target refs were not resolved.`] : []), ...(plugins.childSamples || []).map(describeChildSample), ], childSamples: plugins.childSamples || [], summary: plugins.summary, perPluginSummary: plugins.dispositionSummary, // The status invariant: N installed plugins produce N per-plugin rows, recognized or not. // Recorded here so the check is inspectable in the artifact, not just in tests. statusInvariant: { detectedRecognized: plugins.detection.detected.length, installedButUnrecognized: plugins.detection.installedButUnprofiled.length, genericEntities: plugins.genericEntities.length, unprofiledNamespaces: plugins.unprofiled.length, pluginListAvailable: plugins.detection.pluginListAvailable, }, capabilities: plugins.coverage, // One artifact, two views: the capability rows above are the contract; // this per-plugin projection answers the question a merchant actually asks. perPlugin: plugins.disposition, genericEntities: plugins.genericEntities, unprofiledRoutes: plugins.unprofiled, }; } async function ensureDir(dirPath) { await fs.mkdir(dirPath, { recursive: true }); } async function writeOutputs(outDir, context, progress) { await ensureDir(outDir); await fs.writeFile(path.join(outDir, 'README.md'), renderIndexFile(context), 'utf8'); progress?.progress('Wrote WordPress discovery index', { phase: 'discovery', step: 'write-artifact', artifact: path.join(outDir, 'README.md') }); await fs.writeFile( path.join(outDir, 'skipped-routes.json'), `${JSON.stringify(skippedRoutesPayload(context), null, 2)}\n`, 'utf8', ); progress?.progress('Wrote WordPress skipped-route index', { phase: 'discovery', step: 'write-artifact', artifact: path.join(outDir, 'skipped-routes.json'), }); if (context.plugins) { await fs.writeFile( path.join(outDir, 'plugin-inventory.json'), `${JSON.stringify(inventoryPayload({ generatedAt: context.generatedAt, baseUrl: context.baseUrl, authenticated: context.authMode !== 'none', detection: context.plugins.detection, notes: context.pluginNotes || [], unprofiled: context.plugins.unprofiled, profileCount: context.pluginProfileCount, }), null, 2)}\n`, 'utf8', ); progress?.progress('Wrote WordPress plugin inventory', { phase: 'discovery', step: 'write-artifact', artifact: path.join(outDir, 'plugin-inventory.json'), }); await fs.writeFile( path.join(outDir, 'plugin-coverage.json'), `${JSON.stringify(pluginCoveragePayload(context), null, 2)}\n`, 'utf8', ); progress?.progress('Wrote WordPress plugin coverage', { phase: 'discovery', step: 'write-artifact', artifact: path.join(outDir, 'plugin-coverage.json'), }); // Deliberately no plugin-disposition.md: the per-plugin view lives inside // plugin-coverage.json (one coverage artifact, no second document). } for (const entity of context.entities) { await fs.writeFile(path.join(outDir, entity.fileName), renderEntityFile(entity), 'utf8'); progress?.progress(`Wrote WordPress discovery artifact for ${entity.entityName}`, { phase: 'discovery', step: 'write-artifact', entity: entity.routePath, artifact: path.join(outDir, entity.fileName), }); } } async function main() { const parsed = parseProgressArgs(process.argv.slice(2)); progress = createProgressLogger({ script: 'skills/wix-replatform/resources/rp-source-wordpress/scripts/wp-discovery.js', ...parsed.progress, }); progress.start('WordPress discovery started', { phase: 'discovery' }); const args = await hydrateArgsFromEnvFile(parseArgs(parsed.args)); if (args.help) { printUsage(); progress.complete('WordPress discovery help shown', { phase: 'discovery', step: 'help' }); return; } if (!args.baseUrl || !args.outDir) { printUsage(); progress.error('Missing required WordPress discovery arguments', { phase: 'discovery' }); throw new Error('Missing required arguments: --base-url and --out-dir are required.'); } configureRateLimit({ rateLimitRpm: args.rateLimitRpm, maxRetries: args.maxRetries }); const headers = buildHeaders(args); const rootResponse = await fetchJson(args.baseUrl, '', { headers, method: 'GET', timeoutMs: args.timeoutMs, progress, progressContext: { step: 'rest-index' }, }); if (!rootResponse.ok || !rootResponse.json) { throw new Error(`Failed to fetch WordPress REST index from ${rootResponse.url}: ${rootResponse.status} ${rootResponse.statusText}`); } const indexJson = rootResponse.json; const namespaces = Array.isArray(indexJson?.namespaces) ? indexJson.namespaces : []; progress.progress('WordPress namespace enumeration completed', { phase: 'discovery', step: 'namespace-enumeration', count: namespaces.length, unit: 'namespaces', }); // Plugin inventory pre-pass: runs before classification so profile-declared routes are // already in scope when routes are classified. let inventory = null; if (args.pluginInventory) { try { const gathered = await gatherInventory({ baseUrl: args.baseUrl, headers, timeoutMs: args.timeoutMs, htmlFingerprint: args.htmlFingerprint, restIndex: indexJson, logger: progress, }); inventory = gathered; progress.progress('WordPress plugin inventory completed', { phase: 'discovery', step: 'plugin-inventory', count: gathered.detection.detected.length, unit: 'plugins', details: { pluginListAvailable: gathered.detection.pluginListAvailable, installedButUnprofiled: gathered.detection.installedButUnprofiled.length, }, }); } catch (error) { // Never fail discovery over the plugin pre-pass; record and continue. progress.progress(`WordPress plugin inventory skipped: ${error.message}`, { phase: 'discovery', step: 'plugin-inventory', }); } } const requestOverrides = buildRequestOverrides(pluginsRoot(path.resolve(__dirname, '..'))); const candidates = deriveEntityCandidates(indexJson, args.includeNamespaces, requestOverrides); progress.progress('WordPress endpoint candidates derived', { phase: 'discovery', step: 'derive-endpoints', count: candidates.length, unit: 'endpoints', }); // Registered post types/taxonomies are positive evidence for classification, so the // inventory pre-pass must run before this point (it fetches /wp/v2/types+taxonomies). const classifications = classifyRoutes(candidates, { ...summarizeOverrides(args), registeredRestBases: inventory ? buildRegisteredRestBases({ types: inventory.types, taxonomies: inventory.taxonomies }) : null, }); // classifyRoutes() loads the checked-in plugin profiles lazily via defaultPluginRules(); // a failed load degrades to zero plugin rules with no exception, so it must be surfaced // here or every plugin-owned route silently falls through to generic/unsupported handling. const pluginRulesError = defaultPluginRules().loadError || null; if (pluginRulesError) { progress.progress(`WordPress plugin route rules could not be loaded (${pluginRulesError}); plugin-owned routes will not be recognized this run.`, { phase: 'discovery', step: 'classify-endpoints', }); } const candidatesByRoute = new Map(candidates.map((candidate) => [candidate.routePath, candidate])); const candidatesToInspect = classifications .filter((classification) => ['sample', 'metadata'].includes(classification.effectiveAction)) .map((classification) => ({ ...candidatesByRoute.get(classification.routePath), classification, })); const skippedByCategory = summarizeSkippedByCategory(classifications); const skippedRoutes = classifications.filter((classification) => classification.effectiveAction === 'skip').length; progress.progress('WordPress endpoint candidates classified', { phase: 'discovery', step: 'classify-endpoints', count: candidatesToInspect.length, total: candidates.length, unit: 'endpoints', details: { sampledRoutes: candidatesToInspect.length, skippedRoutes, skippedByCategory, }, }); const entities = []; const responseEnvelopes = buildResponseEnvelopes(pluginsRoot(path.resolve(__dirname, '..'))); const responseFragmentGroups = buildResponseFragmentGroups(pluginsRoot(path.resolve(__dirname, '..'))); const recordKeyFields = buildRecordKeyFields(pluginsRoot(path.resolve(__dirname, '..'))); for (let i = 0; i < candidatesToInspect.length; i += 1) { const candidate = candidatesToInspect[i]; progress.progress(`Inspecting WordPress endpoint ${candidate.routePath}`, { phase: 'discovery', step: 'inspect-endpoint', entity: candidate.routePath, count: i + 1, total: candidatesToInspect.length, unit: 'endpoints', percent: candidatesToInspect.length ? Math.round(((i + 1) / candidatesToInspect.length) * 100) : 100, }); const details = await inspectEntity(args.baseUrl, headers, candidate, { sampleLimit: args.sampleLimit, timeoutMs: args.timeoutMs, progress, responseEnvelope: responseEnvelopes.get(candidate.routePath) || null, responseFragmentGroupSize: responseFragmentGroups.get(candidate.routePath) || null, recordKeyField: recordKeyFields.get(candidate.routePath) || null, requestOverride: candidate.requestOverride, defaultQuery: defaultQueryFor(candidate.routePath), defaultQueryReason: defaultQueryReasonFor(candidate.routePath), }); entities.push(details); } // Second detection pass + Tier-B derivation + coverage. This is where plugins that add // no REST route (Product Bundles, ACF, Yoast) become visible, because it looks at the // record payload keys the sampling loop just collected. let plugins = null; if (inventory) { plugins = buildPluginCoverage({ args, inventory, classifications, entities, allCandidates: candidates }); progress.progress('WordPress plugin coverage classified', { phase: 'discovery', step: 'plugin-coverage', count: plugins.coverage.length, unit: 'capabilities', details: plugins.summary.byStatus, }); // Third pass: plugin-rest-child entities (finding #21) — a parent-scoped sub-resource // (e.g. /wc/v3/orders/{parentId}/notes) cannot be listed on its own, so it is checked by // substituting real ids from the parent collection this run already sampled. const childSamples = await sampleChildEntities({ baseUrl: args.baseUrl, headers, timeoutMs: args.timeoutMs, sampleLimit: args.sampleLimit, progress, detection: plugins.detection, sampledByRoute: new Map(entities.map((entity) => [entity.routePath, entity])), }); if (childSamples.length > 0) { progress.progress('WordPress per-parent sub-resource sampling completed', { phase: 'discovery', step: 'sample-child-entity', count: childSamples.length, unit: 'entities', }); } plugins = { ...plugins, childSamples }; } const context = { generatedAt: new Date().toISOString(), baseUrl: normalizeBaseUrl(args.baseUrl), restRoot: `${normalizeBaseUrl(args.baseUrl)}/wp-json`, authMode: summarizeAuthMode(args), authProviders: Object.keys(indexJson?.authentication || {}), namespaces, totalAdvertisedRoutes: Object.keys(indexJson?.routes || {}).length, totalCandidateRoutes: candidates.length, sampledRoutes: candidatesToInspect.length, skippedRoutes, skippedByCategory, overrides: summarizeOverrides(args), classifications, routeIndexSample: Object.keys(indexJson?.routes || {}).slice(0, 50), entities, plugins, pluginNotes: [ ...(inventory ? inventory.notes : ['Plugin inventory was disabled with --no-plugin-inventory.']), ...(pluginRulesError ? [`WordPress plugin route rules could not be loaded (${pluginRulesError}); plugin-owned routes were not recognized this run.`] : []), ], pluginProfileCount: inventory ? inventory.profileCount : 0, }; await writeOutputs(args.outDir, context, progress); console.log(`Wrote ${entities.length + 1} markdown files and skipped-routes.json to ${args.outDir}`); progress.complete('WordPress discovery completed', { phase: 'discovery', artifact: args.outDir, count: entities.length, unit: 'entities', }); } if (require.main === module) { main().catch((error) => { console.error(error.stack || error.message); if (progress) { progress.error(error && error.message ? error.message : 'WordPress discovery failed', { phase: 'discovery' }); } process.exitCode = 1; }); } module.exports = { parseArgs, deriveEntityCandidates, renderIndexFile, renderPluginSection, skippedRoutesPayload, buildPluginCoverage, pluginCoveragePayload, loadTargetKnowledge, pickChildSampleParentIds, buildChildRoutePath, describeChildSample, sampleChildEntities, getAtPath, inspectEntity, resolveRequestBody, normalizeResponseRecords, findSampledIdsDependencies, };