Setting the file. One moment.
Domain Knowledge · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 44.9
Post
function buildDependsOnGraph
— line 194
This file
Number 44.103
Position 103 of 115
Type JavaScript
Size 33 KB
Lines 763 lib/ domain-knowledge.js
JavaScript · 763 lines · 33 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 SAFE_MODE_CONTACT_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 function generateIndex ( domainsDir ) {
72 const index = {
73 schemaVersion: 1 ,
74 domains: {},
75 sourceAliasIndex: {},
76 routeAliasIndex: {},
77 capabilityIndex: {},
78 flags: {},
79 };
80
81 for ( const domain of listDomainDirs (domainsDir)) {
82 const domainPath = path. join (domain, 'domain.json' );
83 const domainJson = loadDomain (domainsDir, domain);
84 const entities = {};
85 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
86 const entityId = fileName. replace ( / \. json $ / , '' );
87 const entityPath = path. join (domain, 'entities' , fileName);
88 const entity = readJson (path. join (domainsDir, entityPath));
89 entities[entityId] = {
90 path: `domains/${ entityPath }` ,
91 displayName: entity.displayName,
92 classification: entity.target && entity.target.classification,
93 reliabilityStatus: entity.reliability && entity.reliability.status,
94 reliabilityFlags: (entity.reliability && entity.reliability.flags) || [],
95 summary: firstPitfallOrGuidance (entity),
96 };
97 if (entity.capability) {
98 if ( ! index.capabilityIndex[entity.capability]) index.capabilityIndex[entity.capability] = [];
99 index.capabilityIndex[entity.capability]. push ( `${ domain }/${ entityId }` );
100 }
101 for ( const alias of entity.sourceAliases || []) {
102 const key = `${ alias . sourceSystem }:${ alias . sourceEntity }` ;
103 if ( ! index.sourceAliasIndex[key]) index.sourceAliasIndex[key] = [];
104 index.sourceAliasIndex[key]. push ( `${ domain }/${ entityId }` );
105 for ( const route of alias.routes || []) {
106 if ( ! index.routeAliasIndex[route]) index.routeAliasIndex[route] = [];
107 index.routeAliasIndex[route]. push ( `${ domain }/${ entityId }` );
108 }
109 }
110 for ( const flag of (entity.reliability && entity.reliability.flags) || []) {
111 if ( ! index.flags[flag]) index.flags[flag] = [];
112 index.flags[flag]. push ( `${ domain }/${ entityId }` );
113 }
114 }
115 index.domains[domain] = {
116 path: `domains/${ domainPath }` ,
117 displayName: domainJson.displayName,
118 ownerHint: domainJson.ownerHint,
119 entities,
120 };
121 }
122
123 for ( const collection of [index.sourceAliasIndex, index.routeAliasIndex, index.capabilityIndex, index.flags]) {
124 for ( const key of Object. keys (collection)) collection[key] = Array. from ( new Set (collection[key])). sort ();
125 }
126
127 return index;
128 }
129
130 function firstPitfallOrGuidance ( entity ) {
131 if (Array. isArray (entity.pitfalls) && entity.pitfalls[ 0 ] && entity.pitfalls[ 0 ].summary) {
132 return entity.pitfalls[ 0 ].summary;
133 }
134 if (Array. isArray (entity.mappingGuidance) && entity.mappingGuidance[ 0 ]) return entity.mappingGuidance[ 0 ];
135 return '' ;
136 }
137
138 function validateKnowledge ( domainsDir ) {
139 const errors = [];
140 const indexPath = path. join (domainsDir, 'index.json' );
141 const index = fs. existsSync (indexPath) ? readJson (indexPath) : null ;
142 const generated = generateIndex (domainsDir);
143
144 if ( ! fs. existsSync (path. join (domainsDir, 'schema.json' ))) {
145 errors. push ( 'domains/schema.json is missing' );
146 }
147
148 for ( const domain of listDomainDirs (domainsDir)) {
149 const domainFile = path. join (domainsDir, domain, 'domain.json' );
150 if ( ! fs. existsSync (domainFile)) {
151 errors. push ( `${ domain }: missing domain.json` );
152 continue ;
153 }
154 const domainJson = readJson (domainFile);
155 requireFields (domainJson, [ 'schemaVersion' , 'domain' , 'displayName' , 'ownerHint' , 'defaultImportOrder' , 'evidence' ], `${ domain }/domain.json` , errors);
156 if (domainJson.schemaVersion !== 1 ) errors. push ( `${ domain }/domain.json: schemaVersion must be 1` );
157 if (domainJson.domain !== domain) errors. push ( `${ domain }/domain.json: domain must match directory` );
158 for ( const entityId of domainJson.defaultImportOrder || []) {
159 if ( ! fs. existsSync (path. join (domainsDir, domain, 'entities' , `${ entityId }.json` ))) {
160 errors. push ( `${ domain }/domain.json: defaultImportOrder references missing entity ${ entityId }` );
161 }
162 }
163 validateEvidence (domainJson.evidence || [], `${ domain }/domain.json` , errors);
164
165 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
166 const entityId = fileName. replace ( / \. json $ / , '' );
167 const entity = loadEntity (domainsDir, domain, entityId);
168 const label = `${ domain }/entities/${ fileName }` ;
169 validateEntity (entity, domain, entityId, label, errors);
170 }
171
172 validateDocsSurvey (domainsDir, domain, domainJson, errors);
173 }
174
175 if ( ! index) {
176 errors. push ( 'domains/index.json is missing; run domain-knowledge-validate.js --write-index' );
177 } else {
178 const current = JSON . stringify (index);
179 const expected = JSON . stringify (generated);
180 if (current !== expected) {
181 errors. push ( 'domains/index.json is stale; run domain-knowledge-validate.js --write-index' );
182 }
183 validateIndexConsistency (index, domainsDir, errors);
184 }
185
186 validateDependsOn (domainsDir, errors);
187
188 return { ok: errors. length === 0 , errors, generatedIndex: generated };
189 }
190
191 // Spec 0041: the dependsOn graph across every entity that has it. Pure disk read, no validation —
192 // `validateDependsOn` (ref resolution, cycles) and `checkScope` (rp-mapper's review-gate check)
193 // both build on this so the graph is assembled exactly once, the same way, everywhere it's needed.
194 function buildDependsOnGraph ( domainsDir ) {
195 const graph = new Map (); // ref -> dependsOn refs
196 for ( const domain of listDomainDirs (domainsDir)) {
197 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
198 const entityId = fileName. replace ( / \. json $ / , '' );
199 const entity = loadEntity (domainsDir, domain, entityId);
200 if (entity.dependsOn === undefined ) continue ;
201 const ref = `${ domain }/${ entityId }` ;
202 graph. set (ref, Array. isArray (entity.dependsOn) ? entity.dependsOn : []);
203 }
204 }
205 return graph;
206 }
207
208 // Spec 0041: dependsOn is optional (58 of 62 entities have never had it authored — see that
209 // spec's rollout), but when present it must resolve and the graph must stay acyclic. A cycle
210 // is a hard error (no import order could ever satisfy it); a missing field is not an error at
211 // all — see listMissingDependsOn for the non-blocking backlog view instead.
212 // Builds its own local graph rather than calling buildDependsOnGraph afterward — this function
213 // already reads and parses every entity file once for ref-resolution/array-type checks, so
214 // reusing that same read for cycle detection (instead of a second full read+parse pass) halves
215 // the I/O of every `domain-knowledge-validate.js` run.
216 function validateDependsOn ( domainsDir , errors ) {
217 const graph = new Map ();
218 for ( const domain of listDomainDirs (domainsDir)) {
219 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
220 const entityId = fileName. replace ( / \. json $ / , '' );
221 const entity = loadEntity (domainsDir, domain, entityId);
222 const label = `${ domain }/entities/${ fileName }` ;
223 if (entity.dependsOn === undefined ) continue ;
224 if ( ! Array. isArray (entity.dependsOn)) {
225 errors. push ( `${ label }: dependsOn must be an array when present` );
226 continue ;
227 }
228 graph. set ( `${ domain }/${ entityId }` , entity.dependsOn);
229 for ( const dep of entity.dependsOn) {
230 const segments = String (dep). split ( '/' );
231 const [ depDomain , depEntity ] = segments;
232 if (segments. length !== 2 || ! depDomain || ! depEntity || ! fs. existsSync (path. join (domainsDir, depDomain, 'entities' , `${ depEntity }.json` ))) {
233 errors. push ( `${ label }: dependsOn entry "${ dep }" does not resolve to an entity file` );
234 }
235 }
236 }
237 }
238
239 const cycle = findCycle (graph);
240 if (cycle) errors. push ( `dependsOn graph has a cycle: ${ cycle . join ( ' -> ' ) }` );
241 }
242
243 function findCycle ( graph ) {
244 const WHITE = 0 ;
245 const GRAY = 1 ;
246 const BLACK = 2 ;
247 const color = new Map ();
248 const stack = [];
249
250 function visit ( node ) {
251 color. set (node, GRAY );
252 stack. push (node);
253 for ( const next of graph. get (node) || []) {
254 const state = color. get (next) || WHITE ;
255 if (state === GRAY ) return [ ... stack, next];
256 if (state === WHITE && graph. has (next)) {
257 const found = visit (next);
258 if (found) return found;
259 }
260 }
261 stack. pop ();
262 color. set (node, BLACK );
263 return null ;
264 }
265
266 for ( const node of graph. keys ()) {
267 if ((color. get (node) || WHITE ) === WHITE ) {
268 const found = visit (node);
269 if (found) return found;
270 }
271 }
272 return null ;
273 }
274
275 // Every entity ref that actually exists on disk, regardless of whether it has dependsOn authored.
276 // checkScope needs this to tell "unknown ref" (not in this set) apart from "known but unreviewed"
277 // (in this set, absent from the dependsOn graph).
278 function listAllEntityRefs ( domainsDir ) {
279 const refs = [];
280 for ( const domain of listDomainDirs (domainsDir)) {
281 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
282 refs. push ( `${ domain }/${ fileName . replace ( / \. json $ / , '' ) }` );
283 }
284 }
285 return refs;
286 }
287
288 // Spec 0041's rp-mapper review-gate check, as a pure function (PR #142 review corrected the first
289 // implementation, which pushed transitive-closure computation onto the caller and silently passed
290 // both unknown refs and never-reviewed entities as though they were "reviewed, no dependencies").
291 // Given every real entity ref, the dependsOn graph, and the plan's own selected scope (only —
292 // the caller does not pre-expand anything), this walks dependsOn edges outward itself and reports
293 // three distinct, non-overlapping failure categories. `checkScope` below is the disk-reading
294 // wrapper `rp-mapper`/an agent/the CLI actually calls.
295 function computeScopeCheck ( allRefs , graph , selectedRefs ) {
296 const selectedSet = new Set ((selectedRefs || []). map (( ref ) => String (ref)));
297 const unknownRefs = new Set (Array. from (selectedSet). filter (( ref ) => ! allRefs. has (ref)));
298 const validSelected = Array. from (selectedSet). filter (( ref ) => allRefs. has (ref));
299
300 const visited = new Set ();
301 const queue = [ ... validSelected];
302 const unreviewedRefs = [];
303 const missingByRef = new Map ();
304
305 while (queue. length ) {
306 const ref = queue. shift ();
307 if (visited. has (ref)) continue ;
308 visited. add (ref);
309
310 if ( ! allRefs. has (ref)) {
311 // Reached only via a dependsOn edge (never itself in selectedRefs, or `selectedSet` would
312 // already have caught it above) — a dangling/typo'd ref, not merely unreviewed.
313 unknownRefs. add (ref);
314 continue ;
315 }
316
317 if ( ! graph. has (ref)) {
318 // Reachable, resolves to a real entity, but dependsOn has never been authored at all —
319 // fail-closed: "not yet reviewed" is not the same fact as "reviewed, no dependencies."
320 unreviewedRefs. push (ref);
321 continue ;
322 }
323
324 const deps = graph. get (ref);
325 const missing = deps. filter (( dep ) => ! selectedSet. has (dep));
326 if (missing. length ) missingByRef. set (ref, missing);
327 for ( const dep of deps) {
328 if ( ! visited. has (dep)) queue. push (dep);
329 }
330 }
331
332 const missingDependencies = Array. from (missingByRef. entries ())
333 . map (([ ref , missing ]) => ({ ref, missing }))
334 . sort (( a , b ) => a.ref. localeCompare (b.ref));
335
336 return {
337 ok: unknownRefs.size === 0 && missingDependencies. length === 0 && unreviewedRefs. length === 0 ,
338 unknownRefs: Array. from (unknownRefs). sort (),
339 missingDependencies,
340 unreviewedRefs: unreviewedRefs. sort (),
341 };
342 }
343
344 // Disk-reading wrapper — this is what `domain-knowledge-validate.js --check-scope` and rp-mapper's
345 // review gate should call, passing only the plan's own selected scope. See computeScopeCheck for
346 // the pure algorithm and spec 0041's "Consumption" decision for why each failure category exists.
347 function checkScope ( domainsDir , selectedRefs ) {
348 return computeScopeCheck ( new Set ( listAllEntityRefs (domainsDir)), buildDependsOnGraph (domainsDir), selectedRefs);
349 }
350
351 // Spec 0041's live backlog: every entity that has never had dependsOn authored at all (field
352 // absent, not merely empty). Non-blocking by design — this is a report, not a validation error.
353 // `node domain-knowledge-validate.js --list-missing-deps` is the intended way to read it, so the
354 // backlog never drifts out of sync with a hand-maintained list the way a markdown table would.
355 function listMissingDependsOn ( domainsDir ) {
356 const missing = [];
357 for ( const domain of listDomainDirs (domainsDir)) {
358 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
359 const entityId = fileName. replace ( / \. json $ / , '' );
360 const entity = loadEntity (domainsDir, domain, entityId);
361 if (entity.dependsOn === undefined ) missing. push ( `${ domain }/${ entityId }` );
362 }
363 }
364 return missing. sort ();
365 }
366
367 // Spec 0020: every domain with entities carries a docs survey in which every
368 // docs-menu surface is triaged, and its native entities cite their object
369 // page. The backfill completed 2026-08-11, so a missing survey is an error.
370 function validateDocsSurvey ( domainsDir , domain , domainJson , errors ) {
371 const surveyPath = path. join (domainsDir, domain, 'docs-survey.json' );
372 const hasEntities = listEntityFiles (path. join (domainsDir, domain)). length > 0 ;
373 if ( ! fs. existsSync (surveyPath)) {
374 if (hasEntities) errors. push ( `${ domain }: missing docs-survey.json; run docs-survey-sync.js ${ domain } and triage` );
375 return ;
376 }
377
378 const label = `${ domain }/docs-survey.json` ;
379 const survey = readJson (surveyPath);
380 requireFields (survey, [ 'schemaVersion' , 'domain' , 'fetchedAt' , 'roots' , 'surfaces' ], label, errors);
381 if (survey.schemaVersion !== 1 ) errors. push ( `${ label }: schemaVersion must be 1` );
382 if (survey.domain !== domain) errors. push ( `${ label }: domain must match directory` );
383
384 const declaredRoots = JSON . stringify ([ ... (domainJson.docsRoots || [])]. sort ());
385 const surveyRoots = JSON . stringify ([ ... (survey.roots || [])]. sort ());
386 if (declaredRoots !== surveyRoots) {
387 errors. push ( `${ label }: roots must match ${ domain }/domain.json docsRoots; re-run docs-survey-sync.js ${ domain }` );
388 }
389
390 for ( const surface of survey.surfaces || []) {
391 const surfaceLabel = `${ label }: ${ surface . id || '<missing id>'}` ;
392 requireFields (surface, [ 'id' , 'title' , 'url' , 'verdict' ], surfaceLabel, errors);
393 if (surface.removed) {
394 errors. push ( `${ surfaceLabel }: surface no longer in the docs menu; delete the entry or revisit the facts that cited it` );
395 continue ;
396 }
397 if ( ! SURVEY_VERDICTS . has (surface.verdict)) {
398 errors. push ( `${ surfaceLabel }: invalid verdict ${ surface . verdict }` );
399 continue ;
400 }
401 if (surface.verdict === 'unreviewed' ) {
402 errors. push ( `${ surfaceLabel }: unreviewed surface; triage it (claimed / not-importable / not-relevant / gap)` );
403 continue ;
404 }
405 if ( ! surface.reviewedOn) errors. push ( `${ surfaceLabel }: triaged surface must carry reviewedOn` );
406 if (surface.verdict === 'claimed' ) {
407 if ( ! Array. isArray (surface.refs) || surface.refs. length === 0 ) {
408 errors. push ( `${ surfaceLabel }: claimed surface must list refs[]` );
409 } else {
410 for ( const ref of surface.refs) {
411 const [ refDomain , refEntity ] = String (ref). split ( '/' );
412 if ( ! refDomain || ! refEntity || ! fs. existsSync (path. join (domainsDir, refDomain, 'entities' , `${ refEntity }.json` ))) {
413 errors. push ( `${ surfaceLabel }: claimed ref ${ ref } does not resolve to an entity file` );
414 }
415 }
416 }
417 }
418 if ((surface.verdict === 'not-importable' || surface.verdict === 'not-relevant' ) && ! surface.reason) {
419 errors. push ( `${ surfaceLabel }: ${ surface . verdict } requires a reason` );
420 }
421 if (surface.verdict === 'gap' && ( ! surface.reason || ! surface.tracking)) {
422 errors. push ( `${ surfaceLabel }: gap requires reason and tracking` );
423 }
424 }
425
426 for ( const fileName of listEntityFiles (path. join (domainsDir, domain))) {
427 const entityId = fileName. replace ( / \. json $ / , '' );
428 const entity = loadEntity (domainsDir, domain, entityId);
429 if ( ! NATIVE_CLASSIFICATIONS . has (entity.target && entity.target.classification)) continue ;
430 const citesObjectPage = (entity.evidence || []). some (
431 ( item ) => typeof item.url === 'string' && /-object $ / . test (item.url. replace ( / \. md $ / , '' ))
432 );
433 // A surface with no …-object reference page (e.g. Comments, which has no
434 // REST reference at all) declares objectPageException with the reason —
435 // explicit and greppable, never silent.
436 const hasException = typeof entity.objectPageException === 'string' && entity.objectPageException. length > 0 ;
437 if ( ! citesObjectPage && ! hasException) {
438 errors. push ( `${ domain }/entities/${ fileName }: native target must cite its docs object page (…-object URL) in evidence[], or declare objectPageException with a reason` );
439 }
440 }
441 }
442
443 function requireFields ( value , fields , label , errors ) {
444 for ( const field of fields) {
445 if (value[field] === undefined ) errors. push ( `${ label }: missing required field ${ field }` );
446 }
447 }
448
449 function validateEntity ( entity , domain , entityId , label , errors ) {
450 requireFields (entity, [ 'schemaVersion' , 'domain' , 'entity' , 'displayName' , 'target' , 'sourceAliases' , 'preferredWrite' , 'reliability' , 'pitfalls' , 'mappingGuidance' , 'evidence' ], label, errors);
451 if (entity.schemaVersion !== 1 ) errors. push ( `${ label }: schemaVersion must be 1` );
452 if (entity.domain !== domain) errors. push ( `${ label }: domain must match file path` );
453 if (entity.entity !== entityId) errors. push ( `${ label }: entity must match file path` );
454 if ( ! CLASSIFICATIONS . has (entity.target && entity.target.classification)) errors. push ( `${ label }: invalid target.classification` );
455 if ( ! ID_POLICIES . has (entity.target && entity.target.idPolicy)) errors. push ( `${ label }: invalid target.idPolicy` );
456 if ( typeof (entity.target && entity.target.crosswalkRequired) !== 'boolean' ) errors. push ( `${ label }: target.crosswalkRequired must be boolean` );
457 if ( ! VERIFICATIONS . has (entity.preferredWrite && entity.preferredWrite.verification)) errors. push ( `${ label }: invalid preferredWrite.verification` );
458 if ( ! RELIABILITY . has (entity.reliability && entity.reliability.status)) errors. push ( `${ label }: invalid reliability.status` );
459 if ( ! Array. isArray (entity.reliability && entity.reliability.flags)) errors. push ( `${ label }: reliability.flags must be an array` );
460 if (entity.preferredWrite && entity.preferredWrite.writerId !== null && ! WRITER_IDS . has (entity.preferredWrite.writerId)) {
461 errors. push ( `${ label }: writerId ${ entity . preferredWrite . writerId } is not exported by wix-writers.js` );
462 }
463 if (entity.target && entity.target.classification !== 'cms' && entity.target.idPolicy === 'server-assigned' && entity.target.crosswalkRequired !== true ) {
464 errors. push ( `${ label }: server-assigned non-CMS targets must require crosswalk` );
465 }
466 for ( const alias of entity.sourceAliases || []) {
467 if ( ! alias.sourceSystem || ! alias.sourceEntity) errors. push ( `${ label }: sourceAliases entries must include sourceSystem and sourceEntity` );
468 }
469 if (entity.target && entity.target.classification === 'manual-mapping' ) {
470 validateManualMapping (entity, label, errors);
471 } else if (entity.manualSteps !== undefined ) {
472 errors. push ( `${ label }: manualSteps is only allowed when target.classification is manual-mapping` );
473 }
474 validateSafeModeContactFields (entity.safeModeContactFields, label, errors);
475 validateBlockedSourceDependencies (entity, label, errors);
476 validateEvidence (entity.evidence || [], label, errors);
477 }
478
479 function validateBlockedSourceDependencies ( entity , label , errors ) {
480 if (entity.blockedSourceDependencies === undefined ) return ;
481 if ( ! Array. isArray (entity.blockedSourceDependencies)) {
482 errors. push ( `${ label }: blockedSourceDependencies must be an array when present` );
483 return ;
484 }
485 const pitfallCodes = new Set ((entity.pitfalls || []). map (( pitfall ) => pitfall.code));
486 const seen = new Set ();
487 entity.blockedSourceDependencies. forEach (( dependency , index ) => {
488 const dependencyLabel = `${ label }: blockedSourceDependencies[${ index }]` ;
489 if ( ! dependency || typeof dependency !== 'object' || Array. isArray (dependency)) {
490 errors. push ( `${ dependencyLabel } must be an object` );
491 return ;
492 }
493 for ( const field of [ 'sourceEntityRef' , 'degradedField' , 'pitfallCode' , 'degradation' ]) {
494 if ( ! dependency[field] || typeof dependency[field] !== 'string' ) errors. push ( `${ dependencyLabel }.${ field } must be a non-empty string` );
495 }
496 if (dependency.sourceEntityRef && ! / ^ plugin \. [ ^ .] + \. [ ^ .] +$ / . test (dependency.sourceEntityRef)) {
497 errors. push ( `${ dependencyLabel }.sourceEntityRef must use plugin.<slug>.<entity>` );
498 }
499 if (dependency.pitfallCode && ! pitfallCodes. has (dependency.pitfallCode)) {
500 errors. push ( `${ dependencyLabel }.pitfallCode ${ dependency . pitfallCode } does not resolve in this entity's pitfalls[]` );
501 }
502 if (dependency.degradation && ! BLOCKED_DEPENDENCY_DEGRADATIONS . has (dependency.degradation)) {
503 errors. push ( `${ dependencyLabel }.degradation must be warning or deferred` );
504 }
505 const key = `${ dependency . sourceEntityRef }:${ dependency . degradedField }` ;
506 if (seen. has (key)) errors. push ( `${ dependencyLabel } duplicates ${ key }` );
507 seen. add (key);
508 });
509 }
510
511 // For manual-mapping: a manual-mapping entity performs no write, so its preferredWrite must
512 // be structurally inert (nothing for import codegen to accidentally call), and its manualSteps
513 // must actually contain a runbook — an empty or missing one would render nothing to the
514 // merchant, which is worse than not classifying it manual-mapping at all.
515 function validateManualMapping ( entity , label , errors ) {
516 const pw = entity.preferredWrite || {};
517 if (pw.endpoint !== null ) errors. push ( `${ label }: manual-mapping requires preferredWrite.endpoint === null` );
518 if (pw.writerId !== null ) errors. push ( `${ label }: manual-mapping requires preferredWrite.writerId === null` );
519 if (pw.verification !== 'none' ) errors. push ( `${ label }: manual-mapping requires preferredWrite.verification === 'none'` );
520 if (pw.importSafe !== false ) errors. push ( `${ label }: manual-mapping requires preferredWrite.importSafe === false` );
521 if (pw.bulk !== false ) errors. push ( `${ label }: manual-mapping requires preferredWrite.bulk === false` );
522
523 const manualSteps = entity.manualSteps;
524 if ( ! manualSteps || typeof manualSteps !== 'object' ) {
525 errors. push ( `${ label }: manual-mapping requires a manualSteps object` );
526 return ;
527 }
528 if ( ! Array. isArray (manualSteps.steps) || manualSteps.steps. length === 0 ) {
529 errors. push ( `${ label }: manualSteps.steps must be a non-empty array` );
530 return ;
531 }
532 manualSteps.steps. forEach (( step , index ) => {
533 if ( ! step || ! MANUAL_STEP_ACTORS . has (step.actor)) {
534 errors. push ( `${ label }: manualSteps.steps[${ index }].actor must be one of ${ Array . from ( MANUAL_STEP_ACTORS ). join ( ', ' ) }` );
535 }
536 if ( ! step || typeof step.text !== 'string' || step.text. trim () === '' ) {
537 errors. push ( `${ label }: manualSteps.steps[${ index }].text must be a non-empty string` );
538 }
539 });
540 }
541
542 function validateSafeModeContactFields ( fields , label , errors ) {
543 if (fields === undefined ) return ;
544 if ( ! Array. isArray (fields)) {
545 errors. push ( `${ label }: safeModeContactFields must be an array when present` );
546 return ;
547 }
548 for ( const [ index , field ] of fields. entries ()) {
549 const fieldLabel = `${ label }: safeModeContactFields[${ index }]` ;
550 if ( ! field || typeof field !== 'object' || Array. isArray (field)) {
551 errors. push ( `${ fieldLabel } must be an object` );
552 continue ;
553 }
554 if ( ! SAFE_MODE_CONTACT_KINDS . has (field.kind)) errors. push ( `${ fieldLabel }.kind must be email or phone` );
555 if ( ! field.targetPath || typeof field.targetPath !== 'string' ) errors. push ( `${ fieldLabel }.targetPath must be a non-empty string` );
556 if ( ! field.source || typeof field.source !== 'string' ) errors. push ( `${ fieldLabel }.source must be a non-empty string` );
557 }
558 }
559
560 function validateEvidence ( items , label , errors ) {
561 if ( ! Array. isArray (items) || items. length === 0 ) {
562 errors. push ( `${ label }: evidence must be a non-empty array` );
563 return ;
564 }
565 for ( const item of items) {
566 if ( ! item.url && ! item.path) errors. push ( `${ label }: every evidence item must include url or path` );
567 }
568 }
569
570 function validateIndexConsistency ( index , domainsDir , errors ) {
571 const flagged = new Set (index.flags && index.flags. IMPORT_UNRELIABLE ? index.flags. IMPORT_UNRELIABLE : []);
572 const entityFlagged = new Set ();
573
574 for ( const [ domain , domainEntry ] of Object. entries (index.domains || {})) {
575 if ( ! fs. existsSync (path. join (domainsDir, domain, 'domain.json' ))) errors. push ( `index: missing domain file for ${ domain }` );
576 for ( const [ entityId , entityEntry ] of Object. entries (domainEntry.entities || {})) {
577 const ref = `${ domain }/${ entityId }` ;
578 const entityPath = path. join (domainsDir, domain, 'entities' , `${ entityId }.json` );
579 if ( ! fs. existsSync (entityPath)) {
580 errors. push ( `index: missing entity file for ${ ref }` );
581 continue ;
582 }
583 const entity = readJson (entityPath);
584 if ((entity.reliability.flags || []). includes ( 'IMPORT_UNRELIABLE' )) entityFlagged. add (ref);
585 if (entityEntry.path !== `domains/${ domain }/entities/${ entityId }.json` ) {
586 errors. push ( `index: invalid path for ${ ref }` );
587 }
588 }
589 }
590
591 for ( const ref of entityFlagged) {
592 if ( ! flagged. has (ref)) errors. push ( `index: ${ ref } has IMPORT_UNRELIABLE but is missing from flags` );
593 }
594 for ( const ref of flagged) {
595 if ( ! entityFlagged. has (ref)) errors. push ( `index: ${ ref } is flagged IMPORT_UNRELIABLE but entity file is not` );
596 }
597 }
598
599 function loadIndex ( domainsDir ) {
600 return readJson (path. join (domainsDir, 'index.json' ));
601 }
602
603 function listDomains ( domainsDir ) {
604 const index = loadIndex (domainsDir);
605 return Object. entries (index.domains || {}). map (([ domain , info ]) => ({
606 domain,
607 displayName: info.displayName,
608 ownerHint: info.ownerHint,
609 path: info.path,
610 }));
611 }
612
613 function listEntities ( domainsDir , domain ) {
614 const index = loadIndex (domainsDir);
615 const domainInfo = index.domains && index.domains[domain];
616 if ( ! domainInfo) throw new Error ( `Unknown domain: ${ domain }` );
617 return Object. entries (domainInfo.entities || {}). map (([ entity , info ]) => ({
618 ref: `${ domain }/${ entity }` ,
619 entity,
620 displayName: info.displayName,
621 classification: info.classification,
622 reliabilityStatus: info.reliabilityStatus,
623 reliabilityFlags: info.reliabilityFlags,
624 path: info.path,
625 }));
626 }
627
628 function readEntityByRef ( domainsDir , ref ) {
629 const [ domain , entity ] = ref. split ( '/' );
630 if ( ! domain || ! entity) throw new Error ( `Invalid ref: ${ ref }` );
631 return loadEntity (domainsDir, domain, entity);
632 }
633
634 function resolveSource ( domainsDir , { sourceSystem , sourceEntity , route }) {
635 const index = loadIndex (domainsDir);
636 const refs = new Set ();
637 if (sourceSystem && sourceEntity) {
638 for ( const ref of index.sourceAliasIndex[ `${ sourceSystem }:${ sourceEntity }` ] || []) refs. add (ref);
639 }
640 if (route) {
641 for ( const [ pattern , patternRefs ] of Object. entries (index.routeAliasIndex || {})) {
642 if (route === pattern || routeMatches (pattern, route)) {
643 for ( const ref of patternRefs) refs. add (ref);
644 }
645 }
646 }
647 return Array. from (refs). sort (). map (( ref ) => {
648 const entity = readEntityByRef (domainsDir, ref);
649 return {
650 ref,
651 confidence: bestAliasConfidence (entity, { sourceSystem, sourceEntity, route }),
652 sourceAliases: (entity.sourceAliases || []). filter (( alias ) => aliasMatches (alias, { sourceSystem, sourceEntity, route })),
653 };
654 });
655 }
656
657 function routeMatches ( pattern , route ) {
658 if ( ! pattern. includes ( '{' ) && ! pattern. includes ( ':' )) return false ;
659 const escaped = pattern. replace ( / [.*+?^${}()|[ \]\\ ] / g , ' \\ $&' ). replace ( / \\\{ [ ^ }] + \\\} / g , '[^/]+' ). replace ( /: [A-Za-z0-9_-] + / g , '[^/]+' );
660 return new RegExp ( `^${ escaped }$` ). test (route);
661 }
662
663 function aliasMatches ( alias , query ) {
664 const sourceMatches = ( ! query.sourceSystem || alias.sourceSystem === query.sourceSystem) && ( ! query.sourceEntity || alias.sourceEntity === query.sourceEntity);
665 const routeMatchesAlias = ! query.route || (alias.routes || []). some (( pattern ) => query.route === pattern || routeMatches (pattern, query.route));
666 return sourceMatches && routeMatchesAlias;
667 }
668
669 function bestAliasConfidence ( entity , query ) {
670 const order = { high: 3 , medium: 2 , low: 1 };
671 let best = 'low' ;
672 for ( const alias of entity.sourceAliases || []) {
673 if ( aliasMatches (alias, query) && (order[alias.confidence] || 0 ) > (order[best] || 0 )) best = alias.confidence;
674 }
675 return best;
676 }
677
678 function listFlagged ( domainsDir , flag ) {
679 const index = loadIndex (domainsDir);
680 return (index.flags && index.flags[flag] ? index.flags[flag] : []). map (( ref ) => {
681 const entity = readEntityByRef (domainsDir, ref);
682 return {
683 ref,
684 displayName: entity.displayName,
685 classification: entity.target.classification,
686 reliabilityStatus: entity.reliability.status,
687 summary: firstPitfallOrGuidance (entity),
688 };
689 });
690 }
691
692 function summarizeEntities ( domainsDir , refs , { includeEvidence = false } = {}) {
693 return refs. map (( ref ) => {
694 const entity = readEntityByRef (domainsDir, ref);
695 const summary = {
696 ref,
697 displayName: entity.displayName,
698 target: entity.target,
699 preferredWrite: entity.preferredWrite,
700 reliability: entity.reliability,
701 pitfalls: entity.pitfalls,
702 mappingGuidance: entity.mappingGuidance,
703 setupRequirements: entity.setupRequirements || [],
704 fieldContracts: entity.fieldContracts || [],
705 blockedSourceDependencies: entity.blockedSourceDependencies || [],
706 extendedFields: entity.extendedFields || null ,
707 };
708 if (includeEvidence) summary.evidence = entity.evidence;
709 return summary;
710 });
711 }
712
713 // Compact cross-adapter view consumed by the WordPress plugin knowledge base so plugin
714 // profiles can be validated against real Wix target refs and capability claims without
715 // each adapter re-reading the other's tree.
716 function knowledgeSummary ( domainsDir ) {
717 const index = loadIndex (domainsDir);
718 const knownRefs = new Set ();
719 const verificationByRef = new Map ();
720 // manual-mapping entities only — lets classifyCoverage tell a manual-mapping
721 // target apart from a real write target using only this compact summary, with no need to
722 // re-read the full rp-target-wix entity tree from the WordPress side.
723 const manualStepsByRef = new Map ();
724 const blockedSourceDependenciesByRef = new Map ();
725 for ( const [ domain , domainEntry ] of Object. entries (index.domains || {})) {
726 for ( const entityId of Object. keys (domainEntry.entities || {})) {
727 const ref = `${ domain }/${ entityId }` ;
728 knownRefs. add (ref);
729 const entity = loadEntity (domainsDir, domain, entityId);
730 verificationByRef. set (ref, entity.preferredWrite && entity.preferredWrite.verification);
731 if (entity.target && entity.target.classification === 'manual-mapping' && entity.manualSteps) {
732 manualStepsByRef. set (ref, entity.manualSteps);
733 }
734 if (Array. isArray (entity.blockedSourceDependencies) && entity.blockedSourceDependencies. length > 0 ) {
735 blockedSourceDependenciesByRef. set (ref, entity.blockedSourceDependencies);
736 }
737 }
738 }
739 const capabilityRefs = new Map (Object. entries (index.capabilityIndex || {}));
740 return { knownRefs, capabilityRefs, verificationByRef, manualStepsByRef, blockedSourceDependenciesByRef };
741 }
742
743 module . exports = {
744 knowledgeRoot,
745 generateIndex,
746 knowledgeSummary,
747 validateKnowledge,
748 validateEntity,
749 validateBlockedSourceDependencies,
750 buildDependsOnGraph,
751 findCycle,
752 listAllEntityRefs,
753 computeScopeCheck,
754 checkScope,
755 listMissingDependsOn,
756 writeJson,
757 listDomains,
758 listEntities,
759 readEntityByRef,
760 resolveSource,
761 listFlagged,
762 summarizeEntities,
763 };