Setting the file. One moment.
Entity QA Attestation · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page
Number 45.75
Position 75 of 182
Type JavaScript
Size 19 KB
Lines 370 lib/ entity-qa-attestation.js
JavaScript · 370 lines · 19 KB
// The gate proves freshness and coverage, not that the QA pass actually happened. A hollow attestation that copies the report's own
15 // counts and marks every blocked row reverified still passes. That is an accepted limit, not
16 // a defect -- `isSetupVerified` has the identical limit already.
17
18 const SCHEMA_VERSION = 1 ;
19
20 const ENTITY_QA_ATTESTATION_FILE = 'execution/qa/entity-qa-attestation.json' ;
21
22 // The ten fields `completion-report.js`'s `normalizeCompletenessRow` writes to every
23 // `entityCompleteness[]` row. `reconciled`/`expectedReconciled`/`mismatch` are pure functions
24 // of these and are deliberately not attested separately.
25 const FULL_MODE_FIELDS = [
26 'extracted' ,
27 'inScope' ,
28 'attempted' ,
29 'imported' ,
30 'alreadyPresentByCrosswalk' ,
31 'deferred' ,
32 'failed' ,
33 'unresolved' ,
34 'skippedOutOfScope' ,
35 'unexpectedSkipped' ,
36 ];
37
38 const QUICK_MODE_FIELDS = [ 'imported' , 'skipped' ];
39
40 // The report entity prefix of a mapped entity's source-archive sub-part row.
41 const ARCHIVE_SUB_PART_PREFIX = 'archive.' ;
42
43 function entityKey ( entity , subtype ) {
44 return `${ entity }/${ subtype || 'all'}` ;
45 }
46
47 // Normalizes whatever shape the caller's independent mapped-entity source carries (full mode:
48 // `website-handoff.js`'s `readEntityMappings` rows, keyed by `sourceEntity`; quick mode:
49 // `quick-mode/plan.json`'s own `entities` rows, keyed by `id`) into a `key -> {entity,
50 // subtype}` map. Rows with no recognizable entity name are dropped, never counted as coverage.
51 // Returns `null` when the input itself isn't a usable array at all (as opposed to a usable,
52 // but empty-after-normalization, array) -- both cases resolve to "unavailable" one level up,
53 // but keeping them distinct helps a caller that wants to know which happened.
54 function normalizeMappedEntities ( mappedEntities ) {
55 if ( ! Array. isArray (mappedEntities)) return null ;
56 const map = new Map ();
57 for ( const row of mappedEntities) {
58 if ( ! row || typeof row !== 'object' ) continue ;
59 const entity = firstNonEmptyString (row.entity, row.sourceEntity, row.id);
60 if ( ! entity) continue ;
61 const subtype = typeof row.subtype === 'string' && row.subtype ? row.subtype : 'all' ;
62 map. set ( entityKey (entity, subtype), { entity, subtype });
63 }
64 return map;
65 }
66
67 // The report's entity vocabulary decides the coverage key. An importer-written report names
68 // mapped source entities; a report rebuilt from the attempt journal after an interruption
69 // (import-interruption.js) names Wix target refs, because journal rows carry no source entity.
70 // When a full-mode report names none of the mapped source entities but names their target refs,
71 // coverage is keyed by target ref (one key per distinct target). A report that names source
72 // entities keeps the source keys, so the two vocabularies are never mixed.
73 function mappedForReport ( mapped , mappedEntities , report ) {
74 if ( ! mapped || ! mapped.size || ! report || report.managementImportMode === 'quick' || ! Array. isArray (report.entityCompleteness)) return mapped;
75 const named = new Set (report.entityCompleteness. map (( row ) => row && row.entity). filter (Boolean));
76 if ([ ... mapped. values ()]. some (({ entity }) => named. has (entity))) return mapped;
77 const targets = [ ...new Set (mappedEntities. map (( row ) => row && typeof row.targetRef === 'string' && row.targetRef ? row.targetRef : null ). filter (Boolean))];
78 if ( ! targets. some (( target ) => named. has (target))) return mapped;
79 return normalizeMappedEntities (targets. map (( entity ) => ({ entity })));
80 }
81
82 function firstNonEmptyString ( ... values ) {
83 for ( const value of values) {
84 if ( typeof value === 'string' && value) return value;
85 }
86 return null ;
87 }
88
89 // Indexes a full-mode report's `entityCompleteness[]` rows by `entity/subtype`. A row missing
90 // `entity` entirely can never be matched against anything and is dropped, same as
91 // `normalizeMappedEntities`.
92 function indexFullModeReportRows ( report ) {
93 const map = new Map ();
94 const rows = report && Array. isArray (report.entityCompleteness) ? report.entityCompleteness : [];
95 for ( const row of rows) {
96 if ( ! row || typeof row !== 'object' || ! row.entity) continue ;
97 const subtype = row.subtype || 'all' ;
98 map. set ( entityKey (row.entity, subtype), row);
99 }
100 return map;
101 }
102
103 function numericFieldValue ( value ) {
104 return typeof value === 'number' && Number. isFinite (value) ? value : 0 ;
105 }
106
107 // Quick mode's `imported`/`skipped` maps are keyed by plain entity id (no subtype). `skipped`
108 // may be an array of per-record skip reasons (as `rp-quick-shopify` writes) or entirely absent
109 // (as `rp-quick-woocommerce`'s summary is) -- either resolves to a count, and an absent map
110 // resolves to 0 for every entity, never a mismatch (the WooCommerce quick-mode case).
111 function quickModeCount ( container , entity ) {
112 if ( ! container || typeof container !== 'object' ) return 0 ;
113 const value = container[entity];
114 if (Array. isArray (value)) return value. length ;
115 return numericFieldValue (value);
116 }
117
118 function quickModeEntityPresent ( container , entity ) {
119 return !! (container && typeof container === 'object' && Object . prototype .hasOwnProperty. call (container, entity));
120 }
121
122 // A duplicate `entity/subtype` row must not silently let the
123 // last one win -- `setup-verification.js`'s `isSetupVerified`, which this validator mirrors,
124 // flags a duplicate requirement id instead of dropping it quietly (`normalizeIds`'s
125 // `onDuplicate`). `duplicateKeys` collects every key seen more than once so the caller can
126 // raise a finding for it.
127 //
128 // `normalizeIds` keeps the FIRST occurrence of
129 // a duplicate id for everything downstream (`seen.has(id)` short-circuits every later one via
130 // `continue`, before it can overwrite anything) -- it does not compare values at all, but the
131 // keep-first discipline is the behavior this mirrors. This indexer now keeps the first
132 // occurrence of a duplicate key too (the finding is raised regardless, and a duplicate already
133 // fails the gate on its own either way, but the row used for the count comparison -- and
134 // reported in `entities` -- is now deterministically the first one seen, not the last).
135 function indexAttestationEntities ( attestation ) {
136 const map = new Map ();
137 const duplicateKeys = new Set ();
138 for ( const row of attestation.entities) {
139 if ( ! row || typeof row !== 'object' || ! row.entity) continue ;
140 const subtype = row.subtype || 'all' ;
141 const key = entityKey (row.entity, subtype);
142 if (map. has (key)) {
143 duplicateKeys. add (key);
144 continue ;
145 }
146 map. set (key, row);
147 }
148 return { map, duplicateKeys };
149 }
150
151 // `validateEntityQaAttestation({ attestation, report, reportDigest, mappedEntities })`.
152 //
153 // Returns `{ ok, reasons, qaStatus, entities, recheck }`.
154 // - `qaStatus`: 'missing' | 'stale' | 'failed' | 'attested'. Precedence: missing wins over
155 // stale, stale wins over failed -- a stale record's per-entity findings describe an old
156 // report, so they are never computed or listed.
157 // - `entities`: for `qaStatus: 'failed'`, one entry per affected `{entity, subtype, reasons}`.
158 // Empty for every other status.
159 // - `recheck`: 'full' for missing/stale, 'entities' for failed, `null` once attested.
160 function validateEntityQaAttestation ({ attestation , report , reportDigest , mappedEntities }) {
161 // 1. The attestation file itself: missing (or unreadable -- a malformed file is handed in by
162 // the caller's tolerant load as `null`, same as a missing one) or
163 // schema-invalid.
164 if ( ! attestation || typeof attestation !== 'object' ) {
165 return { ok: false , reasons: [ 'entity_qa_attestation_missing' ], qaStatus: 'missing' , entities: [], recheck: 'full' };
166 }
167 if (attestation.schemaVersion !== SCHEMA_VERSION || ! Array. isArray (attestation.entities)) {
168 return { ok: false , reasons: [ 'entity_qa_attestation_schema_invalid' ], qaStatus: 'missing' , entities: [], recheck: 'full' };
169 }
170
171 // 2. There must be a usable completion report and a digest to compare against -- never
172 // "nothing to cover".
173 if ( ! report || typeof report !== 'object' || ! reportDigest) {
174 return { ok: false , reasons: [ 'entity_qa_report_unavailable' ], qaStatus: 'missing' , entities: [], recheck: 'full' };
175 }
176
177 // 3. The independent coverage source must itself be usable. An empty list is exactly as
178 // unusable as a missing one -- it is never a legitimate "nothing was mapped".
179 const mapped = mappedForReport ( normalizeMappedEntities (mappedEntities), mappedEntities, report);
180 if ( ! mapped || mapped.size === 0 ) {
181 return { ok: false , reasons: [ 'entity_qa_mapped_entities_unavailable' ], qaStatus: 'missing' , entities: [], recheck: 'full' };
182 }
183
184 // 4. Freshness: the attestation must name the digest of the report that is actually
185 // shipping. A later report rewrite reopens the whole review -- no per-entity findings are
186 // computed for a stale record, since they would describe a report that no longer exists.
187 if (attestation.completionReportDigest !== reportDigest) {
188 return { ok: false , reasons: [ 'entity_qa_attestation_stale' ], qaStatus: 'stale' , entities: [], recheck: 'full' };
189 }
190
191 // 5. Coverage and counts, judged two ways that together close two different gaps (see the comment above `reportRowsByBaseEntity` below for the second
192 // half). Neither judgment is against the attestation's or report's own entity list taken
193 // alone -- both comparands (`mapped` and, for full mode, the report's own
194 // `entityCompleteness`) are sources the attestation under test cannot have produced itself.
195 //
196 // `mapped` keys are always `<entity>/all`: `mapping-plan.json`'s schema (`readEntityMappings`
197 // in website-handoff.js) documents entities, not write-path subtypes -- subtyping is an
198 // execution/reporting concern that `rp-import-codegen/SKILL.md` requires only "whenever
199 // source records with the same entity map through materially different write paths or
200 // deferral rules, for example simple vs subscription Stores products". A full-mode run that
201 // legitimately splits a mapped entity's `entityCompleteness` rows by subtype therefore
202 // produces attested rows like `product/simple` and `product/subscription` that can never
203 // exact-key-match the plan's `product/all` -- comparing exact keys would flag such a row as
204 // BOTH missing (`product/all` absent) and unexpected (`product/simple` not in the plan)
205 // simultaneously, permanently blocking a correct migration. Coverage against `mapped`
206 // and uniqueness are therefore judged at the base-entity level: `mapped` only decides WHICH
207 // BASE ENTITIES are in scope at all, never which subtypes are required (the plan doesn't
208 // know). Which subtypes are required is then judged separately, against the report itself
209 // (below) -- closing the gap where an attestation that covers only some of an in-scope
210 // entity's real subtype rows used to read as fully covered. Only the per-row count
211 // comparison uses the full `entity/subtype` key, matching whatever subtype breakdown the
212 // attestation and report actually carry.
213 const isQuickMode = report.managementImportMode === 'quick' ;
214 const { map : attested , duplicateKeys } = indexAttestationEntities (attestation);
215 const fullModeReportRows = isQuickMode ? null : indexFullModeReportRows (report);
216
217 const findings = new Map (); // key -> { entity, subtype, reasons: [] }
218 const findingsFor = ( key , entity , subtype ) => {
219 if ( ! findings. has (key)) findings. set (key, { entity, subtype, reasons: [] });
220 return findings. get (key);
221 };
222
223 // A duplicate attested row for the same entity/subtype is a finding, not a silent
224 // last-one-wins resolution.
225 for ( const key of duplicateKeys) {
226 const row = attested. get (key);
227 findingsFor (key, row.entity, row.subtype || 'all' ).reasons. push ( `entity_qa_duplicate_attestation:${ key }` );
228 }
229
230 const mappedBaseEntities = new Set ([ ... mapped. values ()]. map (({ entity }) => entity));
231 // A mapped entity's source archive is a declared sub-part of that entity, not a separate mapped
232 // entity: rp-import-codegen reports it as its own `archive.<entity>` row (subtype
233 // `source-archive`, the MigrationSourceArchive cms/data-item rows), never folded into the entity
234 // row. When the full-mode report carries such a row for a mapped entity, it is attested under
235 // that entity exactly like a subtype row -- required, count-compared, never "unexpected". An
236 // `archive.*` row whose base entity is not mapped stays unexpected.
237 if ( ! isQuickMode) {
238 for ( const row of fullModeReportRows. values ()) {
239 const entity = row.entity;
240 if ( typeof entity === 'string' && entity. startsWith ( ARCHIVE_SUB_PART_PREFIX ) && mappedBaseEntities. has (entity. slice ( ARCHIVE_SUB_PART_PREFIX . length ))) mappedBaseEntities. add (entity);
241 }
242 }
243 const attestedByBaseEntity = new Map (); // entity -> [{ key, subtype }]
244 for ( const [ key , row ] of attested) {
245 const subtype = row.subtype || 'all' ;
246 if ( ! attestedByBaseEntity. has (row.entity)) attestedByBaseEntity. set (row.entity, []);
247 attestedByBaseEntity. get (row.entity). push ({ key, subtype });
248 }
249
250 // The base-entity grouping above fixes a case (a legitimate
251 // subtype split must not be flagged as both missing and unexpected against a plan that only
252 // knows base entities) but, left as-is, opened a new gap -- an attestation covering only
253 // SOME of a mapped entity's real subtype rows read as fully covered, because "missing" was
254 // judged as "at least one attested row for this base entity" rather than "every subtype row
255 // that actually exists". A QA agent could silently omit an inconvenient subtype from
256 // `--entities-file` and the gate would still pass. Close it by indexing which subtype rows
257 // the full-mode report itself actually carries per base entity -- the report, not the plan,
258 // is authoritative on which subtypes exist (the plan never carries subtypes at all).
259 const reportRowsByBaseEntity = new Map (); // entity -> [{ key, subtype }]
260 if ( ! isQuickMode) {
261 for ( const [ key , row ] of fullModeReportRows) {
262 const subtype = row.subtype || 'all' ;
263 if ( ! reportRowsByBaseEntity. has (row.entity)) reportRowsByBaseEntity. set (row.entity, []);
264 reportRowsByBaseEntity. get (row.entity). push ({ key, subtype });
265 }
266 }
267
268 // Missing: for each mapped (in-scope) base entity, every subtype row the report actually
269 // carries for it must have a matching attested row -- this is what closes the partial-subtype gap,
270 // using the report (never the plan) to decide which subtypes are required. When the report
271 // carries no rows at all for a mapped entity (quick mode, which has no subtype concept and no
272 // `entityCompleteness`, or a full-mode entity the report's completeness data never touched),
273 // fall back to requiring at least one attested row under `entity/all` -- the original
274 // behavior, still needed so a genuinely-never-attested entity is still caught.
275 for ( const entity of mappedBaseEntities) {
276 const reportRows = ! isQuickMode ? reportRowsByBaseEntity. get (entity) : null ;
277 const attestedRows = attestedByBaseEntity. get (entity) || [];
278
279 if (reportRows && reportRows. length > 0 ) {
280 const attestedKeys = new Set (attestedRows. map (( row ) => row.key));
281 for ( const { key , subtype } of reportRows) {
282 if ( ! attestedKeys. has (key)) {
283 findingsFor (key, entity, subtype).reasons. push ( `entity_qa_missing_entity:${ key }` );
284 }
285 }
286 } else if (attestedRows. length === 0 ) {
287 const key = entityKey (entity, 'all' );
288 findingsFor (key, entity, 'all' ).reasons. push ( `entity_qa_missing_entity:${ key }` );
289 }
290 }
291 // Unexpected: an attested row whose base entity was never mapped at all -- a subtype split
292 // under a mapped entity is never flagged here, only an entity absent from the plan entirely.
293 for ( const [ entity , rows ] of attestedByBaseEntity) {
294 if (mappedBaseEntities. has (entity)) continue ;
295 for ( const { key , subtype } of rows) {
296 findingsFor (key, entity, subtype).reasons. push ( `entity_qa_unexpected_entity:${ key }` );
297 }
298 }
299
300 // Only attested rows under a mapped base entity ever reach a count comparison -- a
301 // missing/unexpected row already has its own finding above. A plain (unsubtyped) entity
302 // compares its single `entity/all` row, same as before; a subtype-split entity compares each
303 // of its subtype rows independently against the matching report row.
304 for ( const entity of mappedBaseEntities) {
305 const rows = attestedByBaseEntity. get (entity);
306 if ( ! rows || rows. length === 0 ) continue ;
307
308 for ( const { key , subtype } of rows) {
309 const attestedRow = attested. get (key);
310
311 if (isQuickMode) {
312 if ( ! quickModeEntityPresent (report.imported, entity)) {
313 findingsFor (key, entity, subtype).reasons. push ( `entity_qa_report_entity_missing:${ key }` );
314 continue ;
315 }
316 const reportImported = quickModeCount (report.imported, entity);
317 const reportSkipped = quickModeCount (report.skipped, entity);
318 const attestedImported = numericFieldValue (attestedRow.imported);
319 const attestedSkipped = numericFieldValue (attestedRow.skipped);
320 if (attestedImported !== reportImported) {
321 findingsFor (key, entity, subtype).reasons. push ( `entity_qa_count_mismatch:${ key }:imported` );
322 }
323 if (attestedSkipped !== reportSkipped) {
324 findingsFor (key, entity, subtype).reasons. push ( `entity_qa_count_mismatch:${ key }:skipped` );
325 }
326 if (attestedSkipped > 0 && attestedRow.blockedReasonsReverified !== true ) {
327 findingsFor (key, entity, subtype).reasons. push ( `entity_qa_blocked_reasons_not_reverified:${ key }` );
328 }
329 } else {
330 const reportRow = fullModeReportRows. get (key);
331 if ( ! reportRow) {
332 findingsFor (key, entity, subtype).reasons. push ( `entity_qa_report_entity_missing:${ key }` );
333 continue ;
334 }
335 for ( const field of FULL_MODE_FIELDS ) {
336 const attestedValue = numericFieldValue (attestedRow[field]);
337 const reportValue = numericFieldValue (reportRow[field]);
338 if (attestedValue !== reportValue) {
339 findingsFor (key, entity, subtype).reasons. push ( `entity_qa_count_mismatch:${ key }:${ field }` );
340 }
341 }
342 const blocked = numericFieldValue (attestedRow.deferred) > 0
343 || numericFieldValue (attestedRow.failed) > 0
344 || numericFieldValue (attestedRow.unresolved) > 0
345 || numericFieldValue (attestedRow.skippedOutOfScope) > 0 ;
346 if (blocked && attestedRow.blockedReasonsReverified !== true ) {
347 findingsFor (key, entity, subtype).reasons. push ( `entity_qa_blocked_reasons_not_reverified:${ key }` );
348 }
349 }
350 }
351 }
352
353 if (findings.size > 0 ) {
354 const entities = [ ... findings. values ()];
355 const reasons = entities. flatMap (( entry ) => entry.reasons);
356 return { ok: false , reasons, qaStatus: 'failed' , entities, recheck: 'entities' };
357 }
358
359 return { ok: true , reasons: [], qaStatus: 'attested' , entities: [], recheck: null };
360 }
361
362 module . exports = {
363 SCHEMA_VERSION,
364 ENTITY_QA_ATTESTATION_FILE,
365 FULL_MODE_FIELDS,
366 QUICK_MODE_FIELDS,
367 ARCHIVE_SUB_PART_PREFIX,
368 normalizeMappedEntities,
369 validateEntityQaAttestation,
370 };