Setting the file. One moment.
Import Contract Verify · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page 240
async function verifyEntrypoint
— line 240
This file
Number 45.15
Position 15 of 182
Type JavaScript
Size 20 KB
Lines 363 scripts/ import-contract-verify.js
JavaScript · 363 lines · 20 KB
15 // The probe writes only an isolated temporary review store and touches no target.
16
17 const fsSync = require ( 'node:fs' );
18 const fs = require ( 'node:fs/promises' );
19 const os = require ( 'node:os' );
20 const crypto = require ( 'node:crypto' );
21 const review = require ( '../lib/migration-review.js' );
22 const path = require ( 'node:path' );
23 const { spawnSync } = require ( 'node:child_process' );
24 const { verifyPluginHandoff } = require ( '../lib/generated-plugin-handoff.js' );
25 const { parseSelectionArgs } = require ( '../resources/rp-source-wordpress/lib/plugin-entity-read-plan.js' );
26 const { assertGeneratedImporterContract } = require ( '../lib/import-recovery.js' );
27 const { dependencyModeErrors } = require ( '../lib/dependency-readiness.js' );
28 const { missingWriterErrors , rawRequestTargets } = require ( '../lib/target-import-support.js' );
29 const { mappingScopes } = require ( '../lib/migration-review-mapping.js' );
30 const { files } = require ( '../lib/import-readiness-contract.js' );
31 const { createProgressLogger , parseProgressArgs } = require ( '../lib/progress-log.js' );
32
33 const DEFAULT_ENTRYPOINTS = [ 'src/import/run-import.js' ];
34 const DESCRIBE_TIMEOUT_MS = 30000 ;
35
36 let progress;
37
38 function usage () {
39 console. error ( `Usage:
40 node skills/wix-replatform/scripts/import-contract-verify.js <projectDir> [--entrypoint <path>]
41
42 First checks generated code and the execution manifest for values a toolkit function refuses
43 at runtime (verificationKind and the other CONTRACT_ENUMS). Then spawns each
44 generated import entrypoint with --describe, validates the descriptor, refuses
45 generated sources that bypass shared DRY_RUN or held-scope registration, then runs its isolated
46 --migration-review-probe to exercise capture/publication, selected+held scope registration and
47 an injected failing write's recovery capture.
48 Exits non-zero naming missing modes, paths or review hooks. Writes temporary probe state only, touches no target.
49
50 --entrypoint may be repeated; paths are resolved against <projectDir>. When omitted, the
51 conventional entrypoint ${ DEFAULT_ENTRYPOINTS . join ( ', ' ) } is used.
52 ` );
53 }
54
55 function parseArgs ( argv ) {
56 const args = { entrypoints: [], selection: {} };
57 for ( let i = 0 ; i < argv. length ; i += 1 ) {
58 const token = argv[i];
59 if (token === '--' ) {
60 args.selection = parseSelectionArgs (argv. slice (i + 1 ));
61 break ;
62 } else if (token === '--extract-entrypoint' ) {
63 args.extractEntrypoint = argv[ ++ i];
64 } else if (token === '--entrypoint' ) {
65 args.entrypoints. push (argv[i + 1 ]);
66 i += 1 ;
67 } else if ( ! args.projectDir) {
68 args.projectDir = token;
69 } else {
70 throw new Error ( `unknown argument: ${ token }` );
71 }
72 }
73 return args;
74 }
75
76 // The entrypoint prints ONE JSON object. Generated importers habitually log a banner line
77 // first, so take the last balanced top-level object rather than demanding a pristine stdout —
78 // but never guess: unparsable stdout is a gate failure, not a pass.
79 function parseDescriptor ( stdout ) {
80 const trimmed = String (stdout || '' ). trim ();
81 if ( ! trimmed) {
82 throw new Error ( 'printed nothing on --describe (an entrypoint that ignores --describe fails the gate)' );
83 }
84 const start = trimmed. indexOf ( '{' );
85 const end = trimmed. lastIndexOf ( '}' );
86 if (start < 0 || end <= start) {
87 throw new Error ( `printed no JSON object on --describe: ${ trimmed . slice ( 0 , 200 ) }` );
88 }
89 try {
90 return JSON . parse (trimmed. slice (start, end + 1 ));
91 } catch (error) {
92 throw new Error ( `printed unparsable JSON on --describe: ${ error . message }` );
93 }
94 }
95
96 // Blank JS comments so a comment naming DRY_RUN is not a bypass. Strings, templates and regex
97 // literals are kept (only skipped over), so a '//' inside one never hides the code after it.
98 function stripComments ( text ) {
99 let out = '' , i = 0 , prev = '' ;
100 while (i < text. length ) {
101 const c = text[i], n = text[i + 1 ];
102 if (c === '/' && n === '/' ) { while (i < text. length && text[i] !== ' \n ' ) { out += ' ' ; i += 1 ; } continue ; }
103 if (c === '/' && n === '*' ) { const end = text. indexOf ( '*/' , i + 2 ), stop = end < 0 ? text. length : end + 2 ; out += text. slice (i, stop). replace ( / [ ^ \n] / g , ' ' ); i = stop; continue ; }
104 const regex = c === '/' && ( ! prev || / [(,=:[!&|?{};+\-*%<>~^] / . test (prev));
105 if (c === '"' || c === "'" || c === '`' || regex) {
106 let j = i + 1 , cls = false ;
107 while (j < text. length && (text[j] !== c || cls) && ! (c !== '`' && text[j] === ' \n ' )) {
108 if (text[j] === ' \\ ' ) j += 1 ; else if (regex && text[j] === '[' ) cls = true ; else if (regex && text[j] === ']' ) cls = false ;
109 j += 1 ;
110 }
111 out += text. slice (i, j + 1 ); i = j + 1 ; prev = c; continue ;
112 }
113 out += c; if ( ! / \s / . test (c)) prev = c; i += 1 ;
114 }
115 return out;
116 }
117 // Source shapes that bypass shared runtime decisions. These are refusals, never proofs of compliance:
118 // a bounded text backstop over comment-stripped source. Broader (AST/flow) analysis is deferred.
119 const RUNTIME_BYPASSES = [
120 [ /process \. env(?: \. DRY_RUN \b| \[ \s * ['"`] DRY_RUN) |\b DRY_RUN \b [ ^ }] * \} \s * = \s * process \. env/ , 'dry_run_config_bypass' ],
121 [ /mappingScopes \( [ ^ )] * resolvedEntityRefs/ , 'held_scope_registration_omitted' ],
122 // Unmarked requests the import client refuses; a native entity with no writer uses guarded raw requests.
123 // Only calls through a module object or a destructured import: the vendored toolkit's own
124 // definitions and name lists are not uses.
125 [ / \. \s * (?:sendDirectRest | buildDirectRestRequest) \s * \( | \{ [ ^ }] *\b (?:sendDirectRest | buildDirectRestRequest) \b [ ^ }] * \} \s * = \s * require/ , 'direct_rest_write_refused' ],
126 ];
127 // A retired builder hold code (target knowledge `retiredHoldCodes`) as a string literal in generated
128 // code: the builder still holds records a shipped route now writes. Refused at codegen, with the
129 // file, so codegen QA fixes the builder before the read-back inventory refuses it.
130 function retiredHoldUses ( text ) {
131 const codes = Object. keys ( require ( '../resources/rp-target-wix/lib/domain-knowledge.js' ). retiredHoldCodes ());
132 return codes. filter ( code => new RegExp ( `(['" \` ])${ code } \\ 1` ). test (text));
133 }
134 function verifyRuntimeUse ( projectDir ) {
135 const errors = [];
136 for ( const file of files (path. join (projectDir, 'src' ))) {
137 const text = stripComments (fsSync. readFileSync (file, 'utf8' ));
138 for ( const [ pattern , code ] of RUNTIME_BYPASSES ) if (pattern. test (text)) errors. push ( `${ code }:${ path . relative ( projectDir , file ) }` );
139 for ( const code of retiredHoldUses (text)) errors. push ( `retired_hold_code:${ code }:${ path . relative ( projectDir , file ) }` );
140 }
141 return errors;
142 }
143 // Values a toolkit function refuses at runtime, checked in the generated code at
144 // codegen. Each entry asks the runtime function itself (one source of truth, no copied list), so
145 // this refuses only what the import would crash on later -- it moves that crash earlier, with the
146 // file and line. Add an entry when a toolkit function throws on a value generated code passes.
147 const { verificationKind } = require ( '../lib/entity-verification.js' );
148 const CONTRACT_ENUMS = [
149 { key: 'verificationKind' , owner: 'lib/entity-verification.js verificationKind()' , refusal : ( value ) => { try { verificationKind ({ verificationKind: value }); return null ; } catch (error) { return error.message; } } },
150 ];
151 function verifyStaticContract ( projectDir ) {
152 const errors = [];
153 let manifest = null ;
154 try { manifest = JSON . parse (fsSync. readFileSync (path. join (projectDir, 'execution/execution-manifest.json' ), 'utf8' )); } catch { /* the entrypoint check reports a missing manifest */ }
155 for ( const task of manifest?.tasks || []) for ( const { key , owner , refusal } of CONTRACT_ENUMS ) {
156 const refused = task[key] !== undefined && refusal (task[key]);
157 if (refused) errors. push ( `contract_enum_invalid:execution/execution-manifest.json task ${ task . id } ${ key } ${ JSON . stringify ( task [ key ]) } (${ owner }: ${ refused })` );
158 }
159 for ( const file of [ ... files (path. join (projectDir, 'src' )), ... files (path. join (projectDir, 'scripts' ))]) {
160 const lines = stripComments (fsSync. readFileSync (file, 'utf8' )). split ( ' \n ' );
161 lines. forEach (( line , index ) => {
162 for ( const { key , owner , refusal } of CONTRACT_ENUMS ) for ( const match of line. matchAll ( new RegExp ( ` \\ b${ key }['"]? \\ s*: \\ s*(['" \` ])([^'" \` ]*) \\ 1` , 'g' ))) {
163 const refused = refusal (match[ 2 ]);
164 if (refused) errors. push ( `contract_enum_invalid:${ path . relative ( projectDir , file ) }:${ index + 1 } ${ key } ${ JSON . stringify ( match [ 2 ]) } (${ owner }: ${ refused })` );
165 }
166 });
167 }
168 return errors;
169 }
170 // A gated entity (entity decision with a readBackContract) whose generated code does
171 // not call the toolkit's runGatedEntity. A warning shown with the plan, never a refusal: a
172 // hand-wired ReadBackGate that works is not blocked. Names the decision file and, when the code
173 // wires the gate by hand, the first file and line that constructs it.
174 function verifyGateWiring ( projectDir ) {
175 const { DOMAINS , decisionFileName } = require ( '../lib/read-back-contract.js' );
176 let plan = null ;
177 try { plan = JSON . parse (fsSync. readFileSync (path. join (projectDir, 'mapping/mapping-plan.json' ), 'utf8' )); } catch { /* generated-contract checks report missing plans */ }
178 const gated = Object. keys ( DOMAINS ). filter (( entity ) => {
179 try {
180 const file = decisionFileName (projectDir, entity);
181 return Boolean ( JSON . parse (fsSync. readFileSync (path. join (projectDir, `mapping/entity-decisions/${ file }` ), 'utf8' )).readBackContract);
182 } catch { return false ; }
183 });
184 if ( ! gated. length ) return [];
185 const sources = [ ... files (path. join (projectDir, 'src' )), ... files (path. join (projectDir, 'scripts' ))]
186 // The vendored gate module defines both names; it is not a use.
187 . filter (( file ) => path. basename (file) !== 'read-back-gate.js' )
188 . map (( file ) => ({ file: path. relative (projectDir, file), lines: stripComments (fsSync. readFileSync (file, 'utf8' )). split ( ' \n ' ) }));
189 const at = ( pattern ) => { for ( const { file , lines } of sources) { const index = lines. findIndex (( line ) => pattern. test (line)); if (index >= 0 ) return `${ file }:${ index + 1 }` ; } return null ; };
190 const toolkit = at ( / \b runGatedEntity \s * \( / ), handWired = at ( / \b new \s + (?: [\w$.] + \. ) ? ReadBackGate \s * \( / );
191 if (toolkit) return [];
192 return gated. map (( entity ) => ({
193 code: 'read_back_gate_not_toolkit_owned' ,
194 entity,
195 decision: `mapping/entity-decisions/${ decisionFileName ( projectDir , entity ) }` ,
196 location: handWired,
197 message: handWired
198 ? `${ entity }: the read-back gate is wired by hand (${ handWired }); call runGatedEntity from lib/read-back-gate.js instead.`
199 : `${ entity }: no generated code runs the read-back gate; call runGatedEntity from lib/read-back-gate.js, or completion reports gate-not-run.` ,
200 }));
201 }
202 function expectedMappingScopes ( plan ) {
203 return plan?.fieldContractVersion === 1 && plan.executionScope?.selectedEntityRefs?. length ? mappingScopes (plan) : [];
204 }
205
206 async function verifyReviewProbe ( absolute , relativePath , descriptor , mappingPlan ) {
207 if (descriptor?.migrationReview?.probeFlag !== '--migration-review-probe' ) {
208 return { entrypoint: relativePath, ok: false , error: 'migration review probe flag is missing from the descriptor' };
209 }
210 const scratch = await fs. mkdtemp (path. join (os. tmpdir (), 'rp-review-probe-' ));
211 const nonce = crypto. randomUUID ();
212 try {
213 const probeEnv = Object. fromEntries ([ 'PATH' , 'HOME' , 'TMPDIR' , 'TEMP' , 'TMP' , 'LANG' , 'LC_ALL' , 'SystemRoot' ]. filter (( key ) => process.env[key] !== undefined ). map (( key ) => [key, process.env[key]]));
214 const probed = spawnSync (process.execPath, [absolute, '--migration-review-probe' , '--project' , scratch], {
215 cwd: scratch, encoding: 'utf8' , timeout: DESCRIBE_TIMEOUT_MS ,
216 env: { ... probeEnv, MIGRATION_REVIEW_PROBE_PROJECT: scratch, MIGRATION_REVIEW_PROBE_NONCE: nonce, MIGRATION_REVIEW_LIB: path. resolve (__dirname, '../lib/migration-review.js' ) },
217 });
218 if (probed.error || probed.status !== 0 ) throw new Error ((probed.stderr || probed.stdout || probed.error?.message || `exit ${ probed . status }` ). trim (). slice ( 0 , 400 ));
219 const response = parseDescriptor (probed.stdout);
220 const report = await review. readReport (scratch);
221 const proof = await review. readExecutionEvidence (scratch, response.executionId);
222 if (proof.execution.executionKind !== 'probe' ) throw new Error ( 'isolated execution must identify itself as a probe' );
223 if (report.migrationId !== `probe_${ nonce }` || report.destination.siteId !== 'probe-site' || report.source.identity !== `probe_${ nonce }` || report.coverage.status !== 'complete' || ! report.evidenceRevision) throw new Error ( 'isolated publication is missing or incomplete' );
224 if ( ! report.results. some (( row ) => row.probeNonce === nonce)) throw new Error ( 'publication did not preserve probe evidence' );
225 for ( const category of review. EVIDENCE_CATEGORIES ) {
226 if ( ! proof.captures. some (( row ) => row.category === category && row.evidence.probeNonce === nonce)) throw new Error ( `no committed capture for ${ category }` );
227 }
228 const held = expectedMappingScopes (mappingPlan). filter (( scope ) => ! proof.execution.assessedScope. includes (scope));
229 if (held. length ) throw new Error ( `registration omits selected/held mapping scopes: ${ held . join ( ', ' ) }` );
230 if ( ! proof.captures. some (( row ) => row.category === 'execution_recovery' && row.phase === 'recovery' && row.evidence.kind === 'write-failure' && row.evidence.probeNonce === nonce && row.evidence.outcomes?.[ 0 ]?.reasonCode === review. PROBE_WRITE_FAILURE )) throw new Error ( 'injected failing write committed no execution_recovery capture through createWriteRecovery' );
231 const state = JSON . parse ( await fs. readFile (path. join (review. reviewRoot (scratch), 'state.json' ), 'utf8' ));
232 const committed = Object. values (state.committedRequests);
233 if ( ! committed. some (( row ) => row.operation === 'publish-evidence' ) || committed. filter (( row ) => row.operation === 'capture-evidence' ). length < review. EVIDENCE_CATEGORIES . length ) throw new Error ( 'capture/publication receipts are not committed' );
234 return null ;
235 } catch (error) {
236 return { entrypoint: relativePath, ok: false , error: `migration review probe failed: ${ error . message }` };
237 } finally { await fs. rm (scratch, { recursive: true , force: true }); }
238 }
239
240 async function verifyEntrypoint ( projectDir , relativePath ) {
241 const absolute = path. resolve (projectDir, relativePath);
242 if ( ! fsSync. existsSync (absolute)) {
243 return { entrypoint: relativePath, ok: false , error: 'entrypoint not found' };
244 }
245 const described = spawnSync (process.execPath, [absolute, '--describe' ], {
246 cwd: projectDir,
247 encoding: 'utf8' ,
248 timeout: DESCRIBE_TIMEOUT_MS ,
249 });
250 if (described.error) {
251 return { entrypoint: relativePath, ok: false , error: `could not run --describe: ${ described . error . message }` };
252 }
253 if (described.status !== 0 ) {
254 const detail = (described.stderr || described.stdout || '' ). trim (). slice ( 0 , 400 );
255 return { entrypoint: relativePath, ok: false , error: `--describe exited ${ described . status }: ${ detail }` };
256 }
257 let descriptor, mappingPlanForProbe;
258 try {
259 descriptor = parseDescriptor (described.stdout);
260 } catch (error) {
261 return { entrypoint: relativePath, ok: false , error: error.message };
262 }
263 try {
264 const planFile = path. join (projectDir, 'mapping/mapping-plan.json' );
265 const mappingPlan = fsSync. existsSync (planFile) ? JSON . parse (fsSync. readFileSync (planFile, 'utf8' )) : null ;
266 assertGeneratedImporterContract (descriptor,{mappingPlan,projectDir});
267 const readiness = require ( '../lib/import-readiness-contract' ). verifyReadinessContract (projectDir, descriptor);
268 // A stale generated manifest cannot pass by matching its own stale descriptor.
269 const manifest = JSON . parse (fsSync. readFileSync (path. join (projectDir, 'execution/execution-manifest.json' ), 'utf8' ));
270 const modes = [ ... dependencyModeErrors (projectDir, manifest), ... missingWriterErrors (manifest)];
271 const bypasses = verifyRuntimeUse (projectDir);
272 if ( ! readiness.ok || modes. length || bypasses. length ) throw new Error ([ ... readiness.errors, ... modes, ... bypasses]. join ( ';' ));
273 mappingPlanForProbe = mappingPlan;
274 } catch (error) {
275 return { entrypoint: relativePath, ok: false , error: error.message };
276 }
277 const probeFailure = await verifyReviewProbe (absolute, relativePath, descriptor, mappingPlanForProbe);
278 if (probeFailure) return probeFailure;
279 return { entrypoint: relativePath, ok: true , error: null };
280 }
281
282 async function main () {
283 const parsed = parseProgressArgs (process.argv. slice ( 2 ));
284 progress = createProgressLogger ({
285 script: 'skills/wix-replatform/scripts/import-contract-verify.js' ,
286 ... parsed.progress,
287 });
288 progress. start ( 'Import contract verification started' , { phase: 'execution' , step: 'verify-import-contract' });
289
290 const args = parseArgs (parsed.args);
291 if ( ! args.projectDir) {
292 usage ();
293 progress. error ( 'Missing project directory' , { phase: 'execution' , step: 'verify-import-contract' });
294 process. exit ( 1 );
295 }
296
297 const projectDir = path. resolve (args.projectDir);
298 const entrypoints = args.entrypoints. length ? args.entrypoints : DEFAULT_ENTRYPOINTS ;
299 // Inspect canonical inventory and exact generated bytes BEFORE executing --describe.
300 const plugin = verifyPluginHandoff (projectDir, args.selection);
301 if (plugin.applicable && entrypoints. some (( entrypoint ) => entrypoint !== 'src/import/run-import.js' )) throw new Error ( 'unsupported generated plugin importer entrypoint' );
302 if (plugin.applicable && args.extractEntrypoint && args.extractEntrypoint !== 'src/extract/run-extract.js' ) throw new Error ( 'unsupported generated plugin extractor entrypoint' );
303 const results = [];
304 // Static first: no entrypoint runs while the generated code passes values the toolkit refuses.
305 const staticErrors = verifyStaticContract (projectDir);
306 if (staticErrors. length ) results. push ({ entrypoint: '(static contract)' , ok: false , error: staticErrors. join ( ';' ) });
307 else for ( const entrypoint of entrypoints) results. push ( await verifyEntrypoint (projectDir, entrypoint));
308 const failures = results. filter (( result ) => ! result.ok);
309
310 // Entities written by guarded raw requests, for the plan shown before consent.
311 let rawRequests = [];
312 try { rawRequests = rawRequestTargets ( JSON . parse (fsSync. readFileSync (path. join (projectDir, 'execution/execution-manifest.json' ), 'utf8' ))); } catch { /* the entrypoint check reports a missing manifest */ }
313 // Expected CMS items against the site's quota, for the same plan. Shown, never a gate.
314 let cmsItemQuota = null ;
315 try { cmsItemQuota = JSON . parse (fsSync. readFileSync (path. join (projectDir, 'execution/cms-item-quota-plan.json' ), 'utf8' )); } catch { /* no custom-collection rows planned, or not read yet */ }
316 // Read-back gate wiring, for the same plan. Shown, never a gate.
317 const warnings = verifyGateWiring (projectDir);
318 console. log ( JSON . stringify ({
319 ok: failures. length === 0 ,
320 projectDir,
321 entrypoints: results,
322 rawRequestTargets: rawRequests,
323 cmsItemQuota,
324 warnings,
325 }, null , 2 ));
326
327 if (failures. length ) {
328 for ( const failure of failures) {
329 console. error ( `${ failure . entrypoint }: ${ failure . error }` );
330 }
331 console. error ( 'Import aborted: the generated importer does not satisfy the entry contract. Route back to codegen — there is no degraded mode and no skip flag.' );
332 progress. error ( 'Import contract verification failed' , {
333 phase: 'execution' ,
334 step: 'verify-import-contract' ,
335 artifact: projectDir,
336 count: failures. length ,
337 unit: 'entrypoints' ,
338 });
339 process. exit ( 1 );
340 }
341
342 progress. complete ( 'Import contract verification passed' , {
343 phase: 'execution' ,
344 step: 'verify-import-contract' ,
345 artifact: projectDir,
346 count: results. length ,
347 unit: 'entrypoints' ,
348 });
349 process. exit ( 0 );
350 }
351
352 if (require.main === module ) main (). catch (( error ) => {
353 if (progress) {
354 progress. error (error && error.message ? error.message : 'Import contract verification failed' , {
355 phase: 'execution' ,
356 step: 'verify-import-contract' ,
357 });
358 }
359 console. error (error && error.message ? error.message : error);
360 process. exit ( 1 );
361 });
362 // The source refusals, for the cross-gate test.
363 module . exports = { RUNTIME_BYPASSES, CONTRACT_ENUMS, verifyStaticContract, verifyGateWiring, verifyRuntimeUse };