Setting the file. One moment.
Domain Knowledge · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 38.10
Multilingual Restore
function validateKnowledge
— line 150
This file
Number 38.139
Position 139 of 174
Type JavaScript
Size 45 KB
Lines 918 lib/ domain-knowledge.js
JavaScript · 918 lines · 45 KB
9
'cms'
,
10 'native-plus-cms' ,
11 'setup-config' ,
12 'skip-by-default' ,
13 'unsupported-native-gap' ,
14 // For manual-mapping: a complete, decided mapping with no write for our code to make — the entity
15 // carries `manualSteps` instead of a working `preferredWrite`. Deliberately not added to
16 // NATIVE_CLASSIFICATIONS below: import codegen must never treat this as a write target.
17 'manual-mapping' ,
18 ]);
19 const ID_POLICIES = new Set ([ 'client-assigned' , 'server-assigned' , 'natural-key' , 'not-applicable' ]);
20 const VERIFICATIONS = new Set ([ 'verified-live' , 'docs' , 'source-review' , 'internal-only' , 'unverified' , 'none' ]);
21 const RELIABILITY = new Set ([ 'reliable' , 'partially-reliable' , 'unreliable' , 'unknown' ]);
22 const SURVEY_VERDICTS = new Set ([ 'claimed' , 'not-importable' , 'not-relevant' , 'gap' , 'unreviewed' ]);
23 const NATIVE_CLASSIFICATIONS = new Set ([ 'native' , 'native-plus-cms' ]);
24 const CONTACT_FIELD_KINDS = new Set ([ 'email' , 'phone' ]);
25 const BLOCKED_DEPENDENCY_DEGRADATIONS = new Set ([ 'warning' , 'deferred' ]);
26 const MANUAL_STEP_ACTORS = new Set ([ 'merchant' , 'external' , 'wix-automatable' ]);
27 const WRITER_IDS = new Set (Object. keys (wixWriters). filter (( name ) => typeof wixWriters[name] === 'function' ));
28
29 function knowledgeRoot ( rootDir = path. resolve (__dirname, '..' )) {
30 return path. join (rootDir, 'domains' );
31 }
32
33 function readJson ( filePath ) {
34 return JSON . parse (fs. readFileSync (filePath, 'utf8' ));
35 }
36
37 function writeJson ( filePath , value ) {
38 fs. writeFileSync (filePath, `${ JSON . stringify ( value , null , 2 ) } \n ` );
39 }
40
41 function entityRef ( entity ) {
42 return `${ entity . domain }/${ entity . entity }` ;
43 }
44
45 function listDomainDirs ( domainsDir ) {
46 return fs
47 . readdirSync (domainsDir, { withFileTypes: true })
48 . filter (( entry ) => entry. isDirectory ())
49 . map (( entry ) => entry.name)
50 . sort ();
51 }
52
53 function listEntityFiles ( domainDir ) {
54 const entitiesDir = path. join (domainDir, 'entities' );
55 if ( ! fs. existsSync (entitiesDir)) return [];
56 return fs
57 . readdirSync (entitiesDir, { withFileTypes: true })
58 . filter (( entry ) => entry. isFile () && entry.name. endsWith ( '.json' ))
59 . map (( entry ) => entry.name)
60 . sort ();
61 }
62
63 function loadDomain ( domainsDir , domain ) {
64 return readJson (path. join (domainsDir, domain, 'domain.json' ));
65 }
66
67 function loadEntity ( domainsDir , domain , entity ) {
68 return readJson (path. join (domainsDir, domain, 'entities' , `${ entity }.json` ));
69 }
70
71 // Builder hold codes the toolkit retired (a shipped route now writes those records), from every
72 // entity's `retiredHoldCodes` ({ code: remediation }). A builder or generated importer that still
73 // emits one is refused before import (read-back inventory, import-contract-verify).
74 function retiredHoldCodes ( domainsDir = knowledgeRoot ()) {
75 const codes = {};
76 for ( const domain of listDomainDirs (domainsDir)) for ( const file of listEntityFiles (path. join (domainsDir, domain))) {
77 const entity = loadEntity (domainsDir, domain, file. slice ( 0 , - 5 ));
78 for ( const [ code , remediation ] of Object. entries (entity.retiredHoldCodes || {})) codes[code] = { ref: `${ domain }/${ entity . entity }` , remediation };
79 }
80 return codes;
81 }
82
83 function generateIndex ( domainsDir ) {
84 const index = {
85 schemaVersion: 1 ,
86 domains: {},
87 sourceAliasIndex: {},
88 routeAliasIndex: {},
89 capabilityIndex: {},
90 flags: {},
91 };
92
93 for ( const domain of listDomainDirs (domainsDir)) {
94 const domainPath = path. join (domain, 'domain.json' );
95 const domainJson = loadDomain (domainsDir, domain);
96 const entities = {};
97 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
98 const entityId = fileName. replace ( / \. json $ / , '' );
99 const entityPath = path. join (domain, 'entities' , fileName);
100 const entity = readJson (path. join (domainsDir, entityPath));
101 entities[entityId] = {
102 path: `domains/${ entityPath }` ,
103 displayName: entity.displayName,
104 classification: entity.target && entity.target.classification,
105 reliabilityStatus: entity.reliability && entity.reliability.status,
106 reliabilityFlags: (entity.reliability && entity.reliability.flags) || [],
107 summary: firstPitfallOrGuidance (entity),
108 };
109 if (entity.capability) {
110 if ( ! index.capabilityIndex[entity.capability]) index.capabilityIndex[entity.capability] = [];
111 index.capabilityIndex[entity.capability]. push ( `${ domain }/${ entityId }` );
112 }
113 for ( const alias of entity.sourceAliases || []) {
114 const key = `${ alias . sourceSystem }:${ alias . sourceEntity }` ;
115 if ( ! index.sourceAliasIndex[key]) index.sourceAliasIndex[key] = [];
116 index.sourceAliasIndex[key]. push ( `${ domain }/${ entityId }` );
117 for ( const route of alias.routes || []) {
118 if ( ! index.routeAliasIndex[route]) index.routeAliasIndex[route] = [];
119 index.routeAliasIndex[route]. push ( `${ domain }/${ entityId }` );
120 }
121 }
122 for ( const flag of (entity.reliability && entity.reliability.flags) || []) {
123 if ( ! index.flags[flag]) index.flags[flag] = [];
124 index.flags[flag]. push ( `${ domain }/${ entityId }` );
125 }
126 }
127 index.domains[domain] = {
128 path: `domains/${ domainPath }` ,
129 displayName: domainJson.displayName,
130 ownerHint: domainJson.ownerHint,
131 entities,
132 };
133 }
134
135 for ( const collection of [index.sourceAliasIndex, index.routeAliasIndex, index.capabilityIndex, index.flags]) {
136 for ( const key of Object. keys (collection)) collection[key] = Array. from ( new Set (collection[key])). sort ();
137 }
138
139 return index;
140 }
141
142 function firstPitfallOrGuidance ( entity ) {
143 if (Array. isArray (entity.pitfalls) && entity.pitfalls[ 0 ] && entity.pitfalls[ 0 ].summary) {
144 return entity.pitfalls[ 0 ].summary;
145 }
146 if (Array. isArray (entity.mappingGuidance) && entity.mappingGuidance[ 0 ]) return entity.mappingGuidance[ 0 ];
147 return '' ;
148 }
149
150 function validateKnowledge ( domainsDir ) {
151 const errors = [];
152 const indexPath = path. join (domainsDir, 'index.json' );
153 const index = fs. existsSync (indexPath) ? readJson (indexPath) : null ;
154 const generated = generateIndex (domainsDir);
155
156 if ( ! fs. existsSync (path. join (domainsDir, 'schema.json' ))) {
157 errors. push ( 'domains/schema.json is missing' );
158 }
159
160 const alternates = [], defaults = new Map ();
161 for ( const domain of listDomainDirs (domainsDir)) {
162 const domainFile = path. join (domainsDir, domain, 'domain.json' );
163 if ( ! fs. existsSync (domainFile)) {
164 errors. push ( `${ domain }: missing domain.json` );
165 continue ;
166 }
167 const domainJson = readJson (domainFile);
168 requireFields (domainJson, [ 'schemaVersion' , 'domain' , 'displayName' , 'ownerHint' , 'defaultImportOrder' , 'evidence' ], `${ domain }/domain.json` , errors);
169 if (domainJson.schemaVersion !== 1 ) errors. push ( `${ domain }/domain.json: schemaVersion must be 1` );
170 if (domainJson.domain !== domain) errors. push ( `${ domain }/domain.json: domain must match directory` );
171 for ( const entityId of domainJson.defaultImportOrder || []) {
172 if ( ! fs. existsSync (path. join (domainsDir, domain, 'entities' , `${ entityId }.json` ))) {
173 errors. push ( `${ domain }/domain.json: defaultImportOrder references missing entity ${ entityId }` );
174 }
175 }
176 // REACHABILITY, the other direction — and the one that actually bites. The check above
177 // catches an order naming an entity that does not exist, which fails loudly. The reverse
178 // fails SILENTLY: an entity that is authored, has a verified writer, is covered by tests,
179 // and is named in no import order at all. Nothing errors, every test passes, and a run
180 // simply never writes it. That is how three separate entities shipped unreachable
181 // (ecom/shipping-option, ecom/pickup-location, site/business-contact) — each found only
182 // when a migrated site was missing the data and a human noticed.
183 //
184 // Rule: an entity with a writerId MUST be in its domain's defaultImportOrder. An entity
185 // with `writerId: null` has nothing to run and is legitimately absent, so the exclusion is
186 // derived from the entity's own contract rather than a hand-maintained opt-out list that
187 // would drift the same way.
188 const entitiesPath = path. join (domainsDir, domain, 'entities' );
189 if (fs. existsSync (entitiesPath)) {
190 const order = new Set (domainJson.defaultImportOrder || []);
191 for ( const fileName of fs. readdirSync (entitiesPath). filter (( f ) => f. endsWith ( '.json' )). sort ()) {
192 const entityId = fileName. replace ( / \. json $ / , '' );
193 if (order. has (entityId)) continue ;
194 let entityJson = null ;
195 try {
196 entityJson = JSON . parse (fs. readFileSync (path. join (entitiesPath, fileName), 'utf8' ));
197 } catch (error) {
198 continue ; // the entity's own parse error is reported by validateEntity
199 }
200 if (entityJson && entityJson.preferredWrite && entityJson.preferredWrite.writerId) {
201 errors. push ( `${ domain }/domain.json: entity ${ entityId } has writerId ${ entityJson . preferredWrite . writerId } but is not in defaultImportOrder — a writer nothing runs migrates nothing. Add it to the order, or set preferredWrite.writerId to null if it genuinely cannot be written yet.` );
202 }
203 }
204 }
205 if (fs. existsSync (entitiesPath)) {
206 const entities = listEntityFiles (entitiesPath). map (( fileName ) => ({
207 id: fileName. replace ( / \. json $ / , '' ),
208 dependsOn: loadEntity (domainsDir, domain, fileName. replace ( / \. json $ / , '' )).dependsOn,
209 }));
210 errors. push ( ... validateDependsOnPromotion (domain, domainJson.dependsOnRequired, entities));
211 }
212 validateEvidence (domainJson.evidence || [], `${ domain }/domain.json` , errors);
213
214 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
215 const entityId = fileName. replace ( / \. json $ / , '' );
216 const entity = loadEntity (domainsDir, domain, entityId);
217 const label = `${ domain }/entities/${ fileName }` ;
218 validateEntity (entity, domain, entityId, label, errors);
219 for ( const [ index , alternate ] of (entity.alternateWrites || []). entries ()) if (alternate && typeof alternate.writerId === 'string' ) alternates. push ({ label: `${ label }: alternateWrites[${ index }]` , ref: `${ domain }/${ entityId }` , writerId: alternate.writerId });
220 if (entity.preferredWrite && entity.preferredWrite.writerId) defaults. set (entity.preferredWrite.writerId, `${ domain }/${ entityId }` );
221 }
222
223 validateDocsSurvey (domainsDir, domain, domainJson, errors);
224 }
225
226 for ( const row of alternates) {
227 const owner = defaults. get (row.writerId);
228 if (owner && owner !== row.ref) errors. push ( `${ row . label }: writerId ${ row . writerId } is the default writer of ${ owner }` );
229 }
230
231 if ( ! index) {
232 errors. push ( 'domains/index.json is missing; run domain-knowledge-validate.js --write-index' );
233 } else {
234 const current = JSON . stringify (index);
235 const expected = JSON . stringify (generated);
236 if (current !== expected) {
237 errors. push ( 'domains/index.json is stale; run domain-knowledge-validate.js --write-index' );
238 }
239 validateIndexConsistency (index, domainsDir, errors);
240 }
241
242 validateDependsOn (domainsDir, errors);
243
244 return { ok: errors. length === 0 , errors, generatedIndex: generated };
245 }
246
247 function validateDependsOnPromotion ( domain , dependsOnRequired , entities ) {
248 const errors = [];
249 if ( typeof dependsOnRequired !== 'boolean' ) {
250 errors. push ( `${ domain }/domain.json: dependsOnRequired must be a boolean` );
251 return errors;
252 }
253 const undeclared = entities. filter (( entity ) => entity.dependsOn === undefined );
254 if (dependsOnRequired && undeclared. length ) {
255 for ( const entity of undeclared) {
256 errors. push ( `${ domain }/domain.json: dependsOnRequired is true but ${ domain }/entities/${ entity . id }.json has no dependsOn; review and declare its dependencies` );
257 }
258 }
259 if ( ! dependsOnRequired && undeclared. length === 0 ) {
260 errors. push ( `${ domain }/domain.json: dependsOnRequired is false but every entity is declared; promote this domain by setting dependsOnRequired to true` );
261 }
262 return errors;
263 }
264
265 // The dependsOn graph across every entity that has it. Pure disk read, no validation —
266 // `validateDependsOn` (ref resolution, cycles) and `checkScope` (rp-mapper's review-gate check)
267 // both build on this so the graph is assembled exactly once, the same way, everywhere it's needed.
268 function buildDependsOnGraph ( domainsDir ) {
269 const graph = new Map (); // ref -> dependsOn refs
270 for ( const domain of listDomainDirs (domainsDir)) {
271 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
272 const entityId = fileName. replace ( / \. json $ / , '' );
273 const entity = loadEntity (domainsDir, domain, entityId);
274 if (entity.dependsOn === undefined ) continue ;
275 const ref = `${ domain }/${ entityId }` ;
276 graph. set (ref, Array. isArray (entity.dependsOn) ? entity.dependsOn : []);
277 }
278 }
279 return graph;
280 }
281
282 // dependsOn is optional only in a domain that has not yet been promoted. Once a
283 // domain's explicit dependsOnRequired flag is true, validateKnowledge rejects an undeclared
284 // entity; in every domain a present declaration must resolve and the graph must stay acyclic.
285 // Builds its own local graph rather than calling buildDependsOnGraph afterward — this function
286 // already reads and parses every entity file once for ref-resolution/array-type checks, so
287 // reusing that same read for cycle detection (instead of a second full read+parse pass) halves
288 // the I/O of every `domain-knowledge-validate.js` run.
289 function validateDependsOn ( domainsDir , errors ) {
290 const graph = new Map ();
291 for ( const domain of listDomainDirs (domainsDir)) {
292 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
293 const entityId = fileName. replace ( / \. json $ / , '' );
294 const entity = loadEntity (domainsDir, domain, entityId);
295 const label = `${ domain }/entities/${ fileName }` ;
296 if (entity.dependsOn === undefined ) continue ;
297 if ( ! Array. isArray (entity.dependsOn)) {
298 errors. push ( `${ label }: dependsOn must be an array when present` );
299 continue ;
300 }
301 graph. set ( `${ domain }/${ entityId }` , entity.dependsOn);
302 for ( const [ ref , mode ] of Object. entries (entity.dependencyModes || {})) {
303 if (mode !== 'per-record' || ! entity.dependsOn. includes (ref) || ! Array. isArray (entity.dependencyReferenceFields?.[ref]) || ! entity.dependencyReferenceFields[ref]. length ) errors. push ( `dependency_mode_invalid:${ domain }/${ entityId }:${ ref }` );
304 const [ producerDomain , producerEntity ] = ref. split ( '/' );
305 if (fs. existsSync (path. join (domainsDir, producerDomain || '' , 'entities' , `${ producerEntity }.json` )) && loadEntity (domainsDir, producerDomain, producerEntity).target?.classification === 'setup-config' ) errors. push ( `dependency_mode_invalid:${ domain }/${ entityId }:${ ref }` );
306 }
307 // `omit` is automatic for safe media omissions; `offer-omit` and `fix-only` are
308 // per-record final-parent policies and are applied only after the producer pass settles.
309 for ( const [ ref , action ] of Object. entries (entity.dependencyFailureActions || {})) {
310 if ( ! [ 'omit' , 'offer-omit' , 'fix-only' ]. includes (action) || entity.dependencyModes?.[ref] !== 'per-record' ) errors. push ( `dependency_failure_action_invalid:${ domain }/${ entityId }:${ ref }` );
311 }
312 // The record's own writer reports an omitted reference as this cutover item (a site share
313 // image); the generic Import Operator blocker is then not raised for it.
314 for ( const [ ref , code ] of Object. entries (entity.dependencyFailureCutoverItems || {})) {
315 if (entity.dependencyFailureActions?.[ref] !== 'omit' || typeof code !== 'string' || ! / ^ [a-z0-9-] +$ / . test (code)) errors. push ( `dependency_failure_cutover_item_invalid:${ domain }/${ entityId }:${ ref }` );
316 }
317 for ( const [ code , remediation ] of Object. entries (entity.retiredHoldCodes || {})) {
318 if ( ! / ^ [a-z0-9-] +$ / . test (code) || typeof remediation !== 'string' || ! remediation. trim ()) errors. push ( `retired_hold_code_invalid:${ domain }/${ entityId }:${ code }` );
319 }
320 // A per-record edge whose shipped manifests still say task-complete; those stay valid.
321 for ( const ref of entity.legacyTaskCompleteEdges || []) if (entity.dependencyModes?.[ref] !== 'per-record' ) errors. push ( `legacy_task_complete_edge_invalid:${ domain }/${ entityId }:${ ref }` );
322 // A child entity (a product's variants) is produced by its parent's write: the parent must
323 // declare it in an operation contract's childOutputs, and every childOutputs entry must name
324 // an entity that declares this parent.
325 if (entity.childOf !== undefined ) {
326 const [ parentDomain , parentEntity ] = String (entity.childOf). split ( '/' );
327 const parent = parentDomain && parentEntity && fs. existsSync (path. join (domainsDir, parentDomain, 'entities' , `${ parentEntity }.json` )) ? loadEntity (domainsDir, parentDomain, parentEntity) : null ;
328 if ( ! parent || ! Object. values (parent.operationContracts || {}). some ( contract => contract.childOutputs?.[ `${ domain }/${ entityId }` ])) errors. push ( `${ label }: childOf ${ entity . childOf } must resolve to an entity whose operation contract declares childOutputs["${ domain }/${ entityId }"]` );
329 }
330 // An entity whose writer sends its requests as another entity (an order's payment, refund or
331 // invoice, marked ecom/order) is written inside that entity's import task, never its own.
332 if (entity.writtenInTaskOf !== undefined ) {
333 const [ hostDomain , hostEntity ] = String (entity.writtenInTaskOf). split ( '/' );
334 if ( ! hostDomain || ! hostEntity || entity.writtenInTaskOf === `${ domain }/${ entityId }` || ! entity.dependsOn. includes (entity.writtenInTaskOf) || ! fs. existsSync (path. join (domainsDir, hostDomain, 'entities' , `${ hostEntity }.json` ))) errors. push ( `${ label }: writtenInTaskOf must name another entity this one dependsOn` );
335 }
336 for ( const [ operation , contract ] of Object. entries (entity.operationContracts || {})) for ( const childRef of Object. keys (contract.childOutputs || {})) {
337 const [ childDomain , childEntity ] = childRef. split ( '/' );
338 const child = childDomain && childEntity && fs. existsSync (path. join (domainsDir, childDomain, 'entities' , `${ childEntity }.json` )) ? loadEntity (domainsDir, childDomain, childEntity) : null ;
339 if ( ! child || child.childOf !== `${ domain }/${ entityId }` ) errors. push ( `${ label }: operationContracts.${ operation }.childOutputs["${ childRef }"] must name an entity with childOf "${ domain }/${ entityId }"` );
340 }
341 for ( const dep of entity.dependsOn) {
342 const segments = String (dep). split ( '/' );
343 const [ depDomain , depEntity ] = segments;
344 if (segments. length !== 2 || ! depDomain || ! depEntity || ! fs. existsSync (path. join (domainsDir, depDomain, 'entities' , `${ depEntity }.json` ))) {
345 errors. push ( `${ label }: dependsOn entry "${ dep }" does not resolve to an entity file` );
346 }
347 }
348 }
349 }
350
351 const cycle = findCycle (graph);
352 if (cycle) errors. push ( `dependsOn graph has a cycle: ${ cycle . join ( ' -> ' ) }` );
353 }
354
355 function findCycle ( graph ) {
356 const WHITE = 0 ;
357 const GRAY = 1 ;
358 const BLACK = 2 ;
359 const color = new Map ();
360 const stack = [];
361
362 function visit ( node ) {
363 color. set (node, GRAY );
364 stack. push (node);
365 for ( const next of graph. get (node) || []) {
366 const state = color. get (next) || WHITE ;
367 if (state === GRAY ) return [ ... stack, next];
368 if (state === WHITE && graph. has (next)) {
369 const found = visit (next);
370 if (found) return found;
371 }
372 }
373 stack. pop ();
374 color. set (node, BLACK );
375 return null ;
376 }
377
378 for ( const node of graph. keys ()) {
379 if ((color. get (node) || WHITE ) === WHITE ) {
380 const found = visit (node);
381 if (found) return found;
382 }
383 }
384 return null ;
385 }
386
387 // Every entity ref that actually exists on disk, regardless of whether it has dependsOn authored.
388 // checkScope needs this to tell "unknown ref" (not in this set) apart from "known but unreviewed"
389 // (in this set, absent from the dependsOn graph).
390 function listAllEntityRefs ( domainsDir ) {
391 const refs = [];
392 for ( const domain of listDomainDirs (domainsDir)) {
393 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
394 refs. push ( `${ domain }/${ fileName . replace ( / \. json $ / , '' ) }` );
395 }
396 }
397 return refs;
398 }
399
400 // The rp-mapper review-gate check, as a pure function (the caller does not compute the transitive
401 // closure, and unknown refs and never-reviewed entities are never passed as though they were
402 // "reviewed, no dependencies").
403 // Given every real entity ref, the dependsOn graph, and the plan's own selected scope (only —
404 // the caller does not pre-expand anything), this walks dependsOn edges outward itself and reports
405 // three distinct, non-overlapping failure categories. `checkScope` below is the disk-reading
406 // wrapper `rp-mapper`/an agent/the CLI actually calls.
407 function computeScopeCheck ( allRefs , graph , selectedRefs ) {
408 const selectedSet = new Set ((selectedRefs || []). map (( ref ) => String (ref)));
409 const unknownRefs = new Set (Array. from (selectedSet). filter (( ref ) => ! allRefs. has (ref)));
410 const validSelected = Array. from (selectedSet). filter (( ref ) => allRefs. has (ref));
411
412 const visited = new Set ();
413 const queue = [ ... validSelected];
414 const unreviewedRefs = [];
415 const missingByRef = new Map ();
416
417 while (queue. length ) {
418 const ref = queue. shift ();
419 if (visited. has (ref)) continue ;
420 visited. add (ref);
421
422 if ( ! allRefs. has (ref)) {
423 // Reached only via a dependsOn edge (never itself in selectedRefs, or `selectedSet` would
424 // already have caught it above) — a dangling/typo'd ref, not merely unreviewed.
425 unknownRefs. add (ref);
426 continue ;
427 }
428
429 if ( ! graph. has (ref)) {
430 // Reachable, resolves to a real entity, but dependsOn has never been authored at all —
431 // fail-closed: "not yet reviewed" is not the same fact as "reviewed, no dependencies."
432 unreviewedRefs. push (ref);
433 continue ;
434 }
435
436 const deps = graph. get (ref);
437 const missing = deps. filter (( dep ) => ! selectedSet. has (dep));
438 if (missing. length ) missingByRef. set (ref, missing);
439 for ( const dep of deps) {
440 if ( ! visited. has (dep)) queue. push (dep);
441 }
442 }
443
444 const missingDependencies = Array. from (missingByRef. entries ())
445 . map (([ ref , missing ]) => ({ ref, missing }))
446 . sort (( a , b ) => a.ref. localeCompare (b.ref));
447
448 return {
449 ok: unknownRefs.size === 0 && missingDependencies. length === 0 && unreviewedRefs. length === 0 ,
450 unknownRefs: Array. from (unknownRefs). sort (),
451 missingDependencies,
452 unreviewedRefs: unreviewedRefs. sort (),
453 };
454 }
455
456 // Disk-reading wrapper — this is what `domain-knowledge-validate.js --check-scope` and rp-mapper's
457 // review gate should call, passing only the plan's own selected scope. See computeScopeCheck for
458 // the pure algorithm and why each failure category exists.
459 function checkScope ( domainsDir , selectedRefs ) {
460 return computeScopeCheck ( new Set ( listAllEntityRefs (domainsDir)), buildDependsOnGraph (domainsDir), selectedRefs);
461 }
462
463 // The live backlog: every entity that has never had dependsOn authored at all (field
464 // absent, not merely empty). Non-blocking by design — this is a report, not a validation error.
465 // `node domain-knowledge-validate.js --list-missing-deps` is the intended way to read it, so the
466 // backlog never drifts out of sync with a hand-maintained list the way a markdown table would.
467 function listMissingDependsOn ( domainsDir ) {
468 const missing = [];
469 for ( const domain of listDomainDirs (domainsDir)) {
470 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
471 const entityId = fileName. replace ( / \. json $ / , '' );
472 const entity = loadEntity (domainsDir, domain, entityId);
473 if (entity.dependsOn === undefined ) missing. push ( `${ domain }/${ entityId }` );
474 }
475 }
476 return missing. sort ();
477 }
478
479 // Every domain with entities carries a docs survey in which every
480 // docs-menu surface is triaged, and its native entities cite their object
481 // page. The backfill completed 2026-08-11, so a missing survey is an error.
482 function validateDocsSurvey ( domainsDir , domain , domainJson , errors ) {
483 const surveyPath = path. join (domainsDir, domain, 'docs-survey.json' );
484 const hasEntities = listEntityFiles (path. join (domainsDir, domain)). length > 0 ;
485 if ( ! fs. existsSync (surveyPath)) {
486 if (hasEntities) errors. push ( `${ domain }: missing docs-survey.json; run docs-survey-sync.js ${ domain } and triage` );
487 return ;
488 }
489
490 const label = `${ domain }/docs-survey.json` ;
491 const survey = readJson (surveyPath);
492 requireFields (survey, [ 'schemaVersion' , 'domain' , 'fetchedAt' , 'roots' , 'surfaces' ], label, errors);
493 if (survey.schemaVersion !== 1 ) errors. push ( `${ label }: schemaVersion must be 1` );
494 if (survey.domain !== domain) errors. push ( `${ label }: domain must match directory` );
495
496 const declaredRoots = JSON . stringify ([ ... (domainJson.docsRoots || [])]. sort ());
497 const surveyRoots = JSON . stringify ([ ... (survey.roots || [])]. sort ());
498 if (declaredRoots !== surveyRoots) {
499 errors. push ( `${ label }: roots must match ${ domain }/domain.json docsRoots; re-run docs-survey-sync.js ${ domain }` );
500 }
501
502 for ( const surface of survey.surfaces || []) {
503 const surfaceLabel = `${ label }: ${ surface . id || '<missing id>'}` ;
504 requireFields (surface, [ 'id' , 'title' , 'url' , 'verdict' ], surfaceLabel, errors);
505 if (surface.removed) {
506 errors. push ( `${ surfaceLabel }: surface no longer in the docs menu; delete the entry or revisit the facts that cited it` );
507 continue ;
508 }
509 if ( ! SURVEY_VERDICTS . has (surface.verdict)) {
510 errors. push ( `${ surfaceLabel }: invalid verdict ${ surface . verdict }` );
511 continue ;
512 }
513 if (surface.verdict === 'unreviewed' ) {
514 errors. push ( `${ surfaceLabel }: unreviewed surface; triage it (claimed / not-importable / not-relevant / gap)` );
515 continue ;
516 }
517 if ( ! surface.reviewedOn) errors. push ( `${ surfaceLabel }: triaged surface must carry reviewedOn` );
518 if (surface.verdict === 'claimed' ) {
519 if ( ! Array. isArray (surface.refs) || surface.refs. length === 0 ) {
520 errors. push ( `${ surfaceLabel }: claimed surface must list refs[]` );
521 } else {
522 for ( const ref of surface.refs) {
523 const [ refDomain , refEntity ] = String (ref). split ( '/' );
524 if ( ! refDomain || ! refEntity || ! fs. existsSync (path. join (domainsDir, refDomain, 'entities' , `${ refEntity }.json` ))) {
525 errors. push ( `${ surfaceLabel }: claimed ref ${ ref } does not resolve to an entity file` );
526 }
527 }
528 }
529 }
530 if ((surface.verdict === 'not-importable' || surface.verdict === 'not-relevant' ) && ! surface.reason) {
531 errors. push ( `${ surfaceLabel }: ${ surface . verdict } requires a reason` );
532 }
533 if (surface.verdict === 'gap' && ( ! surface.reason || ! surface.tracking)) {
534 errors. push ( `${ surfaceLabel }: gap requires reason and tracking` );
535 }
536 }
537
538 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
539 const entityId = fileName. replace ( / \. json $ / , '' );
540 const entity = loadEntity (domainsDir, domain, entityId);
541 if ( ! NATIVE_CLASSIFICATIONS . has (entity.target && entity.target.classification)) continue ;
542 const citesObjectPage = (entity.evidence || []). some (
543 ( item ) => typeof item.url === 'string' && /-object $ / . test (item.url. replace ( / \. md $ / , '' ))
544 );
545 // A surface with no …-object reference page (e.g. Comments, which has no
546 // REST reference at all) declares objectPageException with the reason —
547 // explicit and greppable, never silent.
548 const hasException = typeof entity.objectPageException === 'string' && entity.objectPageException. length > 0 ;
549 if ( ! citesObjectPage && ! hasException) {
550 errors. push ( `${ domain }/entities/${ fileName }: native target must cite its docs object page (…-object URL) in evidence[], or declare objectPageException with a reason` );
551 }
552 }
553 }
554
555 function requireFields ( value , fields , label , errors ) {
556 for ( const field of fields) {
557 if (value[field] === undefined ) errors. push ( `${ label }: missing required field ${ field }` );
558 }
559 }
560
561 function validateEntity ( entity , domain , entityId , label , errors ) {
562 requireFields (entity, [ 'schemaVersion' , 'domain' , 'entity' , 'displayName' , 'target' , 'sourceAliases' , 'preferredWrite' , 'reliability' , 'pitfalls' , 'mappingGuidance' , 'evidence' ], label, errors);
563 if (entity.schemaVersion !== 1 ) errors. push ( `${ label }: schemaVersion must be 1` );
564 if (entity.domain !== domain) errors. push ( `${ label }: domain must match file path` );
565 if (entity.entity !== entityId) errors. push ( `${ label }: entity must match file path` );
566 if ( ! CLASSIFICATIONS . has (entity.target && entity.target.classification)) errors. push ( `${ label }: invalid target.classification` );
567 if ( ! ID_POLICIES . has (entity.target && entity.target.idPolicy)) errors. push ( `${ label }: invalid target.idPolicy` );
568 if ( typeof (entity.target && entity.target.crosswalkRequired) !== 'boolean' ) errors. push ( `${ label }: target.crosswalkRequired must be boolean` );
569 if ( ! VERIFICATIONS . has (entity.preferredWrite && entity.preferredWrite.verification)) errors. push ( `${ label }: invalid preferredWrite.verification` );
570 if ( ! RELIABILITY . has (entity.reliability && entity.reliability.status)) errors. push ( `${ label }: invalid reliability.status` );
571 if ( ! Array. isArray (entity.reliability && entity.reliability.flags)) errors. push ( `${ label }: reliability.flags must be an array` );
572 validateImportSafe (entity, label, errors);
573 if (entity.preferredWrite && entity.preferredWrite.writerId !== null && ! WRITER_IDS . has (entity.preferredWrite.writerId)) {
574 errors. push ( `${ label }: writerId ${ entity . preferredWrite . writerId } is not exported by wix-writers.js` );
575 }
576 // An alternate writer is a writer the gate may accept, so it is checked like the default one.
577 for ( const [ index , alternate ] of (entity.alternateWrites || []). entries ()) {
578 if ( ! alternate || typeof alternate.writerId !== 'string' ) continue ;
579 const at = `${ label }: alternateWrites[${ index }]` ;
580 if ( ! WRITER_IDS . has (alternate.writerId)) errors. push ( `${ at }: writerId ${ alternate . writerId } is not exported by wix-writers.js` );
581 if ( typeof alternate.importSafe !== 'boolean' ) errors. push ( `${ at }: importSafe must be boolean when writerId is set` );
582 if ( ! VERIFICATIONS . has (alternate.verification)) errors. push ( `${ at }: invalid verification` );
583 if (entity.preferredWrite && alternate.writerId === entity.preferredWrite.writerId) errors. push ( `${ at }: writerId repeats preferredWrite.writerId` );
584 }
585 if (entity.target && entity.target.classification !== 'cms' && entity.target.idPolicy === 'server-assigned' && entity.target.crosswalkRequired !== true ) {
586 errors. push ( `${ label }: server-assigned non-CMS targets must require crosswalk` );
587 }
588 for ( const alias of entity.sourceAliases || []) {
589 if ( ! alias.sourceSystem || ! alias.sourceEntity) errors. push ( `${ label }: sourceAliases entries must include sourceSystem and sourceEntity` );
590 }
591 if (entity.target && entity.target.classification === 'manual-mapping' ) {
592 validateManualMapping (entity, label, errors);
593 } else if (entity.manualSteps !== undefined ) {
594 errors. push ( `${ label }: manualSteps is only allowed when target.classification is manual-mapping` );
595 }
596 errors. push ( ... require ( '../../../lib/mapping-target-rules.js' ). validateMappingRules (entity.mappingRules, `${ domain }/${ entityId }` ). map ( e => `${ label }: ${ e }` ));
597 validateContactFields (entity.contactFields, label, errors);
598 if (entity.safeModeContactFields !== undefined ) errors. push ( `${ label }: safeModeContactFields was renamed contactFields` );
599 for ( const [ name , contract ] of Object. entries (entity.operationContracts || {})) {
600 if (contract && contract.unsupportedWhen !== undefined ) errors. push ( `${ label }: operationContracts.${ name }.unsupportedWhen was removed with safe mode` );
601 }
602 validateBlockedSourceDependencies (entity, label, errors);
603 validateEvidence (entity.evidence || [], label, errors);
604 }
605
606 function validateBlockedSourceDependencies ( entity , label , errors ) {
607 if (entity.blockedSourceDependencies === undefined ) return ;
608 if ( ! Array. isArray (entity.blockedSourceDependencies)) {
609 errors. push ( `${ label }: blockedSourceDependencies must be an array when present` );
610 return ;
611 }
612 const pitfallCodes = new Set ((entity.pitfalls || []). map (( pitfall ) => pitfall.code));
613 const seen = new Set ();
614 entity.blockedSourceDependencies. forEach (( dependency , index ) => {
615 const dependencyLabel = `${ label }: blockedSourceDependencies[${ index }]` ;
616 if ( ! dependency || typeof dependency !== 'object' || Array. isArray (dependency)) {
617 errors. push ( `${ dependencyLabel } must be an object` );
618 return ;
619 }
620 for ( const field of [ 'sourceEntityRef' , 'degradedField' , 'pitfallCode' , 'degradation' ]) {
621 if ( ! dependency[field] || typeof dependency[field] !== 'string' ) errors. push ( `${ dependencyLabel }.${ field } must be a non-empty string` );
622 }
623 if (dependency.sourceEntityRef && ! / ^ plugin \. [ ^ .] + \. [ ^ .] +$ / . test (dependency.sourceEntityRef)) {
624 errors. push ( `${ dependencyLabel }.sourceEntityRef must use plugin.<slug>.<entity>` );
625 }
626 if (dependency.pitfallCode && ! pitfallCodes. has (dependency.pitfallCode)) {
627 errors. push ( `${ dependencyLabel }.pitfallCode ${ dependency . pitfallCode } does not resolve in this entity's pitfalls[]` );
628 }
629 if (dependency.degradation && ! BLOCKED_DEPENDENCY_DEGRADATIONS . has (dependency.degradation)) {
630 errors. push ( `${ dependencyLabel }.degradation must be warning or deferred` );
631 }
632 const key = `${ dependency . sourceEntityRef }:${ dependency . degradedField }` ;
633 if (seen. has (key)) errors. push ( `${ dependencyLabel } duplicates ${ key }` );
634 seen. add (key);
635 });
636 }
637
638 // importSafe: true unless the writer has a non-notification side effect site mute cannot cover
639 // (e.g. moving stock or money). A false value must say why, so the import gate can tell the agent.
640 function validateImportSafe ( entity , label , errors ) {
641 const pw = entity.preferredWrite || {};
642 if (entity.target && entity.target.classification === 'manual-mapping' ) return ;
643 if ( typeof pw.importSafe !== 'boolean' ) errors. push ( `${ label }: preferredWrite.importSafe must be boolean` );
644 if (pw.importSafe !== false ) return ;
645 const reason = pw.importSafeReason || {};
646 if ( typeof reason.summary !== 'string' || ! reason.summary. trim ()) errors. push ( `${ label }: importSafe false requires preferredWrite.importSafeReason.summary` );
647 if ( ! (entity.pitfalls || []). some ( p => p && p.code === reason.pitfall)) errors. push ( `${ label }: preferredWrite.importSafeReason.pitfall must name one of this entity's pitfall codes` );
648 }
649
650 // For manual-mapping: a manual-mapping entity performs no write, so its preferredWrite must
651 // be structurally inert (nothing for import codegen to accidentally call), and its manualSteps
652 // must actually contain a runbook — an empty or missing one would render nothing to the
653 // merchant, which is worse than not classifying it manual-mapping at all.
654 function validateManualMapping ( entity , label , errors ) {
655 const pw = entity.preferredWrite || {};
656 if (pw.endpoint !== null ) errors. push ( `${ label }: manual-mapping requires preferredWrite.endpoint === null` );
657 if (pw.writerId !== null ) errors. push ( `${ label }: manual-mapping requires preferredWrite.writerId === null` );
658 if (pw.verification !== 'none' ) errors. push ( `${ label }: manual-mapping requires preferredWrite.verification === 'none'` );
659 if (pw.importSafe !== null ) errors. push ( `${ label }: manual-mapping requires preferredWrite.importSafe === null (not applicable: nothing is written)` );
660 if (pw.bulk !== false ) errors. push ( `${ label }: manual-mapping requires preferredWrite.bulk === false` );
661
662 const manualSteps = entity.manualSteps;
663 if ( ! manualSteps || typeof manualSteps !== 'object' ) {
664 errors. push ( `${ label }: manual-mapping requires a manualSteps object` );
665 return ;
666 }
667 if ( ! Array. isArray (manualSteps.steps) || manualSteps.steps. length === 0 ) {
668 errors. push ( `${ label }: manualSteps.steps must be a non-empty array` );
669 return ;
670 }
671 manualSteps.steps. forEach (( step , index ) => {
672 if ( ! step || ! MANUAL_STEP_ACTORS . has (step.actor)) {
673 errors. push ( `${ label }: manualSteps.steps[${ index }].actor must be one of ${ Array . from ( MANUAL_STEP_ACTORS ). join ( ', ' ) }` );
674 }
675 if ( ! step || typeof step.text !== 'string' || step.text. trim () === '' ) {
676 errors. push ( `${ label }: manualSteps.steps[${ index }].text must be a non-empty string` );
677 }
678 });
679 }
680
681 function validateContactFields ( fields , label , errors ) {
682 if (fields === undefined ) return ;
683 if ( ! Array. isArray (fields)) {
684 errors. push ( `${ label }: contactFields must be an array when present` );
685 return ;
686 }
687 for ( const [ index , field ] of fields. entries ()) {
688 const fieldLabel = `${ label }: contactFields[${ index }]` ;
689 if ( ! field || typeof field !== 'object' || Array. isArray (field)) {
690 errors. push ( `${ fieldLabel } must be an object` );
691 continue ;
692 }
693 if ( ! CONTACT_FIELD_KINDS . has (field.kind)) errors. push ( `${ fieldLabel }.kind must be email or phone` );
694 if ( ! field.targetPath || typeof field.targetPath !== 'string' ) errors. push ( `${ fieldLabel }.targetPath must be a non-empty string` );
695 if ( ! field.source || typeof field.source !== 'string' ) errors. push ( `${ fieldLabel }.source must be a non-empty string` );
696 }
697 }
698
699 function validateEvidence ( items , label , errors ) {
700 if ( ! Array. isArray (items) || items. length === 0 ) {
701 errors. push ( `${ label }: evidence must be a non-empty array` );
702 return ;
703 }
704 for ( const item of items) {
705 if ( ! item.url && ! item.path) errors. push ( `${ label }: every evidence item must include url or path` );
706 }
707 }
708
709 function validateIndexConsistency ( index , domainsDir , errors ) {
710 const flagged = new Set (index.flags && index.flags. IMPORT_UNRELIABLE ? index.flags. IMPORT_UNRELIABLE : []);
711 const entityFlagged = new Set ();
712
713 for ( const [ domain , domainEntry ] of Object. entries (index.domains || {})) {
714 if ( ! fs. existsSync (path. join (domainsDir, domain, 'domain.json' ))) errors. push ( `index: missing domain file for ${ domain }` );
715 for ( const [ entityId , entityEntry ] of Object. entries (domainEntry.entities || {})) {
716 const ref = `${ domain }/${ entityId }` ;
717 const entityPath = path. join (domainsDir, domain, 'entities' , `${ entityId }.json` );
718 if ( ! fs. existsSync (entityPath)) {
719 errors. push ( `index: missing entity file for ${ ref }` );
720 continue ;
721 }
722 const entity = readJson (entityPath);
723 if ((entity.reliability.flags || []). includes ( 'IMPORT_UNRELIABLE' )) entityFlagged. add (ref);
724 if (entityEntry.path !== `domains/${ domain }/entities/${ entityId }.json` ) {
725 errors. push ( `index: invalid path for ${ ref }` );
726 }
727 }
728 }
729
730 for ( const ref of entityFlagged) {
731 if ( ! flagged. has (ref)) errors. push ( `index: ${ ref } has IMPORT_UNRELIABLE but is missing from flags` );
732 }
733 for ( const ref of flagged) {
734 if ( ! entityFlagged. has (ref)) errors. push ( `index: ${ ref } is flagged IMPORT_UNRELIABLE but entity file is not` );
735 }
736 }
737
738 function loadIndex ( domainsDir ) {
739 return readJson (path. join (domainsDir, 'index.json' ));
740 }
741
742 function listDomains ( domainsDir ) {
743 const index = loadIndex (domainsDir);
744 return Object. entries (index.domains || {}). map (([ domain , info ]) => ({
745 domain,
746 displayName: info.displayName,
747 ownerHint: info.ownerHint,
748 path: info.path,
749 }));
750 }
751
752 function listEntities ( domainsDir , domain ) {
753 const index = loadIndex (domainsDir);
754 const domainInfo = index.domains && index.domains[domain];
755 if ( ! domainInfo) throw new Error ( `Unknown domain: ${ domain }` );
756 return Object. entries (domainInfo.entities || {}). map (([ entity , info ]) => ({
757 ref: `${ domain }/${ entity }` ,
758 entity,
759 displayName: info.displayName,
760 classification: info.classification,
761 reliabilityStatus: info.reliabilityStatus,
762 reliabilityFlags: info.reliabilityFlags,
763 path: info.path,
764 }));
765 }
766
767 function readEntityByRef ( domainsDir , ref ) {
768 const [ domain , entity ] = ref. split ( '/' );
769 if ( ! domain || ! entity) throw new Error ( `Invalid ref: ${ ref }` );
770 return loadEntity (domainsDir, domain, entity);
771 }
772
773 function resolveSource ( domainsDir , { sourceSystem , sourceEntity , route }) {
774 const index = loadIndex (domainsDir);
775 const refs = new Set ();
776 if (sourceSystem && sourceEntity) {
777 for ( const ref of index.sourceAliasIndex[ `${ sourceSystem }:${ sourceEntity }` ] || []) refs. add (ref);
778 }
779 if (route) {
780 for ( const [ pattern , patternRefs ] of Object. entries (index.routeAliasIndex || {})) {
781 if (route === pattern || routeMatches (pattern, route)) {
782 for ( const ref of patternRefs) refs. add (ref);
783 }
784 }
785 }
786 return Array. from (refs). sort (). map (( ref ) => {
787 const entity = readEntityByRef (domainsDir, ref);
788 return {
789 ref,
790 confidence: bestAliasConfidence (entity, { sourceSystem, sourceEntity, route }),
791 sourceAliases: (entity.sourceAliases || []). filter (( alias ) => aliasMatches (alias, { sourceSystem, sourceEntity, route })),
792 };
793 });
794 }
795
796 function routeMatches ( pattern , route ) {
797 if ( ! pattern. includes ( '{' ) && ! pattern. includes ( ':' )) return false ;
798 const escaped = pattern. replace ( / [.*+?^${}()|[ \]\\ ] / g , ' \\ $&' ). replace ( / \\\{ [ ^ }] + \\\} / g , '[^/]+' ). replace ( /: [A-Za-z0-9_-] + / g , '[^/]+' );
799 return new RegExp ( `^${ escaped }$` ). test (route);
800 }
801
802 function aliasMatches ( alias , query ) {
803 const sourceMatches = ( ! query.sourceSystem || alias.sourceSystem === query.sourceSystem) && ( ! query.sourceEntity || alias.sourceEntity === query.sourceEntity);
804 const routeMatchesAlias = ! query.route || (alias.routes || []). some (( pattern ) => query.route === pattern || routeMatches (pattern, query.route));
805 return sourceMatches && routeMatchesAlias;
806 }
807
808 function bestAliasConfidence ( entity , query ) {
809 const order = { high: 3 , medium: 2 , low: 1 };
810 let best = 'low' ;
811 for ( const alias of entity.sourceAliases || []) {
812 if ( aliasMatches (alias, query) && (order[alias.confidence] || 0 ) > (order[best] || 0 )) best = alias.confidence;
813 }
814 return best;
815 }
816
817 function listFlagged ( domainsDir , flag ) {
818 const index = loadIndex (domainsDir);
819 return (index.flags && index.flags[flag] ? index.flags[flag] : []). map (( ref ) => {
820 const entity = readEntityByRef (domainsDir, ref);
821 return {
822 ref,
823 displayName: entity.displayName,
824 classification: entity.target.classification,
825 reliabilityStatus: entity.reliability.status,
826 summary: firstPitfallOrGuidance (entity),
827 };
828 });
829 }
830
831 // Lazily required: domain-knowledge is loaded by tooling that has no need for the schema library,
832 // and a hard dependency at module load is how one domain file breaks everything (learned the hard
833 // way when a generic library loaded an eCommerce entity at init).
834 function stampSetupRequirement ( requirement ) {
835 if ( ! requirement || requirement.kind !== 'extendedFieldSchema' ) return requirement;
836 // eslint-disable-next-line global-require
837 return require ( './data-extension-schema.js' ). stampRequirement (requirement);
838 }
839
840 function summarizeEntities ( domainsDir , refs , { includeEvidence = false } = {}) {
841 return refs. map (( ref ) => {
842 const entity = readEntityByRef (domainsDir, ref);
843 const summary = {
844 ref,
845 displayName: entity.displayName,
846 target: entity.target,
847 preferredWrite: entity.preferredWrite,
848 reliability: entity.reliability,
849 pitfalls: entity.pitfalls,
850 mappingGuidance: entity.mappingGuidance,
851 // Stamped HERE, at the producer the shipped path actually uses. The stamp used to live only
852 // on a domain adapter that setup discovery never calls, so the real chain emitted unstamped
853 // requirements and every invoice write was refused by a guard that was working correctly.
854 setupRequirements: (entity.setupRequirements || []). map (stampSetupRequirement),
855 fieldContracts: entity.fieldContracts || [],
856 mappingRules: entity.mappingRules || [],
857 blockedSourceDependencies: entity.blockedSourceDependencies || [],
858 extendedFields: entity.extendedFields || null ,
859 };
860 if (includeEvidence) summary.evidence = entity.evidence;
861 return summary;
862 });
863 }
864
865 // Compact cross-adapter view consumed by the WordPress plugin knowledge base so plugin
866 // profiles can be validated against real Wix target refs and capability claims without
867 // each adapter re-reading the other's tree.
868 function knowledgeSummary ( domainsDir ) {
869 const index = loadIndex (domainsDir);
870 const knownRefs = new Set ();
871 const verificationByRef = new Map ();
872 // manual-mapping entities only — lets classifyCoverage tell a manual-mapping
873 // target apart from a real write target using only this compact summary, with no need to
874 // re-read the full rp-target-wix entity tree from the WordPress side.
875 const manualStepsByRef = new Map ();
876 const blockedSourceDependenciesByRef = new Map ();
877 for ( const [ domain , domainEntry ] of Object. entries (index.domains || {})) {
878 for ( const entityId of Object. keys (domainEntry.entities || {})) {
879 const ref = `${ domain }/${ entityId }` ;
880 knownRefs. add (ref);
881 const entity = loadEntity (domainsDir, domain, entityId);
882 verificationByRef. set (ref, entity.preferredWrite && entity.preferredWrite.verification);
883 if (entity.target && entity.target.classification === 'manual-mapping' && entity.manualSteps) {
884 manualStepsByRef. set (ref, entity.manualSteps);
885 }
886 if (Array. isArray (entity.blockedSourceDependencies) && entity.blockedSourceDependencies. length > 0 ) {
887 blockedSourceDependenciesByRef. set (ref, entity.blockedSourceDependencies);
888 }
889 }
890 }
891 const capabilityRefs = new Map (Object. entries (index.capabilityIndex || {}));
892 return { knownRefs, capabilityRefs, verificationByRef, manualStepsByRef, blockedSourceDependenciesByRef };
893 }
894
895 module . exports = {
896 knowledgeRoot,
897 retiredHoldCodes,
898 generateIndex,
899 knowledgeSummary,
900 validateKnowledge,
901 validateDependsOnPromotion,
902 validateDependsOn,
903 validateEntity,
904 validateBlockedSourceDependencies,
905 buildDependsOnGraph,
906 findCycle,
907 listAllEntityRefs,
908 computeScopeCheck,
909 checkScope,
910 listMissingDependsOn,
911 writeJson,
912 listDomains,
913 listEntities,
914 readEntityByRef,
915 resolveSource,
916 listFlagged,
917 summarizeEntities,
918 };