Setting the file. One moment.
Mapping Field Contract · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page — line 68
This file
Number 45.98
Position 98 of 182
Type JavaScript
Size 58 KB
Lines 717 lib/ mapping-field-contract.js
JavaScript · 717 lines · 58 KB
(
'./output-applicability'
);
8 const DISPOSITIONS = [ 'native' , 'extended-field' , 'cms' , 'excluded' , 'unresolved' , 'deferred-field' ];
9 const GAP_KEYS = [ 'id' , 'gapKind' , 'sourceEntity' , 'decisionId' , 'sourcePaths' , 'targetRef' ,
10 'writeOperation' , 'disposition' , 'storage' , 'limitation' , 'searchEvidence' , 'knowledgeEvidence' ,
11 'nativeBehaviorMissing' , 'preservationStatus' , 'affectedScope' , 'nextAction' , 'resolutionEvidence' , 'supersedes' , 'recovery' ];
12 function canonical ( value ) {
13 // Match JSON persistence (undefined object members disappear; array holes become null).
14 const serialized = JSON . stringify (value);
15 if (serialized === undefined ) return undefined ;
16 function stable ( v ) {
17 if (Array. isArray (v)) return `[${ v . map ( stable ). join ( ',' ) }]` ;
18 if (v && typeof v === 'object' ) return `{${ Object . keys ( v ). sort (). map ( k => `${ JSON . stringify ( k ) }:${ stable ( v [ k ]) }` ). join ( ',' ) }}` ;
19 return JSON . stringify (v);
20 }
21 return stable ( JSON . parse (serialized));
22 }
23 function digest ( value ) { return crypto. createHash ( 'sha256' ). update ( canonical (value)). digest ( 'hex' ); }
24 function fail ( message ) { throw Object. assign ( new Error (message), { code: 'mapping_field_contract' }); }
25 function entityRef ( row ) { return row?.sourceEntityRef || row?.sourceEntity || row?.entityName || row?.entity || row?.entityKey; }
26 // An entity decision file is named from the target-domain entity in some legacy
27 // plans and from the source identity in exact plans. Gates must use the same
28 // identity set when joining a plan row to a decision file.
29 function entityKeyMatches ( row , key ) {
30 if ( ! row || typeof key !== 'string' || ! key) return false ;
31 const targetEntity = typeof row.targetRef === 'string' ? row.targetRef. split ( '/' ). at ( - 1 ) : null ;
32 let sourceFileKey = null ;
33 try { sourceFileKey = entityFileName (row). replace ( / \. json $ / , '' ); } catch { /* malformed rows fail through their own validation */ }
34 return [row.entity, row.sourceEntity, row.sourceEntityRef, row.name, row.target?.entity, targetEntity, sourceFileKey]
35 . filter ( value => typeof value === 'string' && value). includes (key);
36 }
37 function entityFileName ( row ) {
38 const identity = row.sourceEntity || row.entityKey || entityRef (row);
39 if ( typeof identity !== 'string' || ! identity) fail ( 'entity filename identity missing' );
40 return `${ identity . toLowerCase (). replace ( / [ ^ a-z0-9] + / g , '-' ). replace ( / ^ - +| - +$ / g , '' ) }.json` ;
41 }
42 function entityRows ( plan ) {
43 if (Array. isArray (plan)) return plan;
44 for ( const key of [ 'entities' , 'entityDecisions' , 'entityMappings' , 'mappings' ]) {
45 if (Array. isArray (plan[key])) return plan[key];
46 if (key === 'entities' && plan[key] && typeof plan[key] === 'object' ) {
47 return Object. entries (plan[key]). map (([ ref , row ]) => ({ sourceEntity: ref, ... row }));
48 }
49 }
50 return [];
51 }
52 // Resolve a gated target entity (for example `contact`) to the decision projection
53 // written for its exact-plan source identity (for example `wc-v3-customers.json`).
54 // Legacy plans have no matching source row and retain their target-named filename.
55 function entityDecisionFileName ( plan , key ) {
56 const matches = entityRows (plan). filter ( row => entityKeyMatches (row, key));
57 if (matches. length > 1 ) fail ( `entity decision identity is ambiguous: ${ key }` );
58 return matches. length ? entityFileName (matches[ 0 ]) : `${ key }.json` ;
59 }
60 // Exact locations use the shared lossless grammar (lib/source-location.js). Legacy dotted
61 // paths of the former identifier-only grammar are already canonical and keep their meaning.
62 function exactPath ( value ) {
63 return typeof value === 'string' && L . isExact (value);
64 }
65 const loc = p => L . canonicalize (p);
66 // A decision's exact consumed locations. A legacy selector on a dynamic base path
67 // ({path, keyPath, keys}) replaces that base path with one selector-qualified location per key.
68 function selectorLocations ( sel ) {
69 const t = L . template ({ path: sel.path, selector: { keyPath: sel.keyPath } });
70 return (sel.keys || []). map ( k => L . selected (t, k));
71 }
72 function expandLocations ( paths , selectors ) {
73 const bySel = new Map ();
74 for ( const sel of selectors || []) {
75 const p = loc (sel.path);
76 bySel. set (p, [ ... (bySel. get (p) || []), ... selectorLocations (sel)]);
77 }
78 return [ ...new Set ((paths || []). flatMap ( p => bySel. get ( loc (p)) || [ loc (p)]))];
79 }
80 const decisionLocations = d => expandLocations (d?.sourcePaths, d?.selectors);
81 const outputLocations = ( d , o ) => expandLocations (o?.sourcePaths, d?.selectors);
82 // Coverage locations: a dynamic entry stands for its observed selector-qualified keys.
83 function coverageLocations ( row ) {
84 return [ ...new Set ((row.discoveryCoverage?.paths || []). flatMap ( item => {
85 if ( typeof item === 'string' ) return [ loc (item)];
86 if (item?.dynamic === true ) {
87 const t = L . template (item);
88 return (item.observedKeys || []). map ( k => L . selected (t, k));
89 }
90 return [ loc (item?.path)];
91 }))];
92 }
93 function coverageItems ( row ) {
94 return (row.discoveryCoverage?.paths || []). filter ( item => item && typeof item === 'object' && item.dynamic !== true )
95 . map ( item => ({ ... item, path: loc (item.path) }));
96 }
97 function scopeUnknown ( coverage = {}) {
98 return { sourceSnapshotDigest: coverage.sourceSnapshotDigest || null ,
99 inventoryDigest: coverage.inventoryDigest || null , sourceIds: null , sourceIdsComplete: false ,
100 sourceIdsDigest: null , count: null , countStatus: 'unknown' };
101 }
102 function affectedScope ( scope , coverage ) {
103 const s = { ... scopeUnknown (coverage), ... scope };
104 if ( ! [ 'known' , 'unknown' ]. includes (s.countStatus)) fail ( 'invalid countStatus' );
105 if (s.countStatus === 'unknown' ) {
106 if (s.count !== null || s.sourceIds !== null ) fail ( 'unknown coverage requires null count and sourceIds' );
107 } else {
108 if ( ! Number. isSafeInteger (s.count) || s.count < 0 ) fail ( 'known count must be a nonnegative integer' );
109 if (s.sourceIds !== null && ( ! Array. isArray (s.sourceIds) || s.sourceIds. some ( x => typeof x !== 'string' ))) fail ( 'sourceIds must be strings or null' );
110 if (s.sourceIds) {
111 s.sourceIds = [ ...new Set (s.sourceIds)]. sort ();
112 if (s.sourceIds. length > s.count || (s.sourceIdsComplete && s.sourceIds. length !== s.count)) fail ( 'affected IDs disagree with count' );
113 }
114 }
115 return s;
116 }
117 // Existing source/member policies exclude credentials, never ordinary business fields.
118 // The inventory must classify the same bounded paths; a reference or reason alone cannot
119 // authorize a populated omission. No new user-approved omission contract is implemented here.
120 const CREDENTIAL_KEY = / ^ (?:credentials | password | password_hash | user_pass | application_password | api_key | api_secret | consumer_key | consumer_secret | access_token | refresh_token) $ / i ;
121 function credentialRoot ( parent ) {
122 try {
123 const segs = L . parse (parent);
124 let i = segs. length - 1 ;
125 if (segs[i].each && ! segs[i].where) i -= 1 ;
126 return i >= 0 && segs[i].key !== undefined && CREDENTIAL_KEY . test (segs[i].key);
127 } catch { return false ; }
128 }
129 const within = ( parent , p , strict = false ) => L . within (parent,p,{strict});
130 function mandatoryExclusion ( row , decision ) {
131 if (decision?.policyRef !== 'mandatory-no-credentials' || ! decision.sourcePaths?. length ) return false ;
132 const paths = row.discoveryCoverage?.paths || [];
133 return decision.sourcePaths. every ( parent => credentialRoot (parent) && paths. some ( leaf =>
134 leaf && typeof leaf === 'object' && leaf.retentionClass === 'credential' && within (parent,leaf.path)) &&
135 paths. filter ( leaf => within (parent, typeof leaf === 'string' ? leaf : leaf?.path))
136 . every ( leaf =>typeof leaf === 'object' && leaf.retentionClass === 'credential' ));
137 }
138 // Subtree claims exist only for the mandatory credential policy (descendants strictly below).
139 const credentialSubtree = ( row , d , p ) => d?.disposition === 'excluded' && d.excludeSubtree === true && mandatoryExclusion (row,d) && Array. isArray (d.sourcePaths) && d.sourcePaths. some ( parent => within (parent,p, true ));
140 // A zero is a measurement only when the complete empty population is bound to the
141 // same inspected snapshot/inventory. Unknown, truncated or stale scope cannot authorize loss.
142 function measuredZeroExclusion ( row , decision ) {
143 const s = decision?.affectedScope, c = row?.discoveryCoverage;
144 return Boolean (decision?.disposition === 'excluded' && s?.countStatus === 'known' && s.count === 0 &&
145 s.sourceIdsComplete === true && Array. isArray (s.sourceIds) && s.sourceIds. length === 0 &&
146 s.sourceIdsDigest === digest ([]) && typeof c?.sourceSnapshotDigest === 'string' && c.sourceSnapshotDigest. length &&
147 typeof c.inventoryDigest === 'string' && c.inventoryDigest. length &&
148 s.sourceSnapshotDigest === c.sourceSnapshotDigest && s.inventoryDigest === c.inventoryDigest);
149 }
150 // Source fields whose content is carried, not dropped: a source adapter turns them into one
151 // structural canonical field (for example product.bundle) that a manual-mapping target entity
152 // hands to the operator (its runbook plus one cutover task per record). Excluding them from the
153 // write is therefore not an omission and does not hold the record. Valid only when the canonical
154 // field is normalized, the carrier is a manual-mapping entity with a runbook that declares it
155 // `carries` that field, and the row's own target is one the carrier depends on (the bundle
156 // carrier depends on stores/product, so the policy cannot release a field of any other entity).
157 // This bounds the policy by entity, not by source path: which source paths feed the canonical
158 // field is the adapter's knowledge and is reviewed in the mapping. That a given record emitted the
159 // canonical field is the adapter's contract (it returns the field or an unreadable reason); the
160 // cutover producer rejects a bundle record that has neither.
161 const CARRIED_POLICY = 'carried-to-cutover' ;
162 function carriedToCutoverExclusion ( row , decision ) {
163 if (decision?.disposition !== 'excluded' || decision.policyRef !== CARRIED_POLICY || ! decision.sourcePaths?. length ) return false ;
164 return carriedToCutoverErrors (row, decision). length === 0 ;
165 }
166 function carriedToCutoverErrors ( row , decision ) {
167 const errors = [];
168 const { CANONICAL_FIELDS } = require ( '../resources/rp-target-wix/lib/wix-target-spec.js' );
169 if ( typeof decision.canonicalField !== 'string' || ! Object. hasOwn ( CANONICAL_FIELDS , decision.canonicalField) || CANONICAL_FIELDS [decision.canonicalField].normalized !== true ) errors. push ( 'canonicalField must name a normalized canonical field' );
170 let carrier = null ;
171 if ( typeof decision.carriedBy === 'string' && / ^ [a-z0-9-] + \/ [a-z0-9-] +$ / . test (decision.carriedBy)) {
172 try { carrier = require ( './dependency-proofs.js' ). bundledDomain (decision.carriedBy); } catch { carrier = null ; }
173 }
174 if ( ! carrier) { errors. push ( 'carriedBy must name a target entity' ); return errors; }
175 if (carrier.target?.classification !== 'manual-mapping' || ! carrier.manualSteps?.steps?. length ) errors. push ( 'carriedBy must be a manual-mapping entity with a runbook' );
176 if ( ! Array. isArray (carrier.carries) || ! carrier.carries. includes (decision.canonicalField)) errors. push ( `carriedBy does not carry ${ decision . canonicalField }` );
177 const target = row?.targetRef || (row?.targetDomain && row?.targetEntity ? `${ row . targetDomain }/${ row . targetEntity }` : null );
178 if ( ! target || ! (carrier.dependsOn || []). includes (target)) errors. push ( `carriedBy does not carry fields of ${ target || 'an entity without a target'}` );
179 return errors;
180 }
181 function pendingDecision ( row , d ) {
182 return d.disposition === 'unresolved' || (d.disposition === 'excluded' && ! mandatoryExclusion (row,d) &&
183 ! measuredZeroExclusion (row,d) && ! carriedToCutoverExclusion (row,d));
184 }
185 function mappingRunMode ( projectDir ) {
186 return require ( './orchestration-decisions.js' ). isExplicitUserOneClick ( readOptional (path. join (projectDir, 'orchestration/decisions.json' ),{})) ? 'one_click' : 'interactive' ;
187 }
188 function validateEntity ( row ) {
189 if ( ! row || typeof row !== 'object' || Array. isArray (row)) return [ 'entity must be an object' ];
190 const errors = [], ref = entityRef (row), ids = new Set (), writes = new Map ();
191 const error = m => errors. push ( `${ ref }: ${ m }` );
192 if (row.fieldMappingStatus === 'pending' ) {
193 if ( ! ref || ! Array. isArray (row.fieldMappings) || row.fieldMappings. length || ! Array. isArray (row.discoveryCoverage?.paths) || row.discoveryCoverage.paths. length || ! row.discoveryCoverage?.uninspectedAreas?. length || ! Array. isArray (row.requiredSourceEntityRefs)) error ( 'pending field inventory needs explicit empty decisions/paths and disclosed uninspected scope' );
194 return errors;
195 }
196 if (row.exampleBase !== undefined && ( ! row.exampleBase || typeof row.exampleBase !== 'object' || Array. isArray (row.exampleBase))) error ( 'exampleBase must be one synthetic source record' );
197 if (row.targetClassification === 'manual-mapping' ) {
198 if ( ! row.manualSteps?.steps?. length ) error ( 'manual mapping needs its complete runbook' );
199 if (Array. isArray (row.fieldMappings) && row.fieldMappings. some ( d => d?.outputs?. length )) error ( 'manual mapping cannot invent a write' );
200 return errors;
201 }
202 const coverage = row.discoveryCoverage;
203 if ( ! ref) error ( 'source identity is missing' );
204 if (row.omissionReviewRequested !== undefined && typeof row.omissionReviewRequested !== 'boolean' ) error ( 'omission review marker must be boolean' );
205 if ( ! coverage || ! Array. isArray (coverage.paths) || ! Array. isArray (coverage.uninspectedAreas) || ! Array. isArray (coverage.evidence)) error ( 'discoveryCoverage needs paths, uninspectedAreas and evidence' );
206 if (coverage && Array. isArray (coverage.paths) && (coverage.paths. some ( p => p && typeof p === 'object' && p.dynamic === true && ( ! p.selector || ! Array. isArray (p.observedKeys))) || ! coverage.sourceSnapshotDigest || ! coverage.inventoryDigest)) error ( 'coverage needs snapshot/inventory digests and observed selectors for dynamic keys' );
207 if ( ! Array. isArray (row.requiredSourceEntityRefs)) error ( 'requiredSourceEntityRefs must explicitly declare reviewed dependencies, even when empty' );
208 if ( ! Array. isArray (row.fieldMappings)) error ( 'fieldMappings must be an array' );
209 if (errors. length ) return errors;
210 let dynamicTemplates = [];
211 try {
212 dynamicTemplates = coverage.paths. filter ( p => p && typeof p === 'object' && p.dynamic === true ). map ( L .template);
213 for ( const item of coverage.paths) {
214 const p =typeof item === 'string' ? item : item?.path;
215 if ( ! exactPath (p) || (item?.dynamic === true && ( ! exactPath (item.selector?.keyPath) || item.observedKeys. some ( k =>! L . scalar (k))))) { error ( `inventory path ${ typeof p === 'string' ? p : JSON . stringify ( p ) } is not exact` ); return errors;}
216 }
217 } catch (e) { error ( `invalid dynamic coverage selector: ${ e . message }` ); return errors;}
218 for ( const d of row.fieldMappings) {
219 if ( ! d || typeof d !== 'object' || ! Array. isArray (d.sourcePaths) || (d.outputs !== undefined && ! Array. isArray (d.outputs)) || (d.examples !== undefined && ! Array. isArray (d.examples))) { error ( 'malformed field decision' ); continue ;}
220 if ( ! d.id || ids. has (d.id)) error ( 'missing/duplicate decision ID' ); ids. add (d.id);
221 if ( ! Array. isArray (d.sourcePaths) || d.sourcePaths. some ( p => ! exactPath (p))) { error ( `${ d . id }: sourcePaths must be exact leaf paths` ); continue ;}
222 if (d.selectors !== undefined ) {
223 const declared =new Set (dynamicTemplates. map ( t => canonical ([t.path,t.keyPath])));
224 if ( ! Array. isArray (d.selectors) || d.selectors. some ( sel => {
225 if ( ! sel || ! exactPath (sel.path) || ! exactPath (sel.keyPath) || ! Array. isArray (sel.keys) || ! sel.keys. length || sel.keys. some ( k =>! L . scalar (k)) || ! d.sourcePaths. some ( p => loc (p) === loc (sel.path))) return true ;
226 try { const t = L . template ({path:sel.path,selector:{keyPath:sel.keyPath}}); return ! declared. has ( canonical ([t.path,t.keyPath]));} catch { return true ;}
227 })) { error ( `${ d . id }: selectors must name a declared dynamic path, its key path and observed scalar keys` ); continue ;}
228 }
229 if (d.affectedScopes !== undefined ) {
230 const own =new Set ( decisionLocations (d));
231 if ( ! d.affectedScopes || typeof d.affectedScopes !== 'object' || Array. isArray (d.affectedScopes)) error ( `${ d . id }: affectedScopes must map locations to scopes` );
232 else for ( const [ p , s ] of Object. entries (d.affectedScopes)) {
233 if ( ! exactPath (p) || ! own. has (p)) error ( `${ d . id }: affectedScopes names ${ p }, which is not one of its canonical locations` );
234 try { affectedScope (s,coverage);} catch (e) { error ( `${ d . id }: affectedScopes ${ p }: ${ e . message }` );}
235 }
236 // The decision-level scope is the union of its locations' memberships.
237 const parts = Object. values (d.affectedScopes || {}). map ( s => { try { return affectedScope (s,coverage);} catch { return null ;}});
238 const whole = d.affectedScope ? (() => { try { return affectedScope (d.affectedScope,coverage);} catch { return null ;}})() : null ;
239 if (whole?.sourceIdsComplete && parts. length === own.size && parts. every ( s => s?.sourceIdsComplete && Array. isArray (s.sourceIds)) &&
240 canonical ([ ...new Set (parts. flatMap ( s => s.sourceIds))]. sort ()) !== canonical (whole.sourceIds)) error ( `${ d . id }: decision affected IDs differ from the union of its per-location memberships` );
241 }
242 if ( ! DISPOSITIONS . includes (d.disposition)) error ( `${ d . id }: invalid disposition` );
243 if ( ! Array. isArray (d.evidence) || ! d.evidence. length ) error ( `${ d . id }: missing evidence` );
244 if ( ! d.reason) error ( `${ d . id }: missing reason` );
245 if (d.disposition === 'deferred-field' ) {
246 for ( const m of require ( './mapping-recovery' ). shape (row, d)) error ( `${ d . id }: ${ m }` );
247 if ( ! d.sourcePaths. length || d.outputs?. length || d.omittedOutputs?. length ) error ( `${ d . id }: deferred fields retain source paths and cannot emit or exclude outputs` );
248 continue ;
249 }
250 if (d.omittedOutputs !== undefined && ( ! Array. isArray (d.omittedOutputs) || d.disposition !== 'excluded' || d.omittedOutputs. some ( o =>! o || ! o.writeOperation || ! exactPath (o.writeInput) || ! exactPath (o.targetPath)))) error ( `${ d . id }: invalid omitted intended output binding` );
251 if ([ 'excluded' , 'unresolved' ]. includes (d.disposition)) {
252 if (d.recovery) for ( const m of require ( './mapping-recovery' ). shape (row,d)) error ( `${ d . id }: ${ m }` );
253 if (d.policyRef === CARRIED_POLICY ) {
254 if (d.disposition !== 'excluded' ) error ( `${ d . id }: ${ CARRIED_POLICY } is an exclusion policy` );
255 for ( const m of carriedToCutoverErrors (row, d)) error ( `${ d . id }: ${ m }` );
256 }
257 if ( ! d.sourcePaths?. length ) error ( `${ d . id }: exclusion/question needs source paths` );
258 if (d.outputs?. length ) error ( `${ d . id }: excluded/unresolved decision cannot write outputs` );
259 continue ;
260 }
261 if ( ! d.transform || ! [ 'identity' , 'expression' , 'lookup' , 'helper' ]. includes (d.transform.kind)) error ( `${ d . id }: exact transform is required` );
262 if (d.transform?.kind === 'expression' && ( ! d.transform.expression || /chronology fields | see schema | as appropriate/ i . test (d.transform.expression))) error ( `${ d . id }: undecided expression` );
263 if (d.transform?.kind === 'helper' && ( ! d.transform.name || ! d.transform.revision || ! d.transform.arguments)) error ( `${ d . id }: helper requires name, revision and arguments` );
264 if (d.transform?.kind === 'lookup' && ( ! d.transform.cases || ! d.transform.unknown)) error ( `${ d . id }: lookup requires cases and unknown handling` );
265 for ( const key of [ 'missing' , 'null' , 'empty' , 'zero' , 'invalid' , 'unknown' ]) {
266 if ( ! d.valuePolicy?.[key]?.action || typeof d.valuePolicy[key].action !== 'string' ) error ( `${ d . id }: missing ${ key } value policy action` );
267 }
268 if ( ! Array. isArray (d.examples) || ! d.examples. length || d.examples. some ( e => ! e || ! e.id || ! Object. hasOwn (e, 'input' ) || ( ! Array. isArray (e.expected) || ! e.expected. length ))) error ( `${ d . id }: independently expected examples required` );
269 if ( ! Array. isArray (d.outputs) || ! d.outputs. length ) error ( `${ d . id }: precise outputs required` );
270 const outputIds = new Set ();
271 for ( const o of d.outputs || []) {
272 if ( ! o || typeof o !== 'object' ) { error ( `${ d . id }: malformed output` ); continue ;}
273 if ( ! o.id || outputIds. has (o.id)) error ( `${ d . id }: missing/duplicate output ID` ); outputIds. add (o.id);
274 if ( ! o.targetRef || ! o.writeOperation || ! exactPath (o.writeInput) || ! exactPath (o.targetPath)) error ( `${ d . id }:${ o . id }: incomplete operation/input/path binding` );
275 if ( ! Array. isArray (o.sourcePaths) || o.sourcePaths. some ( p => ! d.sourcePaths?. includes (p))) error ( `${ d . id }:${ o . id }: output inputs must be explicit subsets` );
276 // Applicability is a closed predicate over the decision's own canonical locations (A.2.2).
277 if (o.appliesWhen !== undefined ) {
278 let own = []; try {own = decisionLocations (d);} catch { /* reported above */ }
279 for ( const m of Applicability. validate (o.appliesWhen,own)) error ( `${ d . id }:${ o . id }: ${ m }` );
280 }
281 const key = canonical ([o.targetRef, o.writeOperation, o.writeInput, o.targetPath]);
282 if (writes. has (key) && ! d.transform?.precedence) error ( `${ d . id }: conflicting output with ${ writes . get ( key ) } requires precedence` );
283 writes. set (key, d.id);
284 if ([ 'extended-field' , 'cms' ]. includes (d.disposition) && ! o.storage) error ( `${ d . id }:${ o . id }: custom definition/collection and linkage required` );
285 }
286 for ( const e of d.examples || []) for ( const expected of (Array. isArray (e?.expected) ? e.expected : [])) {
287 // A no-write is explained by the finding the transform reports; the example runner requires it.
288 if (expected?.noWrite === true && ! (Array. isArray (expected.findingCodes) && expected.findingCodes. length )) error ( `${ d . id }: a noWrite expectation names its findingCodes` );
289 if ( ! expected || [Object. hasOwn (expected, 'value' ),Array. isArray (expected.values),expected.omitted === true ,expected.noWrite === true ]. filter (Boolean). length !== 1 || ! (d.outputs || []). some ( o => o?.id === expected.outputId && o.writeOperation === expected.writeOperation)) error ( `${ d . id }: expectation has no output/operation binding` );
290 }
291 if (d.referencePolicy && ( ! d.referencePolicy.cardinality || ! d.referencePolicy.order || ! d.referencePolicy.entityRef || ! d.referencePolicy.input || ! d.referencePolicy.targetPath || ! d.referencePolicy.missing || ! d.referencePolicy.ambiguous || ! d.referencePolicy.orphan)) error ( `${ d . id }: incomplete reference policy` );
292 }
293 if (errors. some ( e => e. includes ( 'malformed' ))) return errors;
294 // Every discovered location, including each observed selector key, needs its own claim.
295 const claims = row.fieldMappings. map ( d => { try { return new Set ( decisionLocations (d));} catch { return new Set ();}});
296 for ( const item of coverage.paths) {
297 const dynamic = item && typeof item === 'object' && item.dynamic === true ;
298 const locations = dynamic ? item.observedKeys. map ( k => L . selected ( L . template (item),k)) : [ loc ( typeof item === 'string' ? item : item.path)];
299 for ( const p of locations) {
300 const claimed = row.fieldMappings. some (( d , i ) => claims[i]. has (p) || credentialSubtree (row,d,p) || (dynamic && credentialSubtree (row,d, loc (item.path))));
301 if ( ! claimed) error (dynamic ? `unclaimed observed dynamic key at ${ L . readable ( p ) }` : `unclaimed discovered path ${ p }` );
302 }
303 }
304 if ( ! errors. length ) {
305 try { projectEntity (row);} catch (e) { error (e.message);}
306 }
307 return errors;
308 }
309 function binding ( o ) { return [o.targetRef, o.writeOperation, o.writeInput, o.targetPath]; }
310 // Identity uses expanded canonical locations, so a legacy {path, selectors} decision and the
311 // same decision written with selector-qualified locations keep one identity.
312 function signature ( d ) {
313 let locations;
314 try {locations = decisionLocations (d). sort ();} catch {locations = [[ ... (d.sourcePaths || [])]. sort (), d.selectors || null ];}
315 return canonical ([locations, (d.outputs || []). map (binding). sort (( a , b ) => canonical (a). localeCompare ( canonical (b)))]);
316 }
317 function assertRemapIdentity ( previous , next ) {
318 const before = new Map ( entityRows (previous). map ( r => [ entityRef (r), r]));
319 for ( const row of entityRows (next)) {
320 const old = before. get ( entityRef (row)); if ( ! old) continue ;
321 const used = new Set ();
322 for ( const d of row.fieldMappings || []) {
323 const matches = (old.fieldMappings || []). filter ( x => x.id && signature (x) === signature (d));
324 if (matches. length > 1 && ! d.reconciles?. length ) fail ( `${ entityRef ( row ) }: ambiguous remap identity` );
325 for ( const prior of matches) {
326 if (used. has (prior.id)) fail ( 'remap duplicates a prior decision' ); used. add (prior.id);
327 if (d.id !== prior.id && ( ! d.supersedes?. includes (prior.id) || gapRows (old). filter ( g => g.decisionId === prior.id). some ( g =>! d.supersedes?. includes (g.id)))) fail ( `${ d . id }: remap must retain IDs or explicitly supersede the prior decision and gaps` );
328 for ( const o of d.outputs || []) {
329 const oldOutput = prior.outputs?. find ( x => canonical ( binding (x)) === canonical ( binding (o)));
330 if (oldOutput && o.id !== oldOutput.id && ! o.supersedes?. includes (oldOutput.id)) fail ( `${ d . id }: output ID changed` );
331 }
332 }
333 }
334 }
335 }
336 function scopedRecordHold ( row , d ) {
337 return d.disposition === 'unresolved' && d.recovery?.scope === 'record' && require ( './mapping-recovery' ). shape (row,d). length === 0 ;
338 }
339 function deriveExecutionScope ( plan , { mode = 'interactive' , provisionalEntityRefs = null , omissionReview = false } = {}) {
340 if (provisionalEntityRefs !== null && ! Array. isArray (provisionalEntityRefs)) fail ( 'human provisional subset must be an explicit array' );
341 const rows = entityRows (plan), selectedEntityRefs = plan.executionScope?.selectedEntityRefs || rows. map (entityRef);
342 const byRef = new Map (rows. map ( r => [ entityRef (r), r]));
343 if ( new Set (selectedEntityRefs).size !== selectedEntityRefs. length || selectedEntityRefs. some ( r => ! byRef. has (r))) fail ( 'selected execution scope is invalid' );
344 const deferred = new Map ();
345 for ( const ref of selectedEntityRefs) {
346 const row = byRef. get (ref);
347 const measuredHolds = (row.fieldMappings || []). filter ( d => plan.recoveryPolicyVersion === 1 && scopedRecordHold (row,d));
348 const heldIds = new Set (measuredHolds. flatMap ( d => d.affectedScope.sourceIds));
349 if (Number. isSafeInteger (row.sourceRecordCount) && row.sourceRecordCount > 0 && heldIds.size === row.sourceRecordCount) {
350 deferred. set (ref,{entityRef:ref,decisionIds:measuredHolds. map ( d => d.id),blockedByEntityRefs:[],reason: 'Every source record has a measured unresolved mapping hold; retain the source and cutover gaps.' });
351 continue ;
352 }
353 const undecided = (row.fieldMappings || []). filter ( d => pendingDecision (row,d) && ! (plan.recoveryPolicyVersion === 1 && scopedRecordHold (row,d)) && ! (omissionReview && row.omissionReviewRequested === true && d.disposition === 'excluded' ));
354 if (row.fieldMappingStatus === 'pending' || undecided. length || (provisionalEntityRefs && ! provisionalEntityRefs. includes (ref))) {
355 deferred. set (ref, { entityRef: ref, decisionIds: row.fieldMappingStatus === 'pending' ? [ 'field-inventory' ] : undecided. map ( d => d.id), blockedByEntityRefs: [], reason: row.fieldMappingStatus === 'pending' ? 'Source field inventory must be inspected by mapper.' : undecided. length ? 'Unresolved mapping or populated omission awaiting the shared approval contract.' : 'Explicitly deferred by human provisional scope.' });
356 }
357 }
358 let changed;
359 do {
360 changed = false ;
361 for ( const ref of selectedEntityRefs) {
362 if (deferred. has (ref)) continue ;
363 const deps = byRef. get (ref).requiredSourceEntityRefs || [];
364 const blockedByEntityRefs = deps. filter ( d => ! selectedEntityRefs. includes (d) || deferred. has (d) && ! dependencyIsPerRecord (byRef. get (ref), d, byRef));
365 const absent = blockedByEntityRefs. filter ( d => ! selectedEntityRefs. includes (d));
366 // Fail closed, but name a likely stale mapper-authored prerequisite before codegen/canary.
367 const reason = absent. length ? `Required source prerequisite ${ absent . join ( ', ' ) } is absent from the mapped scope. requiredSourceEntityRefs lists retained-write prerequisites only; if every use is an omission in this review, it is stale: remove it and re-run contract. Otherwise map it.` : `Required source prerequisite ${ blockedByEntityRefs . join ( ', ' ) } is deferred.` ;
368 if (blockedByEntityRefs. length ) { deferred. set (ref, { entityRef: ref, decisionIds: [], blockedByEntityRefs, reason }); changed = true ; }
369 }
370 } while (changed);
371 if (provisionalEntityRefs && provisionalEntityRefs. some ( ref =>! selectedEntityRefs. includes (ref) || deferred. has (ref))) fail ( 'human provisional subset contains unresolved/unauthorized/dependent work' );
372 if (deferred.size && mode !== 'one_click' && ! provisionalEntityRefs && plan.recoveryPolicyVersion !== 1 ) {
373 for ( const ref of selectedEntityRefs) if ( ! deferred. has (ref)) deferred. set (ref,{entityRef:ref,decisionIds:[],blockedByEntityRefs:[],reason: 'Awaiting the customer mapping checkpoint; no provisional subset was authorized.' });
374 }
375 return { selectedEntityRefs: [ ... selectedEntityRefs], resolvedEntityRefs: selectedEntityRefs. filter ( r => ! deferred. has (r)), deferredEntities: selectedEntityRefs. filter ( r => deferred. has (r)). map ( r => deferred. get (r)),
376 // Resolution is calculated; a provisional subset is an explicit mapper input.
377 ... (provisionalEntityRefs !== null ? {provisionalEntityRefs:[ ... provisionalEntityRefs]} : {}),
378 ... (omissionReview && rows. some ( r =>! deferred. has ( entityRef (r)) && r.omissionReviewRequested && r.fieldMappings?. some ( d => pendingDecision (r,d))) ?
379 {omissionApprovalRequiredEntityRefs:rows. filter ( r =>! deferred. has ( entityRef (r)) && r.omissionReviewRequested && r.fieldMappings?. some ( d => pendingDecision (r,d))). map (entityRef)} : {}) };
380 }
381 function assertExecutionScope ( plan , ref , expectedDigest , projectDir = null ) {
382 if (plan.fieldContractVersion !== 1 ) fail ( 'revised mapping must be explicitly reconciled by mapper' );
383 if ( ! expectedDigest || digest (plan) !== expectedDigest) fail ( 'mapping scope binding missing or stale' );
384 const scope = plan.executionScope;
385 let scopeConflict = null ;
386 if (scope) {
387 // Validate an explicitly provisional subset as authored. For ordinary exact
388 // plans, retain that same closed-scope check so forged resolved rows still
389 // reach the unauthorized-decision guard below. A mixed plan with a deferred
390 // per-record prerequisite is the one exception: the resolved subset is not a
391 // valid provisional input because it removes the prerequisite from the graph.
392 let calculated;
393 try {
394 calculated = deriveExecutionScope (plan,{mode: 'one_click' ,
395 provisionalEntityRefs:scope.provisionalEntityRefs ?? scope.resolvedEntityRefs,
396 omissionReview: true });
397 } catch (error) {
398 const byRef =new Map ( entityRows (plan). map ( row => [ entityRef (row),row]));
399 const deferredRefs =new Set ((scope.deferredEntities || []). map ( row => row.entityRef));
400 const perRecordDependent = scope.provisionalEntityRefs === undefined && entityRows (plan). some ( row =>
401 scope.resolvedEntityRefs?. includes ( entityRef (row)) && (row.requiredSourceEntityRefs || []). some ( dep =>
402 deferredRefs. has (dep) && dependencyIsPerRecord (row,dep,byRef)));
403 if (perRecordDependent && /human provisional subset contains/ . test (error.message)) {
404 calculated = deriveExecutionScope (plan,{mode: 'one_click' ,omissionReview: true });
405 } else if (scope.provisionalEntityRefs !== undefined ) throw error;
406 else scopeConflict = error;
407 }
408 if (calculated && ( canonical (calculated.resolvedEntityRefs) !== canonical (scope.resolvedEntityRefs) ||
409 new Set (scope.resolvedEntityRefs).size !== scope.resolvedEntityRefs. length )) scopeConflict =new Error ( 'resolved scope conflicts with decision/dependency closure' );
410 }
411 if ( ! scope || ! scope.selectedEntityRefs?. includes (ref) || ! scope.resolvedEntityRefs?. includes (ref) ||
412 ( ! scopeConflict && scope.deferredEntities?. some ( d => d.entityRef === ref))) fail ( `${ ref }: outside resolved execution scope` );
413 const row = entityRows (plan). find ( r => entityRef (r) === ref);
414 const errors = row ? validateEntity (row) : [ 'entity absent' ];
415 if (errors. length ) fail (errors. join ( '; ' ));
416 if (plan.recoveryGapRefs?. length || row.fieldMappings. some ( d => d.recovery)) require ( './mapping-recovery' ). assertPersisted (projectDir, plan, [ref]);
417 if (row.fieldMappingStatus === 'pending' ) fail ( 'field inventory is pending mapping' );
418 if (row.targetClassification === 'manual-mapping' ) fail ( 'manual mapping has no import writer' );
419 if (row.fieldMappings. some ( d => pendingDecision (row,d) && ! (plan.recoveryPolicyVersion === 1 && scopedRecordHold (row,d)) && ! (row.omissionReviewRequested === true && d.disposition === 'excluded' ))) fail ( 'unresolved decisions/unauthorized omissions cannot write' );
420 if (scopeConflict) fail ( 'resolved scope conflicts with decision/dependency closure' );
421 const conditional = entityRows (plan). filter ( r => scope.resolvedEntityRefs. includes ( entityRef (r)) && r.omissionReviewRequested && r.fieldMappings?. some ( d => pendingDecision (r,d)));
422 if (conditional. length ) require ( './mapping-omission-approval' ). assertAuthorized (projectDir,plan,conditional);
423 }
424 function dependencyIsPerRecord ( row , dependencyRef , byRef ) {
425 const dependency = byRef. get (dependencyRef);
426 if ( ! dependency?.targetRef || ! row?.targetRef) return false ;
427 try {
428 const knowledge = require ( './dependency-proofs' ). domain (row.targetRef);
429 return knowledge.dependencyModes?.[dependency.targetRef] === 'per-record' ;
430 } catch {
431 return false ;
432 }
433 }
434 function deferredRunResult ( plan ) {
435 if ( ! plan.executionScope?.deferredEntities?. length ) return null ;
436 // A deferred scope is a cutover gap, not a run-wide stop, when another executable
437 // entity remains. The generated importer registers only resolved entities; its
438 // dependency-readiness gate then applies per-record edges to dependents. Keep the
439 // typed blocked result for an all-deferred plan, where no importer can start.
440 const runnable = (plan.executionScope.resolvedEntityRefs || []). filter ( ref => {
441 const row = entityRows (plan). find ( candidate => entityRef (candidate) === ref);
442 return row && row.targetClassification !== 'manual-mapping' ;
443 });
444 if (runnable. length ) return null ;
445 return { outcome: 'blocked' , blocker: { type: 'mapping_scope_deferred' , artifact: 'mapping/mapping-plan.json' ,
446 deferredEntities: plan.executionScope.deferredEntities, requiredInput: 'Resolve these field decisions in the next mapping session.' } };
447 }
448 function deferredMappingActions ( plan ) {
449 return (plan?.executionScope?.deferredEntities || []). map ( deferred => ({
450 code: 'mapping-scope-deferred' ,
451 severity: 'high' ,
452 entity: deferred.entityRef,
453 affectedCount: null ,
454 deadlineReason: 'Resolve before launch; this mapped entity remains outside the executable import scope.' ,
455 action: `Requires Import Operator. Resolve the deferred mapping for ${ deferred . entityRef } in the original migration project, regenerate and re-approve the exact mapping, then resume only that entity. Independent entities continue importing; do not invent source data or resend landed records.` ,
456 }));
457 }
458 // A row's scope is the measured membership of that exact location: a contributor's
459 // per-location affectedScopes entry, else its decision scope. Known measurements must agree;
460 // a contributor without a measurement (unknown) never replaces a known one.
461 function contributingScope ( row , decisions , location , at = null ) {
462 const scopes = decisions. map ( d => affectedScope (at !== null && d.affectedScopes && Object. hasOwn (d.affectedScopes,at) ? d.affectedScopes[at] : d.affectedScope,row.discoveryCoverage));
463 const known = scopes. filter ( s => s.countStatus === 'known' );
464 const scope = known[ 0 ] || scopes[ 0 ] || affectedScope ( null ,row.discoveryCoverage);
465 if (known. some ( s => canonical (s) !== canonical (scope)) || ( ! known. length && scopes. some ( s => canonical (s) !== canonical (scope)))) fail ( `${ entityRef ( row ) }: conflicting affected scopes at ${ location }; reconcile contributing decisions ${ decisions . map ( d => d . id ). join ( ', ' ) }` );
466 return scope;
467 }
468 function projectEntity ( row ) {
469 if (row.fieldMappingStatus === 'pending' ) return { ... row,fieldDispositions:[]};
470 if (row.targetClassification === 'manual-mapping' ) return { ... row,fieldDispositions:row.fieldDispositions || []};
471 // One coverage row per canonical location; selector-qualified keys never share a row.
472 const paths = new Set ( coverageLocations (row));
473 const claims = new Map ((row.fieldMappings || []). map ( d => [d, new Set ( decisionLocations (d))]));
474 for ( const set of claims. values ()) for ( const p of set) paths. add (p);
475 const dispositions = [ ... paths]. sort (). map ( p => {
476 const ds = row.fieldMappings. filter ( d => claims. get (d). has (p) || credentialSubtree (row,d,p));
477 return { kind: 'source' , path: p, status: ds. some ( d => pendingDecision (row,d) && ! scopedRecordHold (row,d) && ! (row.omissionReviewRequested && d.disposition === 'excluded' )) ? 'pending' : ds. some ( d => d.disposition === 'deferred-field' || scopedRecordHold (row,d)) ? 'deferred' : ds. some ( d => d.outputs?. length ) ? 'mapped' : 'notMigrated' ,
478 decisionRefs: ds. map ( d => d.id). sort (), mappingRefs: ds. flatMap ( d => (d.outputs || []). filter ( o => outputLocations (d,o). includes (p)). map ( o => `${ d . id }:${ o . id }` )). sort (),
479 reason: ds. map ( d => d.reason). join ( '; ' ), affectedScope: contributingScope (row,ds, `source ${ p }` ,p) };
480 });
481 const intended = new Map ();
482 for ( const d of row.fieldMappings) for ( const o of d.outputs || []) {
483 const key = canonical ([o.writeOperation,o.writeInput,o.targetPath]);
484 const group = intended. get (key) || {decisions:[],item:{kind: 'intended' ,path: L . join (o.writeInput,o.targetPath),writeOperation:o.writeOperation,
485 status: 'mapped' ,decisionRefs:[],mappingRefs:[],reason:d.reason}};
486 group.decisions. push (d);group.item.decisionRefs. push (d.id);group.item.mappingRefs. push ( `${ d . id }:${ o . id }` );intended. set (key,group);
487 }
488 for ( const d of row.fieldMappings) if (d.disposition === 'excluded' ) for ( const o of d.omittedOutputs || []) {
489 const key = canonical ([o.writeOperation,o.writeInput,o.targetPath]);
490 if (intended. has (key)) fail ( 'omitted intended output conflicts with a written output' );
491 intended. set (key,{decisions:[d],item:{kind: 'intended' ,path: L . join (o.writeInput,o.targetPath),writeOperation:o.writeOperation,
492 status:row.omissionReviewRequested ? 'notMigrated' : 'pending' ,decisionRefs:[d.id],mappingRefs:[],reason:d.reason}});
493 }
494 const intendedRows = [ ... intended. values ()]. map (({ decisions , item }) => ({ ... item,affectedScope: contributingScope (row,decisions, `intended ${ item . writeOperation }:${ item . path }` )}));
495 for ( const d of row.fieldMappings. filter ( d => d.disposition === 'deferred-field' )) for ( const p of d.recovery.createWithoutField.paths) {
496 const c = d.recovery.createWithoutField, full = L . join (c.writeInput, p);
497 if (intendedRows. some ( r => r.path === full)) fail ( 'deferred intended output conflicts with a written output' );
498 intendedRows. push ({kind: 'intended' , path:full, writeOperation:c.operation, status: 'deferred' , decisionRefs:[d.id], mappingRefs:[], reason:d.reason, affectedScope: affectedScope (d.affectedScope,row.discoveryCoverage)});
499 }
500 const verificationMappings = row.fieldMappings. flatMap ( d => (d.outputs || []). map ( o => ({
501 canonicalField: `${ d . id }:${ o . id }` , decisionRef: d.id,
502 sourcePaths: outputLocations (d,o), ... (d.selectors ? {selectors:d.selectors} : {}),
503 targetRef:o.targetRef, writeOperation:o.writeOperation, writeInput:o.writeInput,
504 writePath: L . join (o.writeInput,o.targetPath),
505 ... (o.appliesWhen !== undefined ? {appliesWhen:o.appliesWhen} : {}),
506 ... Object. fromEntries ([ 'readPath' , 'unverifiableReason' , 'comparatorId' , 'provenance' ]. filter ( key => o.verification?.[key] !== undefined ). map ( key => [key,o.verification[key]]))
507 })));
508 return { ... row, verificationMappings, fieldDispositions: [ ... dispositions, ... intendedRows. sort (( a , b ) => canonical (a). localeCompare ( canonical (b)))] };
509 }
510 function gapRows ( row ) {
511 const ref = entityRef (row), gaps = [];
512 const decisions = row.fieldMappingStatus === 'pending' ? [{id: 'field-inventory' ,sourcePaths:[],disposition: 'unresolved' ,reason: 'Source field inventory requires explicit mapper decisions.' ,evidence:row.discoveryCoverage.evidence,nextAction:{role: 'Import Operator' ,cta: 'Inspect the source inventory and resolve exact field mappings.' }}] : row.fieldMappings || [];
513 for ( const d of decisions) {
514 const kinds = [ 'unresolved' , 'deferred-field' ]. includes (d.disposition) ? [ 'unresolved' ] : d.disposition === 'excluded' ? [ 'excluded' ] : [ 'extended-field' , 'cms' ]. includes (d.disposition) ? [ 'no-native-target' ] : [];
515 if (d.lossy) kinds. push ( 'lossy' ); if (d.setupBlocked) kinds. push ( 'setup-blocked' );
516 for ( const kind of kinds) gaps. push ({ id: `field-gap:${ digest ([ ref , d . id , kind ]) }` , gapKind: kind, code: `FIELD_MAPPING_${ kind . toUpperCase (). replace ( /-/ g , '_' ) }` ,
517 entityName: ref, gapType: 'field-mapping' , description: d.reason, recommendedHandling: d.nextAction?.cta || d.reason,
518 severity: [ 'unresolved' , 'setup-blocked' ]. includes (kind) || pendingDecision (row,d) ? 'blocker' : d.disposition === 'excluded' && ( mandatoryExclusion (row,d) || carriedToCutoverExclusion (row,d)) ? 'info' : 'warning' ,
519 sourceEntity: ref, decisionId: d.id, sourcePaths: d.sourcePaths, targetRef: d.outputs?.[ 0 ]?.targetRef || d.recovery?.createWithoutField?.targetRef || (d.recovery ? row.targetRef : null ) || null , writeOperation: d.outputs?.[ 0 ]?.writeOperation || d.recovery?.createWithoutField?.operation || null ,
520 disposition: d.disposition, storage: (d.outputs || []). map ( o => ({ outputId: o.id, targetRef:o.targetRef, writeOperation:o.writeOperation, writeInput:o.writeInput, targetPath:o.targetPath, ... (o.storage ? { definition:o.storage } : {}) })),
521 limitation: d.limitation || { status: 'unknown' }, searchEvidence: d.searchEvidence || [], knowledgeEvidence: d.evidence,
522 nativeBehaviorMissing: d.nativeBehaviorMissing || [], preservationStatus: d.outputs?. length ? 'planned' : 'unknown' ,
523 affectedScope: affectedScope (d.affectedScope, row.discoveryCoverage), nextAction: d.nextAction || null , resolutionEvidence: d.resolutionEvidence || [],
524 supersedes: d.supersedes?. filter ( x => x. startsWith ( 'field-gap:' )) || [], ... (d.recovery ? {recovery: structuredClone (d.recovery)} : {}) });
525 }
526 return gaps;
527 }
528 function telemetryForGap ( gap ) {
529 const events = [], add = ( event_type , subtype , extra = {}) => events. push ({ event_type, subtype,
530 stage: 'mapping' ,skill: 'rp-mapper' ,severity:gap.severity === 'blocker' ? 'blocking' : 'degraded' ,
531 what_happened: `Field mapping ${ gap . gapKind }; preservation ${ gap . preservationStatus }; affected count ${ gap . affectedScope . countStatus }.` ,
532 evidence_refs:[{artifact: 'mapping/review/mapping-gaps.json' ,locator:gap.id}], ... extra });
533 if (gap.gapKind === 'excluded' && gap.affectedScope.countStatus === 'known' && gap.affectedScope.count > 0 ) add ( 'fidelity_loss' , 'dropped_field' );
534 if (gap.gapKind === 'lossy' ) add ( 'fidelity_loss' , 'coerced_value' );
535 if (gap.gapKind === 'no-native-target' && gap.nativeBehaviorMissing. length ) add ( 'fidelity_loss' , 'no_target' );
536 if (gap.gapKind === 'unresolved' ) add ( 'skill_coverage_gap' , gap.limitation.status === 'conflicting-knowledge' ? 'ambiguous_instruction' : 'path_not_covered' );
537 if (gap.limitation.status === 'missing-writer' ) add ( 'skill_coverage_gap' , 'path_not_covered' );
538 if ([ 'missing_capability' , 'missing_api' , 'internal_only' ]. includes (gap.limitation.status) && gap.limitation.wix_api_surface && gap.limitation.signature) {
539 add ( 'api_gap' ,gap.limitation.status,{wix_api_surface:gap.limitation.wix_api_surface,actual: canonical ({signature:gap.limitation.signature})});
540 }
541 return [ ...new Map (events. map ( e => [ canonical ([e.event_type,e.subtype,e.wix_api_surface]),e])). values ()];
542 }
543 function atomicJson ( file , value ) {
544 fs. mkdirSync (path. dirname (file), {recursive: true }); const staging = `${ file }.writing` ;
545 fs. writeFileSync (staging, `${ JSON . stringify ( value , null , 2 ) } \n ` ); fs. renameSync (staging,file);
546 }
547 function readOptional ( file , fallback ) { return fs. existsSync (file) ? JSON . parse (fs. readFileSync (file, 'utf8' )) : fallback; }
548 function fieldSection ( rows ) {
549 const safe = s => String (s ?? '' ). replace ( / \| / g , ' \\ |' ). replace ( / \r ? \n / g , ' ' );
550 return [ '## Exact field mappings' , '' , '| Entity | Decision | Source paths | Outcome | Operation / input / path |' , '| --- | --- | --- | --- | --- |' ,
551 ... rows. flatMap ( r => (r.fieldMappings || []). map ( d => `| ${ safe ( entityRef ( r )) } | ${ safe ( d . id ) } | ${ safe ( decisionLocations ( d ). map ( L . readable ). join ( ', ' )) } | ${ d . disposition } | ${ safe ([ ... ( d . outputs || []). map ( o => `${ o . writeOperation } / ${ o . writeInput } / ${ o . targetPath }${ o . appliesWhen ? ` when ${ Applicability . readable ( o . appliesWhen ) }` : ''}` ), ... ( d . omittedOutputs || []). map ( o => `omit ${ o . writeOperation } / ${ o . writeInput } / ${ o . targetPath }` )]. join ( '; ' )) } |` )),
552 '' , '### Mapping gaps and consequences' , '' ,
553 '| Entity | Source paths | Handling | Affected records | Reason / missing behavior | Next action |' ,
554 '| --- | --- | --- | --- | --- | --- |' ,
555 ... rows. flatMap (gapRows). map ( g => `| ${ safe ( g . sourceEntity ) } | ${ safe ( g . sourcePaths . join ( ', ' )) } | ${ g . gapKind } | ${ g . affectedScope . countStatus === 'known' ? g . affectedScope . count : 'unknown'} | ${ safe ([ g . description , ... g . nativeBehaviorMissing ]. join ( '; ' )) } | ${ safe ( g . nextAction ?. cta || g . recommendedHandling ) } |` ),
556 ... omissionReviewSection (rows,safe)
557 ]. join ( ' \n ' );
558 }
559 // Plain-text omission review: one line per selector-qualified location with its own measured
560 // count, plus each contributing decision's union count. Source values are never shown.
561 function omissionReviewSection ( rows , safe ) {
562 const count = s => s?.countStatus === 'known' ? String (s.count) : 'unknown' ;
563 const lines = rows. flatMap ( r => {
564 const byId =new Map ((r.fieldMappings || []). map ( d => [d.id,d]));
565 return (r.fieldDispositions || []). filter ( d => d.status !== 'mapped' ). map ( d => `| ${ safe ( entityRef ( r )) } | ${ safe ( d . kind === 'intended' ? `${ d . writeOperation }: ${ d . path }` : L . readable ( d . path )) } | ${ d . status } | ${ count ( d . affectedScope ) } | ${ safe ( d . decisionRefs . map ( id => `${ id } (${ count ( affectedScope ( byId . get ( id )?. affectedScope , r . discoveryCoverage )) } records across its locations)` ). join ( '; ' )) } |` );
566 });
567 if ( ! lines. length ) return [];
568 return [ '' , '### Not migrated or pending, by source location' , '' ,
569 'Each location is counted separately; a record can appear under several locations. The decision count is the union across that decision \' s locations.' , '' ,
570 '| Entity | Location | Status | Records at this location | Decision (records across its locations) |' , '| --- | --- | --- | --- | --- |' , ... lines];
571 }
572 function replaceSection ( text , section ) {
573 const start = '<!-- exact-field-mapping:start -->' , end = '<!-- exact-field-mapping:end -->' ;
574 const block = `${ start } \n ${ section } \n ${ end }` ;
575 if (text. includes (start) && text. includes (end)) return text. slice ( 0 ,text. indexOf (start)) + block + text. slice (text. indexOf (end) + end. length );
576 return `${ text . trimEnd () } \n\n ${ block } \n ` ;
577 }
578 // No importer exists for a plan without any resolved automatic scope. Keep the explicit
579 // unavailable result in existing mapping reports and project it into the existing handoff.
580 function mappingReviewFallback ( plan , ledger ) {
581 if (plan?.fieldContractVersion !== 1 || ! plan.executionScope?.selectedEntityRefs?. length || ! Array. isArray (plan.executionScope.resolvedEntityRefs)) return null ;
582 const { selectedEntityRefs , resolvedEntityRefs , deferredEntities } = plan.executionScope;
583 if ( ! Array. isArray (deferredEntities) || selectedEntityRefs. some ( ref => resolvedEntityRefs. includes (ref) === deferredEntities. some ( d => d.entityRef === ref))) return null ;
584 if (resolvedEntityRefs. some ( ref => entityRows (plan). find ( row => entityRef (row) === ref)?.targetClassification !== 'manual-mapping' )) return null ;
585 return {status: 'unavailable' ,reason: 'no-importer-execution' ,mappingPlanDigest: digest (plan),gapLedgerDigest: digest (ledger),
586 artifacts:[ 'mapping/mapping-plan.json' , 'mapping/review/mapping-gaps.json' , 'mapping/review/mapping-summary.md' , 'mapping/review/mapping-plan.md' ],
587 deferredEntities:plan.executionScope.deferredEntities || [],
588 requiredInput: 'Requires Import Operator: resolve deferred field decisions or follow the retained manual runbooks; recapture and publish under the next actual importer execution.' };
589 }
590 function persistMappingReviewFallback ( projectDir , publication ) {
591 const start = '<!-- mapping-review-publication:start -->' , end = '<!-- mapping-review-publication:end -->' ;
592 const block = publication ? `${ start } \n ## Migration review publication \n\n Current-run Migration review publication is unavailable. No importer execution or published Data tasks are claimed. \n\n\`\`\` json \n ${ JSON . stringify ({ reviewPublication:publication }, null , 2 ) } \n\`\`\`\n ${ end }` : '' ;
593 for ( const name of [ 'mapping-summary.md' , 'mapping-plan.md' ]) {
594 const file = path. join (projectDir, 'mapping/review' ,name);
595 let text = fs. existsSync (file) ? fs. readFileSync (file, 'utf8' ) : '' ;
596 if (text. includes (start) && text. includes (end)) text = text. slice ( 0 ,text. indexOf (start)) + text. slice (text. indexOf (end) + end. length );
597 const next = `${ text . trimEnd () }${ block ? ` \n\n ${ block }` : ''} \n ` ;
598 if (next !== text) {fs. mkdirSync (path. dirname (file),{recursive: true }); const staging = `${ file }.writing` ;fs. writeFileSync (staging,next);fs. renameSync (staging,file);}
599 }
600 }
601 function compareMappingProjections ( projectDir , plan ) {
602 const errors = [];
603 for ( const row of entityRows (plan)) {
604 const invalid = validateEntity (row);
605 if (invalid. length ) {errors. push ( ... invalid); continue ;}
606 const expected = projectEntity (row);
607 if ( canonical (row.fieldDispositions || []) !== canonical (expected.fieldDispositions)) errors. push ( `${ entityRef ( row ) }: authority dispositions conflict with field decisions` );
608 const actual = readOptional (path. join (projectDir, 'mapping/entity-decisions' , entityFileName (row)), null );
609 if ( ! actual || canonical (actual) !== canonical ({ ... expected,executionScope:plan.executionScope})) errors. push ( `${ entityRef ( row ) }: missing/conflicting entity projection` );
610 }
611 if ( ! errors. length && plan?.fieldContractVersion === 1 ) errors. push ( ... require ( './mapping-resolution' ). compare (projectDir,plan).errors);
612 return {ok:errors. length === 0 ,errors};
613 }
614 async function writeMappingAuthority ( projectDir , next , { provisionalEntityRefs = null , recordTelemetry = null , omissionReviewEntityRefs = null } = {}) {
615 const mode = mappingRunMode (projectDir);
616 const planFile = path. join (projectDir, 'mapping/mapping-plan.json' ), previous = readOptional (planFile,{});
617 const approval = readOptional (path. join (projectDir, 'orchestration/approvals.json' ),{}).mapping;
618 if ([ 'approved' , 'provisional' ]. includes (approval?.status)) fail ( 'reopen the existing mapping checkpoint before replacing authority' );
619 if (next.fieldContractVersion !== 1 ) fail ( 'new/revised plan must declare fieldContractVersion:1; legacy decisions are not upgraded automatically' );
620 if (omissionReviewEntityRefs !== null && ! Array. isArray (omissionReviewEntityRefs)) fail ( 'omission review entities must be an array' );
621 if (omissionReviewEntityRefs && require ( './mapping-omission-approval' ). mode ( readOptional (path. join (projectDir, 'orchestration/decisions.json' ),{})) === 'one_click' ) fail ( 'omission review requires interactive mode' );
622 const candidate = structuredClone (next);
623 const rows = entityRows (candidate). map ( row => {
624 const legacy = readOptional (path. join (projectDir, 'mapping/entity-decisions' , entityFileName (row)),{});
625 delete legacy.executionScope;
626 const merged = { ... legacy, ... row};
627 // A proposal is a per-call choice, never inherited from either authority projection.
628 delete merged.omissionReviewRequested;
629 if (omissionReviewEntityRefs?. includes ( entityRef (row))) merged.omissionReviewRequested = true ;
630 return merged;
631 });
632 const errors = rows. flatMap (validateEntity);
633 if ( ! rows. length || errors. length ) fail (errors. join ( '; ' ) || 'empty authority' );
634 require ( './operation-assessments' ). validate (candidate, projectDir);
635 require ( './operation-assessments' ). withPlan (candidate,projectDir,() => require ( './mapping-target-rules' ). assertKnownMappings (candidate));
636 for ( const row of rows) for ( const d of row.fieldMappings || []) if (d.recovery) require ( './mapping-recovery' ). validate (projectDir, row, d);
637 assertRemapIdentity (previous,next);
638 const slugs = rows. map (entityFileName);
639 if ( new Set (slugs).size !== slugs. length ) fail ( 'entity filenames collide; reconcile source identities' );
640 const projected = rows. map (projectEntity);
641 const plan = candidate;
642 // Keep the producer's existing container vocabulary, including object-keyed entities.
643 for ( const key of [ 'entities' , 'entityDecisions' , 'entityMappings' , 'mappings' ]) {
644 if (Array. isArray (plan[key])) { plan[key] = projected; break ; }
645 if (key === 'entities' && plan[key] && typeof plan[key] === 'object' ) { plan[key] = Object. fromEntries (Object. entries (plan[key]). map (([ k , row ]) => [k,projected. find ( r => entityRef (r) === entityRef ({sourceEntity:k, ... row}))])); break ; }
646 }
647 if ( ! plan.executionScope && previous.executionScope) plan.executionScope = {selectedEntityRefs:previous.executionScope.selectedEntityRefs};
648 plan.executionScope = deriveExecutionScope (plan,{mode,provisionalEntityRefs,omissionReview:mode !== 'one_click' });
649 const gapFile = path. join (projectDir, 'mapping/review/mapping-gaps.json' ), existing = readOptional (gapFile,[]);
650 const previousGaps = Array. isArray (existing) ? existing : existing.gaps || [], byId =new Map (previousGaps. filter ( g => g.id). map ( g => [g.id,g]));
651 const activeIds =new Set (projected. flatMap (gapRows). map ( g => g.id));
652 for ( const row of projected) for ( const gap of gapRows (row)) {
653 gap.supersedes = [ ...new Set ([ ... gap.supersedes, ... previousGaps. filter ( p => p.gapType === 'field-mapping' && p.sourceEntity === gap.sourceEntity && p.decisionId === gap.decisionId && p.gapKind !== gap.gapKind && ! activeIds. has (p.id)). map ( p => p.id)])]. sort ();
654 const prior = byId. get (gap.id); if (prior && prior.preservationStatus !== 'planned' && canonical (prior.storage) === canonical (gap.storage) && canonical (prior.affectedScope) === canonical (gap.affectedScope)) { gap.preservationStatus = prior.preservationStatus; gap.resolutionEvidence = prior.resolutionEvidence; }
655 byId. set (gap.id,gap);
656 }
657 const gaps = [ ... previousGaps. filter ( g =>! g.id), ... byId. values ()];
658 const recoveryRefs = new Map ((previous.recoveryGapRefs || []). map ( r => [r.id,r]));
659 for ( const g of gaps. filter ( g => g.recovery)) recoveryRefs. set (g.id,{id:g.id,sourceEntity:g.sourceEntity,
660 recoveryDigest: require ( './operation-assessments' ). digest ([g.recovery,g.affectedScope])});
661 if (recoveryRefs.size) plan.recoveryGapRefs = [ ... recoveryRefs. values ()];
662 // The approval-bound resolution is a projection of this authority (any source); a CSV
663 // resolver seed is retained. Computed before persistence so a failure changes nothing.
664 const resolutionLib = require ( './mapping-resolution' );
665 const seed = resolutionLib. seedOf ( readOptional (path. join (projectDir,resolutionLib. FILE ), null ));
666 if (seed?.provenance?.tampered) fail ( 'mapping resolution seed was edited after projection; rerun the resolver, then the mapping contract' );
667 const resolution = resolutionLib. project (plan,{seed});
668 // Commit authority first. A crash during projections is recovered by re-running from it.
669 atomicJson (planFile,plan);
670 for ( const row of projected) {
671 const file = path. join (projectDir, 'mapping/entity-decisions' , entityFileName (row));
672 atomicJson (file,{ ... row,executionScope:plan.executionScope});
673 }
674 atomicJson (gapFile,Array. isArray (existing) ? gaps : { ... existing,gaps});
675 atomicJson (path. join (projectDir,resolutionLib. FILE ),resolution);
676 const section = fieldSection (projected) + ` \n\n Execution scope: ${ canonical ( plan . executionScope ) } \n\n Field gaps: ${ gaps . filter ( g => g . gapType === 'field-mapping' ). map ( g => `${ g . id } (${ g . preservationStatus }; ${ g . affectedScope . countStatus } affected count)` ). join ( '; ' ) || 'none'}. \n ` ;
677 for ( const name of [ 'mapping-plan.md' , 'mapping-summary.md' ]) {
678 const file = path. join (projectDir, `mapping/review/${ name }` ); fs. mkdirSync (path. dirname (file),{recursive: true });
679 fs. writeFileSync (file, replaceSection (fs. existsSync (file) ? fs. readFileSync (file, 'utf8' ) : '' ,section));
680 }
681 persistMappingReviewFallback (projectDir, mappingReviewFallback (plan,Array. isArray (existing) ? gaps : { ... existing,gaps}));
682 const telemetryFailures = [];
683 const record = recordTelemetry || ( event => require ( '../resources/rp-telemetry/lib/telemetry-recorder.js' ). record (projectDir,event));
684 const observations = require ( '../resources/rp-telemetry/lib/knowledge-observation' );
685 const toolkitVersion = require ( '../resources/rp-telemetry/lib/telemetry-recorder' ). resolveSkillsVersion () || 'unknown' ;
686 for ( const assessment of plan.operationAssessments || []) {
687 if ((previous.operationAssessments || []). some ( a => digest (a) === digest (assessment))) continue ;
688 try { await record (observations. event ({findingClass: 'missing-knowledge' ,targetRef:assessment.targetRef,operation:assessment.operation,
689 toolkitVersion,outcome: 'resolved-mapping' ,verificationLevel: 'mapping-examples' ,affectedCount: null })); }
690 catch (e) { telemetryFailures. push ({reason:e.code || 'telemetry_failed' }); }
691 }
692 for ( const row of projected) for ( const d of row.fieldMappings || []) if (d.knowledgeFinding && d.outputs?. length ) {
693 const prior = entityRows (previous). find ( r => entityRef (r) === entityRef (row))?.fieldMappings?. find ( p => p.id === d.id);
694 if (prior && digest (prior) === digest (d)) continue ;
695 for ( const output of [ ...new Map (d.outputs. map ( o => [ canonical ([o.targetRef,o.writeOperation]),o])). values ()]) {
696 try { await record (observations. event ({findingClass:d.knowledgeFinding,targetRef:output.targetRef,operation:output.writeOperation,
697 toolkitVersion,outcome: 'resolved-mapping' ,verificationLevel: 'mapping-examples' ,affectedCount:d.affectedScope?.countStatus === 'known' ? d.affectedScope.count : null }));}
698 catch (e) {telemetryFailures. push ({decisionId:d.id,reason:e.code || 'telemetry_failed' });}
699 }
700 }
701 for ( const g of gaps. filter ( g => g.gapType === 'field-mapping' )) {
702 if (previousGaps. some ( p => p.id === g.id && digest (p) === digest (g))) continue ;
703 for ( const event of telemetryForGap (g)) { try { await record (event); } catch (e) {telemetryFailures. push ({gapId:g.id,reason:e.code || 'telemetry_failed' });} }
704 if (g.gapKind === 'unresolved' ) {
705 const findingClass = [ 'missing-writer' , 'source-ambiguity' , 'conflicting-knowledge' , 'target-limitation' ]. includes (g.limitation?.status) ? g.limitation.status : 'missing-knowledge' ;
706 try { await record (observations. event ({findingClass,targetRef:g.recovery?.createWithoutField?.targetRef || g.targetRef,
707 operation:g.recovery?.createWithoutField?.operation || g.writeOperation,toolkitVersion,
708 outcome:g.disposition === 'deferred-field' ? 'deferred-field' : 'deferred-record' ,verificationLevel:g.recovery ? 'schema-reviewed' : 'not-verified' ,
709 affectedCount:g.affectedScope.countStatus === 'known' ? g.affectedScope.count : null })); }
710 catch (e) {telemetryFailures. push ({gapId:g.id,reason:e.code || 'telemetry_failed' });}
711 }
712 }
713 return { plan,gaps,telemetryFailures,blockedResult:mode === 'one_click' ? deferredRunResult (plan) : null };
714 }
715 module . exports = {measuredZeroExclusion,mandatoryExclusion,carriedToCutoverExclusion,CARRIED_POLICY,pendingDecision,mappingRunMode,GAP_KEYS,canonical,digest,entityRef,entityKeyMatches,entityFileName,entityRows,entityDecisionFileName,validateEntity,affectedScope,assertRemapIdentity,
716 deriveExecutionScope,assertExecutionScope,deferredRunResult,deferredMappingActions,mappingReviewFallback,persistMappingReviewFallback,projectEntity,gapRows,telemetryForGap,compareMappingProjections,writeMappingAuthority,
717 exactPath,decisionLocations,outputLocations,coverageLocations};