Setting the file. One moment.
Source Coverage · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page 240
function sampledCount
— line 240
This file
Number 45.148
Position 148 of 182
Type JavaScript
Size 54 KB
Lines 1,130 lib/ source-coverage.js
JavaScript · 1,130 lines · 54 KB
15 // rp-mapper -> mapping/review/mapping-gaps.json rows carrying `coverageId`, or a
16 // `sourceCoverage:<coverageId>` decision in orchestration/decisions.json
17 // reconcile / summarize -> every row has exactly one valid disposition, or the run refuses
18 // writeCompletionReport -> sourceCoverage[], status floor, headline, operatorActions
19 //
20 // Everything here is deterministic set/lookup work over adapter declarations and coverage rows.
21 // No model calls, no network, no per-record passes.
22
23 const fs = require ( 'node:fs/promises' );
24 const path = require ( 'node:path' );
25
26 const SOURCE_COVERAGE_SCHEMA_VERSION = 1 ;
27 const SOURCE_COVERAGE_CONTRACT = 1 ;
28
29 // Every bundled adapter must emit a coverage file. An adapter outside this set cannot promote
30 // discovery until it is added; there is no unstamped path for it.
31 const COVERAGE_EMITTERS = Object. freeze ([ 'wordpress' , 'csv' , 'shopify' , 'quick-woocommerce' , 'quick-shopify' ]);
32 const QUICK_ADAPTERS = Object. freeze ([ 'quick-woocommerce' , 'quick-shopify' ]);
33
34 // Where each full adapter writes its raw capture; promotion reads the adapter file from here.
35 const ADAPTER_RAW_DIRS = Object. freeze ({
36 wordpress: 'data/wp-discovery' ,
37 csv: 'data/csv-discovery' ,
38 shopify: 'data/shopify-discovery' ,
39 });
40
41 const SOURCE_COVERAGE_FILE = 'discovery/source-coverage.json' ;
42 const ADAPTER_COVERAGE_FILE_NAME = 'source-coverage.json' ;
43 const SOURCE_SCHEMA_FILE = 'source-schema.json' ;
44 const QUICK_PLAN_FILE = 'quick-mode/plan.json' ;
45 const MAPPING_GAPS_FILE = 'mapping/review/mapping-gaps.json' ;
46 const DECISIONS_FILE = 'orchestration/decisions.json' ;
47 const APPROVALS_FILE = 'orchestration/approvals.json' ;
48
49 const COVERAGE_REASONS = Object. freeze ([
50 'auth-gated' ,
51 'route-unsupported' ,
52 'route-skipped-by-rule' ,
53 'capture-failed' ,
54 'adapter-excluded' ,
55 'declared-not-observed' ,
56 ]);
57 const RECOVERABILITY = Object. freeze ([ 'import-operator' , 'requires-development' , 'not-recoverable' ]);
58 const GATES = Object. freeze ([ 'exclusion' , 'loss-accepted' ]);
59 const DECISION_VALUES = Object. freeze ([ 'provided' , 'declined' ]);
60 const DECISION_PREFIX = 'sourceCoverage:' ;
61 // Rows whose open state owes the Cutover Operator an urgent action: the source still holds the
62 // only copy and nobody decided to leave it behind.
63 const URGENT_REASONS = new Set ([ 'auth-gated' , 'capture-failed' , 'declared-not-observed' ]);
64
65 // Status floors reuse the completion report's existing vocabulary and rank (completion-report.js
66 // COMPLETION_STATUS). Duplicated as strings, not imported, so this module stays a leaf.
67 const STATUS_FLOOR = Object. freeze ({
68 recovered: null ,
69 'gated-exclusion' : null ,
70 'ungated-exclusion' : 'complete_with_warnings' ,
71 'accepted-loss' : 'incomplete_with_failures' ,
72 unresolved: 'incomplete_with_mismatches' ,
73 'pre-contract' : 'complete_with_warnings' ,
74 });
75 const STATUS_ORDER = [
76 'aborted' ,
77 'incomplete_with_mismatches' ,
78 'incomplete_with_failures' ,
79 'complete_with_deferred_records' ,
80 'complete_with_recovered_records' ,
81 'complete_with_warnings' ,
82 'complete' ,
83 ];
84
85 const PRE_CONTRACT_REASON = 'pre-contract-discovery' ;
86 const PRE_CONTRACT_HEADLINE = 'source coverage was not recorded for this run' ;
87
88 class SourceCoverageError extends Error {
89 constructor ( message , failures = []) {
90 super (message);
91 this .name = 'SourceCoverageError' ;
92 this .code = 'source_coverage_invalid' ;
93 this .failures = failures;
94 }
95 }
96
97 function worse ( left , right ) {
98 if ( ! right) return left;
99 if ( ! left) return right;
100 return STATUS_ORDER . indexOf (left) <= STATUS_ORDER . indexOf (right) ? left : right;
101 }
102
103 function nonEmptyString ( value ) {
104 return typeof value === 'string' && value. trim () !== '' ;
105 }
106
107 function requireString ( value , label ) {
108 if ( ! nonEmptyString (value)) throw new SourceCoverageError ( `${ label } must be a non-empty string` );
109 return value;
110 }
111
112 // ---------------------------------------------------------------------------------------------
113 // Rows and the file envelope
114 // ---------------------------------------------------------------------------------------------
115
116 function coverageIdFor ( platform , sourceEntity ) {
117 return `${ platform }:${ sourceEntity }` ;
118 }
119
120 function normalizeEvidence ( value , label ) {
121 if ( ! value || typeof value !== 'object' || Array. isArray (value)) {
122 throw new SourceCoverageError ( `${ label }.evidence must be an object { artifact, pointer }` );
123 }
124 return {
125 artifact: requireString (value.artifact, `${ label }.evidence.artifact` ),
126 pointer: value.pointer === undefined || value.pointer === null ? null : String (value.pointer),
127 };
128 }
129
130 function normalizeCoverageRow ( row , index = 0 , { platform = null } = {}) {
131 const label = `rows[${ index }]` ;
132 if ( ! row || typeof row !== 'object' || Array. isArray (row)) throw new SourceCoverageError ( `${ label } must be an object` );
133 const rowPlatform = requireString (row.platform, `${ label }.platform` );
134 if (platform && rowPlatform !== platform) {
135 throw new SourceCoverageError ( `${ label }.platform ${ rowPlatform } does not match the file platform ${ platform }` );
136 }
137 const sourceEntity = requireString (row.sourceEntity, `${ label }.sourceEntity` );
138 const coverageId = requireString (row.coverageId, `${ label }.coverageId` );
139 if (coverageId !== coverageIdFor (rowPlatform, sourceEntity)) {
140 throw new SourceCoverageError ( `${ label }.coverageId must be ${ coverageIdFor ( rowPlatform , sourceEntity ) } (got ${ coverageId })` );
141 }
142 const reason = requireString (row.reason, `${ label }.reason` );
143 if ( ! COVERAGE_REASONS . includes (reason)) {
144 throw new SourceCoverageError ( `${ label }.reason must be one of ${ COVERAGE_REASONS . join ( ', ' ) }` );
145 }
146 let capability = null ;
147 let capabilityUnregistered = false ;
148 if (row.capability === null || row.capability === undefined ) {
149 if (row.capabilityUnregistered !== true ) {
150 throw new SourceCoverageError ( `${ label }: a null capability requires capabilityUnregistered: true` );
151 }
152 capabilityUnregistered = true ;
153 } else {
154 capability = requireString (row.capability, `${ label }.capability` );
155 capabilityUnregistered = row.capabilityUnregistered === true ;
156 }
157 const routes = Array. isArray (row.routes) ? row.routes. map (( route , i ) => requireString (route, `${ label }.routes[${ i }]` )) : [];
158 const recoverability = requireString (row.recoverability, `${ label }.recoverability` );
159 if ( ! RECOVERABILITY . includes (recoverability)) {
160 throw new SourceCoverageError ( `${ label }.recoverability must be one of ${ RECOVERABILITY . join ( ', ' ) }` );
161 }
162 let recoveryAction = null ;
163 if (recoverability === 'import-operator' ) {
164 recoveryAction = requireString (row.recoveryAction, `${ label }.recoveryAction` );
165 }
166 const disposition = row.disposition === undefined || row.disposition === null ? null : row.disposition;
167 if (disposition !== null && disposition !== 'declared-exclusion' ) {
168 // Every other disposition lives downstream (a gap entry or a decision); only a quick adapter's
169 // declared exclusion is recorded on the row itself.
170 throw new SourceCoverageError ( `${ label }.disposition may only be null or "declared-exclusion" in the coverage file` );
171 }
172 return {
173 coverageId,
174 platform: rowPlatform,
175 capability,
176 ... (capabilityUnregistered ? { capabilityUnregistered: true } : {}),
177 sourceEntity,
178 routes,
179 reason,
180 evidence: normalizeEvidence (row.evidence, label),
181 recoverability,
182 ... (recoveryAction ? { recoveryAction } : {}),
183 ... ( nonEmptyString (row.pluginCoverageRef) ? { pluginCoverageRef: row.pluginCoverageRef } : {}),
184 disposition,
185 };
186 }
187
188 function validateCoverageFile ( file ) {
189 if ( ! file || typeof file !== 'object' || Array. isArray (file)) {
190 throw new SourceCoverageError ( 'source-coverage.json must be an object { schemaVersion, platform, adapter, rows }' );
191 }
192 if (file.schemaVersion !== SOURCE_COVERAGE_SCHEMA_VERSION ) {
193 throw new SourceCoverageError ( `source-coverage.json schemaVersion must be ${ SOURCE_COVERAGE_SCHEMA_VERSION }` );
194 }
195 const platform = requireString (file.platform, 'source-coverage.json platform' );
196 const adapter = requireString (file.adapter, 'source-coverage.json adapter' );
197 if ( ! COVERAGE_EMITTERS . includes (adapter)) {
198 throw new SourceCoverageError ( `source-coverage.json adapter ${ adapter } is not a known coverage emitter (${ COVERAGE_EMITTERS . join ( ', ' ) })` );
199 }
200 if ( ! Array. isArray (file.rows)) {
201 throw new SourceCoverageError ( 'source-coverage.json rows must be an array; an adapter with nothing to report writes rows: []' );
202 }
203 const seen = new Set ();
204 const rows = file.rows. map (( row , index ) => {
205 const normalized = normalizeCoverageRow (row, index, { platform });
206 if (seen. has (normalized.coverageId)) {
207 throw new SourceCoverageError ( `source-coverage.json lists coverageId ${ normalized . coverageId } twice` );
208 }
209 seen. add (normalized.coverageId);
210 if (normalized.disposition === 'declared-exclusion' && ! QUICK_ADAPTERS . includes (adapter)) {
211 throw new SourceCoverageError ( `${ normalized . coverageId }: declared-exclusion is only a quick adapter's disposition` );
212 }
213 return normalized;
214 });
215 return { schemaVersion: SOURCE_COVERAGE_SCHEMA_VERSION , platform, adapter, rows };
216 }
217
218 function buildCoverageFile ({ platform , adapter , rows = [], generatedAt = null }) {
219 const file = validateCoverageFile ({ schemaVersion: SOURCE_COVERAGE_SCHEMA_VERSION , platform, adapter, rows });
220 return generatedAt ? { ... file, generatedAt } : file;
221 }
222
223 // ---------------------------------------------------------------------------------------------
224 // Adapter row builders
225 // ---------------------------------------------------------------------------------------------
226
227 const DATA_CATEGORIES = new Set ([ 'backend_data' , 'backend_metadata' , 'public_commerce_data' ]);
228
229 // The OPTIONS probe is a metadata request: inspectEntity records its failure and still issues the
230 // data request. Only a failed data request (GET, a profile override, a bridge query) means the
231 // entity was not captured, so an OPTIONS-only 401 on a readable, empty entity produces no row.
232 function dataRequestErrors ( entity ) {
233 return (entity.requestErrors || []). filter (( error ) => error && error.request !== 'OPTIONS' );
234 }
235
236 function isAuthError ( error ) {
237 return error.status === 401 || error.status === 403 ;
238 }
239
240 function sampledCount ( entity ) {
241 if (Array. isArray (entity.sampleRecords) && entity.sampleRecords. length ) return entity.sampleRecords. length ;
242 return Number. isInteger (entity.sampleRecordCount) ? entity.sampleRecordCount : 0 ;
243 }
244
245 // WordPress / WooCommerce. Inputs are the wp-discovery context the adapter already built:
246 // `entities` (inspected routes) and `classifications` (every candidate route's scope decision).
247 // A frontend/theme/admin/transient skip is route-scope evidence only and produces no row; a
248 // duplicate route is not a capability at all.
249 function buildWordPressCoverageRows ({ entities = [], classifications = [], rawDir = 'data/wp-discovery' , platform = 'wordpress' } = {}) {
250 const rows = [];
251 const seen = new Set ();
252 const push = ( row ) => {
253 if (seen. has (row.coverageId)) return ;
254 seen. add (row.coverageId);
255 rows. push (row);
256 };
257 for ( const entity of entities) {
258 if ( ! entity || ! nonEmptyString (entity.entityName)) continue ;
259 if ( sampledCount (entity) > 0 ) continue ;
260 const errors = dataRequestErrors (entity);
261 if ( ! errors. length ) continue ;
262 const authGated = errors. some (isAuthError);
263 const status = errors. map (( error ) => error.status). filter (( value ) => value !== undefined ). join ( ',' );
264 push ({
265 coverageId: coverageIdFor (platform, entity.entityName),
266 platform,
267 capability: null ,
268 capabilityUnregistered: true ,
269 sourceEntity: entity.entityName,
270 routes: [entity.routePath]. filter (nonEmptyString),
271 reason: authGated ? 'auth-gated' : 'capture-failed' ,
272 evidence: { artifact: `${ rawDir }/${ entity . fileName || 'README.md'}` , pointer: `requestErrors (status ${ status || 'unknown'})` },
273 recoverability: 'import-operator' ,
274 recoveryAction: authGated
275 ? `Supply credentials that can read ${ entity . routePath } and re-run WordPress discovery for it.`
276 : `Fix source access for ${ entity . routePath } and re-run WordPress discovery for it.` ,
277 disposition: null ,
278 });
279 }
280 const unsupportedByNamespace = new Map ();
281 for ( const classification of classifications) {
282 if ( ! classification || classification.effectiveAction !== 'skip' || classification.includedByOverride) continue ;
283 if ( DATA_CATEGORIES . has (classification.category)) {
284 const sourceEntity = classification.routePath. replace ( / ^ \/ + / , '' );
285 push ({
286 coverageId: coverageIdFor (platform, sourceEntity),
287 platform,
288 capability: null ,
289 capabilityUnregistered: true ,
290 sourceEntity,
291 routes: [classification.routePath],
292 reason: 'route-skipped-by-rule' ,
293 evidence: { artifact: `${ rawDir }/skipped-routes.json` , pointer: `routes[routePath=${ classification . routePath }]` },
294 recoverability: 'import-operator' ,
295 recoveryAction: `Re-run WordPress discovery with an audited --include-route override for ${ classification . routePath } if this data is needed.` ,
296 disposition: null ,
297 });
298 } else if (classification.category === 'excluded_credential_bearing' ) {
299 // A source-data route the adapter deliberately never reads because its payload carries live
300 // secrets (WooCommerce payment-gateway settings). Not an evidence-only skip: it stays
301 // accountable. Not recoverable from the source by design -- the secrets are never read.
302 const sourceEntity = classification.routePath. replace ( / ^ \/ + / , '' );
303 push ({
304 coverageId: coverageIdFor (platform, sourceEntity),
305 platform,
306 capability: null ,
307 capabilityUnregistered: true ,
308 sourceEntity,
309 routes: [classification.routePath],
310 reason: 'route-skipped-by-rule' ,
311 evidence: { artifact: `${ rawDir }/skipped-routes.json` , pointer: `routes[routePath=${ classification . routePath }, category=excluded_credential_bearing]` },
312 recoverability: 'not-recoverable' ,
313 disposition: null ,
314 });
315 } else if (classification.category === 'excluded_unsupported' && ! classification.duplicateOf) {
316 // A route the adapter does not support, grouped per namespace so a plugin-heavy site gets
317 // one row per unsupported surface rather than one per route.
318 const namespace = nonEmptyString (classification.namespace) ? classification.namespace : 'unknown-namespace' ;
319 if ( ! unsupportedByNamespace. has (namespace)) unsupportedByNamespace. set (namespace, []);
320 unsupportedByNamespace. get (namespace). push (classification.routePath);
321 }
322 }
323 for ( const [ namespace , routes ] of unsupportedByNamespace) {
324 push ({
325 coverageId: coverageIdFor (platform, namespace),
326 platform,
327 capability: null ,
328 capabilityUnregistered: true ,
329 sourceEntity: namespace,
330 routes: [ ... routes]. sort (),
331 reason: 'route-unsupported' ,
332 evidence: { artifact: `${ rawDir }/skipped-routes.json` , pointer: `routes[namespace=${ namespace }, category=excluded_unsupported]` },
333 recoverability: 'requires-development' ,
334 disposition: null ,
335 });
336 }
337 return rows;
338 }
339
340 // CSV. An unknown file role and a halted layout are both things the adapter was handed and could
341 // not turn into entities; each gets a row so the decision the halt asks for cannot be skipped.
342 function buildCsvCoverageRows ({ sourceFiles = [], streams = [], csvInputRoot = null , rawDir = 'data/csv-discovery' , platform = 'csv' } = {}) {
343 const rows = [];
344 const seen = new Set ();
345 const rel = ( file ) => (csvInputRoot ? path. relative (csvInputRoot, file) || path. basename (file) : path. basename (file));
346 for ( const entry of sourceFiles) {
347 if ( ! entry || entry.role !== 'unknown' ) continue ;
348 const sourceEntity = rel (entry.file);
349 const coverageId = coverageIdFor (platform, sourceEntity);
350 if (seen. has (coverageId)) continue ;
351 seen. add (coverageId);
352 rows. push ({
353 coverageId,
354 platform,
355 capability: null ,
356 capabilityUnregistered: true ,
357 sourceEntity,
358 routes: [sourceEntity],
359 reason: 'route-unsupported' ,
360 evidence: { artifact: `${ rawDir }/fileset.json` , pointer: `sourceFiles[file=${ sourceEntity }].role=unknown` },
361 recoverability: 'import-operator' ,
362 recoveryAction: `Identify what ${ sourceEntity } holds and re-run CSV discovery with --role ${ sourceEntity }:<role>.` ,
363 disposition: null ,
364 });
365 }
366 for ( const stream of streams) {
367 if ( ! stream || ! stream.layout || stream.layout.halt !== true ) continue ;
368 const sourceEntity = rel (stream.primary);
369 const coverageId = coverageIdFor (platform, sourceEntity);
370 if (seen. has (coverageId)) continue ;
371 seen. add (coverageId);
372 rows. push ({
373 coverageId,
374 platform,
375 capability: null ,
376 capabilityUnregistered: true ,
377 sourceEntity,
378 routes: [sourceEntity],
379 reason: 'capture-failed' ,
380 evidence: { artifact: `${ rawDir }/fileset.json` , pointer: `streams[primary=${ sourceEntity }].layout.halt` },
381 recoverability: 'import-operator' ,
382 recoveryAction: `Answer the layout question for ${ sourceEntity } and re-run CSV discovery.` ,
383 disposition: null ,
384 });
385 }
386 return rows;
387 }
388
389 // Shopify (private data, read through the Shopify CLI). Inputs are the discovery capture's failed
390 // reads -- `{ entity, artifact, errors[] }` per entity whose count or sample read errored -- plus
391 // the rows other discovery steps already built. A failed read is a `capture-failed` row, never an
392 // empty entity. `capability` is the registry string where one exists, otherwise null.
393 const SHOPIFY_CAPABILITIES = Object. freeze ({
394 products: 'commerce.catalog-product' ,
395 orders: 'commerce.orders' ,
396 customers: 'crm.contact' ,
397 'gift-cards' : 'commerce.gift-cards' ,
398 articles: 'content.blog-post' ,
399 files: 'media.file' ,
400 'inventory-items' : 'commerce.inventory' ,
401 'delivery-profiles' : 'commerce.delivery-profiles' ,
402 });
403
404 function shopifyCapability ( sourceEntity ) {
405 const capability = SHOPIFY_CAPABILITIES [sourceEntity];
406 return capability ? { capability } : { capability: null , capabilityUnregistered: true };
407 }
408
409 function buildShopifyCoverageRows ({ captureFailures = [], stepRows = [], rawDir = 'data/shopify-discovery' , platform = 'shopify' } = {}) {
410 const rows = [];
411 const seen = new Set ();
412 const push = ( row ) => {
413 if (seen. has (row.coverageId)) return ;
414 seen. add (row.coverageId);
415 rows. push (row);
416 };
417 for ( const failed of captureFailures) {
418 if ( ! failed || ! nonEmptyString (failed.entity)) continue ;
419 const codes = (failed.errors || []). map (( error ) => error && (error.code || error.class)). filter (nonEmptyString);
420 push ({
421 coverageId: coverageIdFor (platform, failed.entity),
422 platform,
423 ... shopifyCapability (failed.entity),
424 sourceEntity: failed.entity,
425 routes: (failed.reads || []). filter (nonEmptyString),
426 reason: 'capture-failed' ,
427 evidence: { artifact: nonEmptyString (failed.artifact) ? failed.artifact : `${ rawDir }/README.md` , pointer: `errors (${ [ ...new Set ( codes )]. join ( ',' ) || 'unknown'})` },
428 recoverability: 'import-operator' ,
429 recoveryAction: `Fix the Shopify read of ${ failed . entity } and re-run Shopify discovery with --reread ${ failed . entity }.` ,
430 disposition: null ,
431 });
432 }
433 for ( const row of stepRows) push (row);
434 return rows;
435 }
436
437 // Quick mode. Every `excluded[]` entry is a declared exclusion under the existing quick-mode
438 // scope policy; the row keeps the source name and carries the committed capability translation.
439 function buildQuickCoverageRows ( contract , { rawText = null } = {}) {
440 const errors = validateQuickExcludedCapabilities (contract, { rawText });
441 if (errors. length ) throw new SourceCoverageError ( `quick-mode.json excludedCapabilities is invalid: ${ errors . join ( '; ' ) }` , errors. map (( message ) => ({ code: 'invalid_quick_contract' , message })));
442 return contract.excluded. map (( sourceEntity ) => ({
443 coverageId: coverageIdFor (contract.platform, sourceEntity),
444 platform: contract.platform,
445 capability: contract.excludedCapabilities[sourceEntity],
446 // Until the capability registry exists, a source-scope.* string is not registered anywhere.
447 ... (contract.excludedCapabilities[sourceEntity]. startsWith ( 'source-scope.' ) ? { capabilityUnregistered: true } : {}),
448 sourceEntity,
449 routes: [],
450 reason: 'adapter-excluded' ,
451 evidence: { artifact: `resources/rp-${ contract . id }/quick-mode.json` , pointer: `excluded[${ contract . excluded . indexOf ( sourceEntity ) }]` },
452 recoverability: 'not-recoverable' ,
453 disposition: 'declared-exclusion' ,
454 }));
455 }
456
457 // The committed quick-mode contract for a quick adapter id; what a declared exclusion is checked
458 // against. Read from the bundle, never from the project, so a stale or hand-edited coverage file
459 // or plan cannot vouch for itself.
460 function bundledQuickContract ( adapter ) {
461 if ( ! QUICK_ADAPTERS . includes (adapter)) return null ;
462 // eslint-disable-next-line global-require, import/no-dynamic-require
463 return require (path. join (__dirname, '..' , 'resources' , `rp-${ adapter }` , 'quick-mode.json' ));
464 }
465
466 let cachedCapabilityKeys = null ;
467 function bundledCapabilityKeys () {
468 if ( ! cachedCapabilityKeys) {
469 // eslint-disable-next-line global-require, import/no-dynamic-require
470 const index = require (path. join (__dirname, '..' , 'resources' , 'rp-target-wix' , 'domains' , 'index.json' ));
471 cachedCapabilityKeys = new Set (Object. keys (index.capabilityIndex || {}));
472 }
473 return cachedCapabilityKeys;
474 }
475
476 // JSON.parse keeps the last of two equal keys, so a duplicate key in the committed file would be
477 // silently resolved by position. Scan the raw text of the (flat, string-valued) object instead.
478 function duplicateExcludedCapabilityKeys ( text ) {
479 const start = text. indexOf ( '"excludedCapabilities"' );
480 if (start === - 1 ) return [];
481 const open = text. indexOf ( '{' , start);
482 const close = text. indexOf ( '}' , open);
483 if (open === - 1 || close === - 1 ) return [];
484 const seen = new Set ();
485 const duplicates = new Set ();
486 for ( const match of text. slice (open + 1 , close). matchAll ( /"((?: [ ^ " \\ ] | \\ . ) * )" \s * :/ g )) {
487 if (seen. has (match[ 1 ])) duplicates. add (match[ 1 ]);
488 seen. add (match[ 1 ]);
489 }
490 return [ ... duplicates];
491 }
492
493 // The committed translation from quick-mode source-scope names to capability strings. No
494 // translation is inferred: exactly one key per excluded[] entry, each value an existing target
495 // capability or `source-scope.<its own key>`.
496 function validateQuickExcludedCapabilities ( contract , { capabilityKeys = bundledCapabilityKeys (), rawText = null } = {}) {
497 const errors = rawText ? duplicateExcludedCapabilityKeys (rawText). map (( key ) => `excludedCapabilities lists key ${ key } more than once` ) : [];
498 if ( ! contract || typeof contract !== 'object' ) return [ 'quick-mode contract must be an object' ];
499 const excluded = Array. isArray (contract.excluded) ? contract.excluded : null ;
500 if ( ! excluded) return [ 'excluded must be an array' ];
501 const map = contract.excludedCapabilities;
502 if ( ! map || typeof map !== 'object' || Array. isArray (map)) return [ 'excludedCapabilities must be an object with one key per excluded[] entry' ];
503 const seenExcluded = new Set ();
504 for ( const name of excluded) {
505 if (seenExcluded. has (name)) errors. push ( `excluded[] lists ${ name } twice` );
506 seenExcluded. add (name);
507 if ( ! Object . prototype .hasOwnProperty. call (map, name)) errors. push ( `excludedCapabilities is missing ${ name }` );
508 }
509 for ( const [ key , value ] of Object. entries (map)) {
510 if ( ! seenExcluded. has (key)) {
511 errors. push ( `excludedCapabilities has extra key ${ key } that is not in excluded[]` );
512 continue ;
513 }
514 if ( typeof value !== 'string' || ! value) {
515 errors. push ( `excludedCapabilities.${ key } must be a capability string` );
516 continue ;
517 }
518 if (value. startsWith ( 'source-scope.' )) {
519 if (value !== `source-scope.${ key }` ) errors. push ( `excludedCapabilities.${ key }: a source-scope value must be source-scope.${ key } (got ${ value })` );
520 } else if ( ! capabilityKeys. has (value)) {
521 errors. push ( `excludedCapabilities.${ key }: ${ value } is neither a target capability nor source-scope.${ key }` );
522 }
523 }
524 return errors;
525 }
526
527 // ---------------------------------------------------------------------------------------------
528 // The join: exactly one valid disposition per row
529 // ---------------------------------------------------------------------------------------------
530
531 function readGapList ( gaps ) {
532 if (Array. isArray (gaps)) return gaps;
533 if (gaps && Array. isArray (gaps.gaps)) return gaps.gaps;
534 return [];
535 }
536
537 function schemaEntityNames ( sourceSchema ) {
538 const entities = sourceSchema && sourceSchema.entities;
539 if (Array. isArray (entities)) {
540 return new Set (entities. map (( entity ) => entity && (entity.name || entity.entityName || entity.sourceEntity)). filter (nonEmptyString));
541 }
542 if (entities && typeof entities === 'object' ) return new Set (Object. keys (entities));
543 return new Set ();
544 }
545
546 function registerEntry ( registers , register , capability ) {
547 const list = registers && registers[register];
548 if ( ! Array. isArray (list)) return null ;
549 return list. find (( entry ) => entry && entry.capability === capability && nonEmptyString (entry.decidedBy)) || null ;
550 }
551
552 // Does the cited gate exist? This only validates; it never creates one.
553 function citedGateExists ( gate , evidence , lazy ) {
554 if ( ! evidence || typeof evidence !== 'object' ) return false ;
555 if ( nonEmptyString (evidence.register)) {
556 if (gate === 'exclusion' && evidence.register !== 'no-migration-needed' ) return false ;
557 if ( ! [ 'no-migration-needed' , 'requires-development' ]. includes (evidence.register)) return false ;
558 return Boolean ( registerEntry (lazy. registers (), evidence.register, evidence.capability));
559 }
560 if (gate === 'loss-accepted' && evidence.approval === 'mapping' ) {
561 const approvals = lazy. approvals ();
562 const mapping = approvals && approvals.mapping;
563 return Boolean (mapping && mapping.status === 'approved' );
564 }
565 return false ;
566 }
567
568 function failure ( code , coverageId , message , sources = []) {
569 return { code, coverageId, message, sources };
570 }
571
572 // Pure over its inputs. `sourceSchema`, `registers` and `approvals` may be values or thunks; they
573 // are only consulted for a `provided` decision or a cited gate, so the common case reads exactly
574 // the coverage, gaps and decisions files.
575 function reconcileSourceCoverage ({ coverage , gaps , decisions , sourceSchema = null , registers = null , approvals = null , quickContract = null } = {}) {
576 const file = validateCoverageFile (coverage);
577 const once = ( value ) => {
578 let loaded = false ;
579 let result;
580 return () => {
581 if ( ! loaded) { result = typeof value === 'function' ? value () : value; loaded = true ; }
582 return result;
583 };
584 };
585 const lazy = {
586 sourceSchema: once (sourceSchema),
587 registers: once (registers),
588 approvals: once (approvals),
589 quickContract: once (quickContract || (() => bundledQuickContract (file.adapter))),
590 };
591 const failures = [];
592 const rowsById = new Map (file.rows. map (( row ) => [row.coverageId, row]));
593 const isQuick = QUICK_ADAPTERS . includes (file.adapter);
594
595 const gapsById = new Map ();
596 readGapList (gaps). forEach (( gap , index ) => {
597 if ( ! gap || ! nonEmptyString (gap.coverageId)) return ;
598 if ( ! rowsById. has (gap.coverageId)) {
599 failures. push ( failure ( 'unknown_coverage_id' , gap.coverageId, `mapping-gaps.json entry ${ index } references unknown coverageId ${ gap . coverageId }` , [ `gap[${ index }]` ]));
600 return ;
601 }
602 if ( ! gapsById. has (gap.coverageId)) gapsById. set (gap.coverageId, []);
603 gapsById. get (gap.coverageId). push ({ gap, index });
604 });
605
606 const decisionsById = new Map ();
607 for ( const [ key , entry ] of Object. entries (decisions || {})) {
608 if ( ! key. startsWith ( DECISION_PREFIX )) continue ;
609 const coverageId = key. slice ( DECISION_PREFIX . length );
610 if ( ! rowsById. has (coverageId)) {
611 failures. push ( failure ( 'unknown_coverage_id' , coverageId, `decision ${ key } references unknown coverageId ${ coverageId }` , [ `decision:${ key }` ]));
612 continue ;
613 }
614 const value = entry && typeof entry === 'object' ? entry.value : entry;
615 if ( ! DECISION_VALUES . includes (value)) {
616 failures. push ( failure ( 'invalid_decision' , coverageId, `decision ${ key } must be provided or declined (got ${ JSON . stringify ( value ) })` , [ `decision:${ key }` ]));
617 continue ;
618 }
619 decisionsById. set (coverageId, { key, value });
620 }
621
622 const resolved = [];
623 for ( const row of file.rows) {
624 const rowGaps = gapsById. get (row.coverageId) || [];
625 const decision = decisionsById. get (row.coverageId) || null ;
626 const declared = row.disposition === 'declared-exclusion' ;
627 const sources = [
628 ... rowGaps. map (({ index }) => `gap[${ index }]` ),
629 ... (decision ? [ `decision:${ decision . key }` ] : []),
630 ... (declared ? [ 'declared-exclusion' ] : []),
631 ];
632 if (sources. length === 0 ) {
633 failures. push ( failure ( 'missing_disposition' , row.coverageId, `${ row . coverageId } has no disposition: record a mapping-gaps.json entry with this coverageId or a ${ DECISION_PREFIX }${ row . coverageId } decision` ));
634 continue ;
635 }
636 if (sources. length > 1 ) {
637 failures. push ( failure ( 'disposition_conflict' , row.coverageId, `${ row . coverageId } has ${ sources . length } active dispositions (${ sources . join ( ', ' ) }); exactly one is allowed` , sources));
638 continue ;
639 }
640 if (isQuick && row.reason !== 'adapter-excluded' ) {
641 failures. push ( failure ( 'quick_reason_unsupported' , row.coverageId, `${ row . coverageId }: a quick adapter may only report adapter-excluded rows (got ${ row . reason })` , sources));
642 continue ;
643 }
644
645 if (declared) {
646 // Declared exclusion: only on an adapter-excluded row from a quick adapter, for a name that
647 // adapter's committed contract lists in excluded[], carrying that contract's translation.
648 // A stale or forged row has no status floor to fall back on, so it is refused here.
649 if (row.reason !== 'adapter-excluded' || ! isQuick) {
650 failures. push ( failure ( 'gate_reason_mismatch' , row.coverageId, `${ row . coverageId }: declared-exclusion is valid only on a quick adapter's adapter-excluded row` , sources));
651 continue ;
652 }
653 const contract = lazy. quickContract ();
654 const excluded = contract && Array. isArray (contract.excluded) ? contract.excluded : [];
655 if ( ! excluded. includes (row.sourceEntity)) {
656 failures. push ( failure ( 'gate_reason_mismatch' , row.coverageId, `${ row . coverageId }: ${ row . sourceEntity } is not in the ${ file . adapter } contract's excluded[]; re-run quick-mode plan` , sources));
657 continue ;
658 }
659 const committed = contract.excludedCapabilities && contract.excludedCapabilities[row.sourceEntity];
660 if (row.capability !== committed) {
661 failures. push ( failure ( 'gate_reason_mismatch' , row.coverageId, `${ row . coverageId }: capability ${ row . capability } does not match the committed translation ${ committed || 'none'}; re-run quick-mode plan` , sources));
662 continue ;
663 }
664 resolved. push ({ row, disposition: 'declared-exclusion' , treatment: 'gated-exclusion' , gate: null , gateEvidence: null , handling: null });
665 continue ;
666 }
667
668 if (decision) {
669 if (decision.value === 'provided' ) {
670 const names = schemaEntityNames (lazy. sourceSchema ());
671 if ( ! names. has (row.sourceEntity)) {
672 failures. push ( failure ( 'disposition_stale' , row.coverageId, `${ row . coverageId }: decision is "provided" but ${ row . sourceEntity } is not an entity in source-schema.json; re-run discovery or record "declined"` , sources));
673 continue ;
674 }
675 resolved. push ({ row, disposition: 'recovered' , treatment: 'recovered' , gate: null , gateEvidence: null , handling: null });
676 } else {
677 resolved. push ({
678 row,
679 disposition: 'declined' ,
680 treatment: row.reason === 'adapter-excluded' ? 'ungated-exclusion' : 'unresolved' ,
681 gate: null ,
682 gateEvidence: null ,
683 handling: null ,
684 });
685 }
686 continue ;
687 }
688
689 const { gap } = rowGaps[ 0 ];
690 const gate = gap.gate === undefined || gap.gate === null ? null : gap.gate;
691 const handling = nonEmptyString (gap.recommendedHandling) ? gap.recommendedHandling : ( nonEmptyString (gap.handling) ? gap.handling : null );
692 if (gate === null ) {
693 resolved. push ({ row, disposition: 'gap' , treatment: row.reason === 'adapter-excluded' ? 'ungated-exclusion' : 'unresolved' , gate: null , gateEvidence: null , handling });
694 continue ;
695 }
696 if ( ! GATES . includes (gate)) {
697 failures. push ( failure ( 'invalid_gate' , row.coverageId, `${ row . coverageId }: gate must be one of ${ GATES . join ( ', ' ) } (got ${ JSON . stringify ( gate ) })` , sources));
698 continue ;
699 }
700 const evidence = gap.gateEvidence || null ;
701 if (gate === 'exclusion' ) {
702 // A gated exclusion has no status floor, so it must be exactly what it claims: an
703 // adapter exclusion backed by a signed no-migration-needed entry for this capability.
704 const evidenceCapability = evidence && evidence.capability;
705 if (row.reason !== 'adapter-excluded' || ! row.capability || evidenceCapability !== row.capability || (evidence && evidence.register !== 'no-migration-needed' )) {
706 failures. push ( failure ( 'gate_reason_mismatch' , row.coverageId, `${ row . coverageId }: an exclusion gate is valid only on an adapter-excluded row whose no-migration-needed entry names its own capability (reason ${ row . reason }, row capability ${ row . capability }, cited ${ evidenceCapability || 'none'})` , sources));
707 continue ;
708 }
709 }
710 if (gate === 'loss-accepted' && evidence && nonEmptyString (evidence.register) && evidence.capability !== row.capability) {
711 failures. push ( failure ( 'gate_reason_mismatch' , row.coverageId, `${ row . coverageId }: the cited ${ evidence . register } entry names ${ evidence . capability }, not this row's capability ${ row . capability }` , sources));
712 continue ;
713 }
714 if ( ! citedGateExists (gate, evidence, lazy)) {
715 failures. push ( failure ( 'gate_missing' , row.coverageId, `${ row . coverageId }: the cited ${ gate } gate does not exist; reconcile never creates one` , sources));
716 continue ;
717 }
718 resolved. push ({
719 row,
720 disposition: 'gap' ,
721 treatment: gate === 'exclusion' ? 'gated-exclusion' : 'accepted-loss' ,
722 gate,
723 gateEvidence: evidence,
724 handling,
725 });
726 }
727
728 return { ok: failures. length === 0 , adapter: file.adapter, platform: file.platform, failures, resolved };
729 }
730
731 // ---------------------------------------------------------------------------------------------
732 // Completion: status floor, headline, sourceCoverage[] and operator actions
733 // ---------------------------------------------------------------------------------------------
734
735 function labelOf ( row ) {
736 return row.capability || row.sourceEntity;
737 }
738
739 // What the Cutover Operator reads about a row it can only act on in Wix. Source recovery is
740 // mentioned only for an `import-operator` row; a `not-recoverable` row (for example a
741 // credential-bearing route whose secrets are never read) or a `requires-development` row must
742 // never suggest that the source can still supply the data through an operator.
743 function sourceRecoveryNote ( recoverability ) {
744 switch (recoverability) {
745 case 'import-operator' :
746 return ' Recovering it from the source is Import Operator work before the source is retired.' ;
747 case 'requires-development' :
748 return ' This migration cannot recover it from the source without new adapter development.' ;
749 default :
750 return ' This migration cannot recover it from the source.' ;
751 }
752 }
753
754 function cutoverActionFor ( treatment , label , recoverability ) {
755 switch (treatment) {
756 case 'gated-exclusion' :
757 return `Excluded by the chosen scope: ${ label } is not part of this migration. Set it up in Wix separately only if the business needs it.` ;
758 case 'ungated-exclusion' :
759 return `Excluded without a recorded review: confirm with the merchant whether ${ label } needs to be set up in Wix.` ;
760 case 'accepted-loss' :
761 return `Accepted loss: ${ label } was left behind by a recorded decision. Nothing to recreate unless that decision changes.` ;
762 default :
763 return `Not migrated: recreate or confirm ${ label } in Wix if the business still needs it.${ sourceRecoveryNote ( recoverability ) }` ;
764 }
765 }
766
767 function headlineFor ( treatment , label , reasons ) {
768 const reasonText = [ ... reasons]. sort (). join ( ', ' );
769 switch (treatment) {
770 case 'ungated-exclusion' :
771 return `${ label }: excluded from the source without a recorded gate (${ reasonText })` ;
772 case 'accepted-loss' :
773 return `${ label }: not migrated from the source; loss accepted (${ reasonText })` ;
774 default :
775 return `${ label }: not migrated from the source (${ reasonText })` ;
776 }
777 }
778
779 function preContractSummary () {
780 return {
781 sourceCoverageRecorded: false ,
782 sourceCoverageReason: PRE_CONTRACT_REASON ,
783 sourceCoverage: [],
784 operatorActions: [],
785 headline: [{ entity: 'source-coverage' , subtype: 'source-coverage' , message: PRE_CONTRACT_HEADLINE }],
786 status: STATUS_FLOOR [ 'pre-contract' ],
787 };
788 }
789
790 // The pure join. Throws on any reconcile failure, so a writer refuses instead of choosing a status.
791 // `coverage: null` means a pre-contract project (no stamp); the caller decides that, never this.
792 function summarizeSourceCoverage ({ coverage , gaps , decisions , sourceSchema = null , registers = null , approvals = null , quickContract = null } = {}) {
793 if (coverage === null ) return preContractSummary ();
794 const result = reconcileSourceCoverage ({ coverage, gaps, decisions, sourceSchema, registers, approvals, quickContract });
795 if ( ! result.ok) {
796 throw new SourceCoverageError (
797 `source coverage does not reconcile: ${ result . failures . map (( item ) => `${ item . code } ${ item . coverageId || ''}` . trim ()). join ( '; ' ) }` ,
798 result.failures,
799 );
800 }
801
802 let status = null ;
803 const sourceCoverage = [];
804 const grouped = new Map ();
805 const gatedCapabilities = new Set ();
806 for ( const item of result.resolved) {
807 status = worse (status, STATUS_FLOOR [item.treatment]);
808 if (item.treatment === 'recovered' ) continue ;
809 const label = labelOf (item.row);
810 sourceCoverage. push ({
811 coverageId: item.row.coverageId,
812 platform: item.row.platform,
813 capability: item.row.capability,
814 sourceEntity: item.row.sourceEntity,
815 reason: item.row.reason,
816 recoverability: item.row.recoverability,
817 disposition: item.disposition,
818 treatment: item.treatment,
819 gate: item.gate,
820 gateEvidence: item.gateEvidence,
821 recommendedHandling: item.handling,
822 cutoverAction: cutoverActionFor (item.treatment, label, item.row.recoverability),
823 });
824 if (item.treatment === 'gated-exclusion' ) {
825 gatedCapabilities. add (label);
826 continue ;
827 }
828 const key = `${ item . treatment } \0 ${ label }` ;
829 if ( ! grouped. has (key)) grouped. set (key, { treatment: item.treatment, label, reasons: new Set (), urgent: false , sourceRecoverable: false });
830 const group = grouped. get (key);
831 group.reasons. add (item.row.reason);
832 if (item.row.recoverability === 'import-operator' ) group.sourceRecoverable = true ;
833 if (item.treatment === 'unresolved' && URGENT_REASONS . has (item.row.reason)) group.urgent = true ;
834 }
835
836 const headline = [];
837 const operatorActions = [];
838 for ( const group of grouped. values ()) {
839 headline. push ({ entity: group.label, subtype: 'source-coverage' , message: headlineFor (group.treatment, group.label, group.reasons) });
840 if (group.urgent) {
841 operatorActions. push ({
842 code: 'source-capability-not-migrated' ,
843 severity: 'high' ,
844 action: `${ group . label } was not migrated from the source (${ [ ... group . reasons ]. sort (). join ( ', ' ) }). Recreate or confirm it in Wix${ group . sourceRecoverable ? '; recovering it from the source requires the Import Operator in the original migration environment' : ''}.` ,
845 deadlineReason: 'the source is still the only copy of this data' ,
846 entity: group.label,
847 affectedCount: 0 ,
848 });
849 }
850 }
851 if (gatedCapabilities.size) {
852 headline. push ({
853 entity: 'source-coverage' ,
854 subtype: 'source-coverage' ,
855 message: `${ gatedCapabilities . size } source capabilit${ gatedCapabilities . size === 1 ? 'y' : 'ies'} excluded by the chosen scope` ,
856 });
857 }
858
859 return {
860 sourceCoverageRecorded: true ,
861 sourceCoverageReason: null ,
862 sourceCoverage,
863 operatorActions,
864 headline,
865 status,
866 };
867 }
868
869 // ---------------------------------------------------------------------------------------------
870 // Project I/O: the stamp, the fail-closed loaders, and the decision writer
871 // ---------------------------------------------------------------------------------------------
872
873 async function readJsonFile ( filePath ) {
874 try {
875 return JSON . parse ( await fs. readFile (filePath, 'utf8' ));
876 } catch (error) {
877 if (error.code === 'ENOENT' ) return undefined ;
878 throw new SourceCoverageError ( `${ filePath } could not be read as JSON: ${ error . message }` );
879 }
880 }
881
882 function contractStampOf ( document , label ) {
883 if ( ! document || typeof document !== 'object' || document.sourceCoverageContract === undefined ) return null ;
884 if (document.sourceCoverageContract !== SOURCE_COVERAGE_CONTRACT ) {
885 throw new SourceCoverageError ( `${ label } carries unknown sourceCoverageContract ${ JSON . stringify ( document . sourceCoverageContract ) }` );
886 }
887 return SOURCE_COVERAGE_CONTRACT ;
888 }
889
890 // Full mode reads the stamp only from the project-root source-schema.json; quick mode only from
891 // quick-mode/plan.json. Never from anywhere else.
892 async function readCoverageContract ( projectDir , { mode = 'full' , readJson = readJsonFile } = {}) {
893 const file = mode === 'quick' ? QUICK_PLAN_FILE : SOURCE_SCHEMA_FILE ;
894 const document = await readJson (path. join (projectDir, file));
895 return { stamped: contractStampOf (document, file) === SOURCE_COVERAGE_CONTRACT , document: document || null , file };
896 }
897
898 async function loadRegisters () {
899 const dir = path. join (__dirname, '..' , 'resources' , 'rp-source-wordpress' , 'plugins' );
900 const read = async ( name ) => {
901 const document = await readJsonFile (path. join (dir, `${ name }.json` ));
902 return Array. isArray (document && document.capabilities) ? document.capabilities : [];
903 };
904 return { 'no-migration-needed' : await read ( 'no-migration-needed' ), 'requires-development' : await read ( 'requires-development' ) };
905 }
906
907 // Loads the three named files (coverage, gaps, decisions), failing closed on a missing coverage
908 // file. The source schema, registers and approvals are read only when a row actually needs them.
909 async function loadCoverageInputs ( projectDir , { readJson = readJsonFile } = {}) {
910 const coverage = await readJson (path. join (projectDir, SOURCE_COVERAGE_FILE ));
911 if (coverage === undefined ) {
912 throw new SourceCoverageError ( `${ SOURCE_COVERAGE_FILE } is missing; it is never optional for a current adapter` , [
913 { code: 'missing_coverage_file' , coverageId: null , message: `${ SOURCE_COVERAGE_FILE } is missing` , sources: [] },
914 ]);
915 }
916 const gaps = ( await readJson (path. join (projectDir, MAPPING_GAPS_FILE ))) || [];
917 const decisions = ( await readJson (path. join (projectDir, DECISIONS_FILE ))) || {};
918 return { coverage, gaps, decisions };
919 }
920
921 function needsLazyInputs ( inputs ) {
922 const gaps = readGapList (inputs.gaps);
923 const anyProvided = Object. entries (inputs.decisions || {}). some (([ key , entry ]) => key. startsWith ( DECISION_PREFIX ) && entry && entry.value === 'provided' );
924 const anyRegister = gaps. some (( gap ) => gap && gap.coverageId && gap.gateEvidence && gap.gateEvidence.register);
925 const anyApproval = gaps. some (( gap ) => gap && gap.coverageId && gap.gateEvidence && gap.gateEvidence.approval);
926 return { anyProvided, anyRegister, anyApproval };
927 }
928
929 // Eagerly resolves the lazily-needed side inputs (async), but only those a row will consult.
930 async function resolveSideInputs ( projectDir , inputs , { readJson = readJsonFile, registers = null } = {}) {
931 const need = needsLazyInputs (inputs);
932 return {
933 sourceSchema: need.anyProvided ? ( await readJson (path. join (projectDir, SOURCE_SCHEMA_FILE ))) || null : null ,
934 registers: need.anyRegister ? (registers || ( await loadRegisters ())) : null ,
935 approvals: need.anyApproval ? ( await readJson (path. join (projectDir, APPROVALS_FILE ))) || null : null ,
936 };
937 }
938
939 async function reconcileProject ( projectDir , { readJson = readJsonFile, registers = null } = {}) {
940 const inputs = await loadCoverageInputs (projectDir, { readJson });
941 const side = await resolveSideInputs (projectDir, inputs, { readJson, registers });
942 return reconcileSourceCoverage ({ ... inputs, ... side });
943 }
944
945 // The completion-time join for a project. `mode: 'quick'` reads the quick-mode plan stamp.
946 // The pre-contract exception is bounded to an unstamped full-mode source-schema.json; a quick
947 // plan written since Phase 1 always carries the stamp, so a missing or unstamped one refuses.
948 async function summarizeProjectCoverage ( projectDir , { mode = 'full' , readJson = readJsonFile, registers = null } = {}) {
949 const contract = await readCoverageContract (projectDir, { mode, readJson });
950 if ( ! contract.stamped && mode === 'quick' ) {
951 const what = contract.document ? 'has no sourceCoverageContract stamp' : 'is missing' ;
952 throw new SourceCoverageError ( `${ QUICK_PLAN_FILE } ${ what }; re-run quick-mode plan before import` , [
953 { code: 'quick_plan_unstamped' , coverageId: null , message: `${ QUICK_PLAN_FILE } ${ what }` , sources: [] },
954 ]);
955 }
956 if ( ! contract.stamped) return summarizeSourceCoverage ({ coverage: null });
957 const inputs = await loadCoverageInputs (projectDir, { readJson });
958 const side = await resolveSideInputs (projectDir, inputs, { readJson, registers });
959 return summarizeSourceCoverage ({ ... inputs, ... side });
960 }
961
962 async function writeJsonAtomic ( filePath , value ) {
963 await fs. mkdir (path. dirname (filePath), { recursive: true });
964 const temp = `${ filePath }.${ process . pid }.${ Date . now () }.tmp` ;
965 await fs. writeFile (temp, `${ JSON . stringify ( value , null , 2 ) } \n ` , 'utf8' );
966 await fs. rename (temp, filePath);
967 }
968
969 // Promotion: validate the adapter's file, copy it to discovery/, and stamp the canonical
970 // project-root source-schema.json. Refuses for an unknown adapter, a missing adapter file or a
971 // missing source schema -- there is no unstamped path for a current adapter.
972 async function promoteSourceCoverage ( projectDir , { adapter }) {
973 if ( ! COVERAGE_EMITTERS . includes (adapter) || QUICK_ADAPTERS . includes (adapter)) {
974 throw new SourceCoverageError ( `adapter ${ adapter } cannot promote discovery: full-discovery coverage emitters are ${ Object . keys ( ADAPTER_RAW_DIRS ). join ( ', ' ) }` );
975 }
976 const adapterFile = path. join (projectDir, ADAPTER_RAW_DIRS [adapter], ADAPTER_COVERAGE_FILE_NAME );
977 const raw = await readJsonFile (adapterFile);
978 if (raw === undefined ) {
979 throw new SourceCoverageError ( `${ path . relative ( projectDir , adapterFile ) } is missing; the ${ adapter } adapter must write it (rows: [] when there is nothing to report) before discovery is promoted` , [
980 { code: 'missing_coverage_file' , coverageId: null , message: 'adapter coverage file missing' , sources: [] },
981 ]);
982 }
983 const file = validateCoverageFile (raw);
984 if (file.adapter !== adapter) throw new SourceCoverageError ( `adapter coverage file names adapter ${ file . adapter }, not ${ adapter }` );
985 const schemaPath = path. join (projectDir, SOURCE_SCHEMA_FILE );
986 const schema = await readJsonFile (schemaPath);
987 if ( ! schema || typeof schema !== 'object' ) {
988 throw new SourceCoverageError ( `${ SOURCE_SCHEMA_FILE } must be synthesized before source coverage is promoted` );
989 }
990 await writeJsonAtomic (path. join (projectDir, SOURCE_COVERAGE_FILE ), { ... file, ... (raw.generatedAt ? { generatedAt: raw.generatedAt } : {}) });
991 await writeJsonAtomic (schemaPath, { ... schema, sourceCoverageContract: SOURCE_COVERAGE_CONTRACT });
992 return { coveragePath: path. join (projectDir, SOURCE_COVERAGE_FILE ), rows: file.rows. length };
993 }
994
995 // A coverage row first found after discovery (during the import) joins the adapter's raw file by
996 // coverageId -- a row with the same id is replaced, otherwise appended -- and the file is
997 // re-promoted, so the canonical record and the stamp come from the one promotion path. What the
998 // run does next is the general path for every source; this writes no gap, decision or approval.
999 async function upsertAdapterCoverageRows ( projectDir , { adapter , rows , checkpoint = null }) {
1000 if ( ! COVERAGE_EMITTERS . includes (adapter) || QUICK_ADAPTERS . includes (adapter)) {
1001 throw new SourceCoverageError ( `adapter ${ adapter } has no adapter coverage file to update` );
1002 }
1003 if ( ! Array. isArray (rows) || rows. length === 0 ) throw new SourceCoverageError ( 'upsertAdapterCoverageRows needs at least one row' );
1004 const adapterFile = path. join (projectDir, ADAPTER_RAW_DIRS [adapter], ADAPTER_COVERAGE_FILE_NAME );
1005 const raw = await readJsonFile (adapterFile);
1006 if (raw === undefined ) {
1007 throw new SourceCoverageError ( `${ path . relative ( projectDir , adapterFile ) } is missing; discovery must have written it before a row can be added` , [
1008 { code: 'missing_coverage_file' , coverageId: null , message: 'adapter coverage file missing' , sources: [] },
1009 ]);
1010 }
1011 const file = validateCoverageFile (raw);
1012 if (file.adapter !== adapter) throw new SourceCoverageError ( `adapter coverage file names adapter ${ file . adapter }, not ${ adapter }` );
1013 const merged = [ ... file.rows];
1014 let added = 0 ;
1015 let replaced = 0 ;
1016 for ( const [ index , candidate ] of rows. entries ()) {
1017 const normalized = normalizeCoverageRow (candidate, index, { platform: file.platform });
1018 const at = merged. findIndex (( existing ) => existing.coverageId === normalized.coverageId);
1019 if (at === - 1 ) { merged. push (normalized); added += 1 ; } else { merged[at] = normalized; replaced += 1 ; }
1020 }
1021 const next = validateCoverageFile ({ ... file, rows: merged });
1022 await writeJsonAtomic (adapterFile, { ... next, ... (raw.generatedAt ? { generatedAt: raw.generatedAt } : {}) });
1023 // Optional checkpoints let recovery tests stop between the two durable writes.
1024 if ( typeof checkpoint === 'function' ) await checkpoint ( 'after-row-upsert' );
1025 await promoteSourceCoverage (projectDir, { adapter });
1026 if ( typeof checkpoint === 'function' ) await checkpoint ( 'after-re-promotion' );
1027 return { added, replaced, rows: next.rows. length };
1028 }
1029
1030 // The rp-mapper decision writer. Recording a `sourceCoverage:` decision removes that row's gap
1031 // entries in the same write, so the normal recovery path never creates a disposition conflict.
1032 // Gaps are written first: an interruption between the two writes leaves a row with no
1033 // disposition (reconcile fails closed; re-running this fixes it), never a silent conflict.
1034 async function recordCoverageDecision ( projectDir , { coverageId , value , rationale = null , source = 'user' , timestamp = new Date (). toISOString () }) {
1035 requireString (coverageId, 'coverageId' );
1036 if ( ! DECISION_VALUES . includes (value)) throw new SourceCoverageError ( `decision value must be one of ${ DECISION_VALUES . join ( ', ' ) }` );
1037 const inputs = await loadCoverageInputs (projectDir);
1038 const file = validateCoverageFile (inputs.coverage);
1039 if ( ! file.rows. some (( row ) => row.coverageId === coverageId)) {
1040 throw new SourceCoverageError ( `unknown coverageId ${ coverageId }` );
1041 }
1042 const gapsPath = path. join (projectDir, MAPPING_GAPS_FILE );
1043 const rawGaps = await readJsonFile (gapsPath);
1044 let removed = 0 ;
1045 if (rawGaps !== undefined ) {
1046 const keep = ( gap ) => {
1047 if (gap && gap.coverageId === coverageId) { removed += 1 ; return false ; }
1048 return true ;
1049 };
1050 const next = Array. isArray (rawGaps) ? rawGaps. filter (keep)
1051 : rawGaps && Array. isArray (rawGaps.gaps) ? { ... rawGaps, gaps: rawGaps.gaps. filter (keep) } : rawGaps;
1052 if (removed) await writeJsonAtomic (gapsPath, next);
1053 }
1054 const decisionsPath = path. join (projectDir, DECISIONS_FILE );
1055 const decisions = ( await readJsonFile (decisionsPath)) || {};
1056 decisions[ `${ DECISION_PREFIX }${ coverageId }` ] = { value, decidedAt: timestamp, source, rationale };
1057 await writeJsonAtomic (decisionsPath, decisions);
1058 return { coverageId, value, removedGaps: removed };
1059 }
1060
1061 // Quick-mode `plan`: write the declared-exclusion rows, run the same reconcile check, and return
1062 // the stamp the plan must carry. Throws (so the plan is not written) when either fails.
1063 async function writeQuickPlanCoverage ( projectDir , contract , { rawText = null , generatedAt = new Date (). toISOString () } = {}) {
1064 const rows = buildQuickCoverageRows (contract, { rawText });
1065 const file = buildCoverageFile ({ platform: contract.platform, adapter: contract.id, rows });
1066 const result = reconcileSourceCoverage ({ coverage: file, gaps: [], decisions: {}, quickContract: contract });
1067 if ( ! result.ok) {
1068 throw new SourceCoverageError ( `quick-mode source coverage does not reconcile: ${ result . failures . map (( item ) => item . code ). join ( ', ' ) }` , result.failures);
1069 }
1070 await writeJsonAtomic (path. join (projectDir, SOURCE_COVERAGE_FILE ), { ... file, generatedAt });
1071 return { sourceCoverageContract: SOURCE_COVERAGE_CONTRACT , rows: rows. length };
1072 }
1073
1074 // Quick-mode `import`: the completion summary carries the same sourceCoverage[] field and
1075 // coverage status. Throws (so no summary is written) on a stamped plan with a missing or
1076 // unreconciled coverage file.
1077 async function quickSummaryCoverage ( projectDir ) {
1078 const summary = await summarizeProjectCoverage (projectDir, { mode: 'quick' });
1079 return {
1080 sourceCoverageRecorded: summary.sourceCoverageRecorded,
1081 ... (summary.sourceCoverageReason ? { sourceCoverageReason: summary.sourceCoverageReason } : {}),
1082 sourceCoverageStatus: summary.status,
1083 sourceCoverage: summary.sourceCoverage,
1084 sourceCoverageHeadline: summary.headline,
1085 };
1086 }
1087
1088 module . exports = {
1089 writeQuickPlanCoverage,
1090 quickSummaryCoverage,
1091 SOURCE_COVERAGE_SCHEMA_VERSION,
1092 SOURCE_COVERAGE_CONTRACT,
1093 COVERAGE_EMITTERS,
1094 QUICK_ADAPTERS,
1095 ADAPTER_RAW_DIRS,
1096 SHOPIFY_CAPABILITIES,
1097 SOURCE_COVERAGE_FILE,
1098 ADAPTER_COVERAGE_FILE_NAME,
1099 SOURCE_SCHEMA_FILE,
1100 QUICK_PLAN_FILE,
1101 MAPPING_GAPS_FILE,
1102 DECISIONS_FILE,
1103 COVERAGE_REASONS,
1104 RECOVERABILITY,
1105 DECISION_PREFIX,
1106 PRE_CONTRACT_REASON,
1107 PRE_CONTRACT_HEADLINE,
1108 SourceCoverageError,
1109 coverageIdFor,
1110 normalizeCoverageRow,
1111 validateCoverageFile,
1112 buildCoverageFile,
1113 buildWordPressCoverageRows,
1114 buildCsvCoverageRows,
1115 buildShopifyCoverageRows,
1116 buildQuickCoverageRows,
1117 validateQuickExcludedCapabilities,
1118 duplicateExcludedCapabilityKeys,
1119 reconcileSourceCoverage,
1120 summarizeSourceCoverage,
1121 readCoverageContract,
1122 loadCoverageInputs,
1123 loadRegisters,
1124 reconcileProject,
1125 summarizeProjectCoverage,
1126 promoteSourceCoverage,
1127 upsertAdapterCoverageRows,
1128 recordCoverageDecision,
1129 writeJsonAtomic,
1130 };