Setting the file. One moment. Read Back Gate · Wix Replatform · wix/skills · Skills Docsscripts/read-back-gate.js
JavaScript·242 lines·12 KB
(
'../lib/read-back-contract'
);
10const state = require('../lib/read-back-state');
11const {
12 readExtract
13} = require('../lib/read-back-extract');
14function problem(code) {
15 return Object.assign(new Error(code), {
16 code
17 });
18}
19function parse(args) {
20 const [command, directory, ...flags] = args;
21 if (!['contract', 'inventory', 'validate', 'summary', 'draft-stubs'].includes(command) || !directory || directory.startsWith('--')) throw problem('usage');
22 const options = {};
23 for (let i = 0; i < flags.length; i += 2) {
24 const name = flags[i];
25 if (!['--entity', '--extract', '--builder', '--source-key', '--format', '--omissions'].includes(name) || !flags[i + 1] || flags[i + 1].startsWith('--') || Object.hasOwn(options, name)) throw problem('usage');
26 options[name] = flags[i + 1];
27 }
28 if (command !== 'summary' && !options['--entity']) throw problem('entity_required');
29 if (options['--entity']) C.entityKey(options['--entity']);
30 const allowed = command === 'inventory' ? ['--entity', '--extract', '--builder', '--source-key', '--format'] : command === 'contract' ? ['--entity','--omissions'] : ['--entity'];
31 if (Object.keys(options).some(key => !allowed.includes(key))) throw problem('usage');
32 if (options['--omissions'] && options['--omissions'] !== 'review') throw problem('usage');
33 return {
34 command,
35 projectDir: path.resolve(directory),
36 entity: options['--entity'],
37 options
38 };
39}
40async function projectFile(projectDir, supplied) {
41 const file = path.resolve(projectDir, supplied),
42 relative = path.relative(projectDir, file);
43 if (!relative || relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) throw problem('project_file_required');
44 const realProject = await fs.realpath(projectDir),
45 real = await fs.realpath(file);
46 const realRelative = path.relative(realProject, real);
47 if (!realRelative || realRelative.startsWith(`..${path.sep}`) || realRelative === '..' || path.isAbsolute(realRelative)) throw problem('project_file_required');
48 return real;
49}
50function sourceKey(dotted = 'id') {
51 const keys = dotted.split('.');
52 if (keys.some(key => !key || ['__proto__', 'prototype', 'constructor'].includes(key))) throw problem('source_key_invalid');
53 return record => {
54 let value = record;
55 for (const key of keys) value = value && Object.hasOwn(value, key) ? value[key] : undefined;
56 if (typeof value !== 'string' && !(typeof value === 'number' && Number.isFinite(value))) throw problem('source_key_missing');
57 return value;
58 };
59}
60function deferredMapping(plan, entity) {
61 if (plan?.fieldContractVersion !== 1) return null;
62 const exact = require('../lib/mapping-field-contract');
63 const row = exact.entityRows(plan).find(candidate => exact.entityKeyMatches(candidate, entity));
64 return row && plan.executionScope?.deferredEntities?.find(item => item.entityRef === exact.entityRef(row)) || null;
65}
66function inventorySummary(entity, inv) {
67 return {
68 entity,
69 status: 'inventoried',
70 recordCount: inv.records.length,
71 sourcePaths: inv.sourcePaths,
72 writePaths: inv.writePaths,
73 ...(inv.heldRecords ? { heldRecords: inv.heldRecords } : {})
74 };
75}
76async function run(args) {
77 const {
78 command,
79 projectDir,
80 entity,
81 options
82 } = parse(args);
83 if (command === 'contract') {
84 const plan = await state.readJson(projectDir, 'mapping/mapping-plan.json', {});
85 const file = `mapping/entity-decisions/${C.decisionFileName(projectDir, entity)}`;
86 const decision = await state.readJson(projectDir, file, {});
87 if (!decision || typeof decision !== 'object' || Array.isArray(decision)) throw problem('entity_decision_invalid');
88 if (plan.fieldContractVersion === 1) {
89 const exact = require('../lib/mapping-field-contract');
90 const entries = exact.entityRows(plan).filter(row => exact.entityKeyMatches(row, entity));
91 if (entries.length !== 1) throw problem('entity_decision_invalid');
92 entries[0].readBackContract = C.domainContract(entity);
93 if (plan.entities && !Array.isArray(plan.entities)) {
94 const key = Object.keys(plan.entities).find(key => exact.entityRef({sourceEntity: key, ...plan.entities[key]}) === exact.entityRef(entries[0]));
95 plan.entities[key].readBackContract = entries[0].readBackContract;
96 }
97 // This CLI edits one entity; explicitly retain the other entities' current proposals.
98 // One-click cannot approve omissions: a plain call clears leftover proposals (they defer).
99 const selectedRef=exact.entityRef(entries[0]);
100 const oneClick=require('../lib/mapping-omission-approval').mode(await state.readJson(projectDir,'orchestration/decisions.json',{}))==='one_click';
101 const omissionReviewEntityRefs=oneClick ? [] : exact.entityRows(plan)
102 .filter(row=>row.omissionReviewRequested===true && exact.entityRef(row)!==selectedRef).map(exact.entityRef);
103 if (options['--omissions']==='review') omissionReviewEntityRefs.push(selectedRef);
104 await exact.writeMappingAuthority(projectDir, plan, { provisionalEntityRefs: plan.executionScope?.provisionalEntityRefs ?? null,
105 omissionReviewEntityRefs: omissionReviewEntityRefs.length ? omissionReviewEntityRefs : null });
106 } else await state.atomic(projectDir, file, {
107 ...decision,
108 readBackContract: C.domainContract(entity)
109 });
110 return {
111 entity,
112 status: 'contract-copied',
113 domainDigest: C.domainContract(entity).domainDigest
114 };
115 }
116 if (command === 'inventory') {
117 // A deferred entity is a durable mapping/cutover gap, not an inventory failure.
118 // Check the exact mapping before requiring an extract or builder: its source data
119 // may be precisely what the mapper could not read.
120 const plan = await state.readJson(projectDir, 'mapping/mapping-plan.json', {});
121 const deferred = deferredMapping(plan, entity);
122 if (deferred) return { entity, status: 'deferred', code: 'mapping_scope_deferred', deferred, exitCode: 0 };
123 if (!options['--extract']) return {
124 entity,
125 status: 'not-run',
126 code: 'extract_missing',
127 exitCode: 2
128 };
129 let file;
130 try {
131 file = await projectFile(projectDir, options['--extract']);
132 } catch (error) {
133 if (error.code === 'ENOENT') return {
134 entity,
135 status: 'not-run',
136 code: 'extract_missing',
137 exitCode: 2
138 };
139 throw error;
140 }
141 if (!options['--builder']) throw problem('builder_required');
142 const builderPath = await projectFile(projectDir, options['--builder']);
143 // Refuse before loading: the pre-approval builder may not carry client/executor wiring.
144 C.assertPureBuilder(await fs.readFile(builderPath, 'utf8'));
145 const builderModule = await import(pathToFileURL(builderPath).href);
146 const buildWrite = builderModule.buildWrite || builderModule.default?.buildWrite;
147 if (typeof buildWrite !== 'function') throw problem('builder_export_missing');
148 const rows = C.mappingRows(plan, entity);
149 // The builder is pinned: a later change to it makes this inventory stale.
150 const realProject = await fs.realpath(projectDir);
151 const builder = { path: path.relative(realProject, builderPath).split(path.sep).join('/'), sha256: require('../lib/artifact-freshness').hashArtifact(realProject, path.relative(realProject, builderPath)) };
152 // The toolkit records the extract version it inventories; a changed or field-losing version is refused.
153 const ledger = require('../lib/extract-ledger');
154 const extractPath = path.relative(realProject, await fs.realpath(file)).split(path.sep).join('/');
155 // An unreadable record or key is reported by the inventory below, with its established code.
156 // A changed or field-losing extract is re-read from the source and reported, never refused.
157 let recorded = null, recordError = null;
158 try { recorded = (await ledger.ensureExtract(realProject, { entity, extractPath, keyField: options['--source-key'] || 'id', recordedBy: 'read-back-inventory' })).row; }
159 catch (error) { recordError = error.code || 'extract_unreadable'; }
160 if (recordError === 'source_extract_unreadable') return { entity, status: 'not-run', code: recordError, next: [ledger.nextStep(recordError)], exitCode: 2 };
161 if (recordError && !/^extract_/.test(recordError)) return { entity, status: 'invalid', errors: [`${recordError}:${extractPath}`], next: [ledger.nextStep(recordError)].filter(Boolean), exitCode: 2 };
162 const inv = await C.inventory(projectDir, entity, readExtract(file, {
163 format: options['--format'] || 'auto'
164 }), {
165 sourceKey: sourceKey(options['--source-key']),
166 buildWrite,
167 rows,
168 templates: C.selectorTemplates(plan, entity),
169 builder,
170 extract: recorded ? { path: extractPath, sha256: recorded.sha256, ledgerHead: recorded.rowHash } : null
171 });
172 if (!recorded) return { entity, status: 'invalid', errors: [`${recordError}:${extractPath}`], exitCode: 2 };
173 return {
174 ...inventorySummary(entity, inv),
175 preparationMs: inv.preparationMs
176 };
177 }
178 if (command === 'validate') {
179 const plan = await state.readJson(projectDir, 'mapping/mapping-plan.json');
180 const deferred = deferredMapping(plan, entity);
181 if (deferred) return { entity, status: 'deferred', code: 'mapping_scope_deferred', deferred, exitCode: 0 };
182 let result;
183 try {
184 result = await C.validate(projectDir, entity);
185 } catch {
186 return {
187 entity,
188 status: 'invalid',
189 errors: ['Read-back validation could not read required artifacts or gate history.'],
190 exitCode: 2
191 };
192 }
193 // Before approval the only permissible failure is the missing identified approval for
194 // explicitly reviewed notMigrated paths; that state is named but still exits 2 (no writes).
195 const awaiting = !result.valid && result.errors.every(e => /^Identified exact-binding approval missing: |^identified interactive omission approval required$|^omission approval request missing$/.test(e));
196 return {
197 entity,
198 status: result.valid ? 'valid' : awaiting ? 'awaiting-approval' : 'invalid',
199 errors: result.errors,
200 ...(result.heldRecords ? { heldRecords: result.heldRecords } : {}),
201 exitCode: result.valid ? 0 : 2
202 };
203 }
204 if (command === 'draft-stubs') {
205 // Read-only: proposes unresolved stubs for unclaimed descendants of selected metadata values.
206 const plan = await state.readJson(projectDir, 'mapping/mapping-plan.json');
207 const inv = await state.readJson(projectDir, `execution/read-back-inventory/${entity}.json`);
208 if (!inv) return { entity, status: 'not-run', code: 'inventory_missing', exitCode: 2 };
209 return { entity, status: 'draft', stubs: C.draftSelectorDescendantStubs(plan, entity, inv) };
210 }
211 return require('../lib/read-back-summary').projectReadBackSummary(await state.load(projectDir), { entity });
212}
213async function main(args = process.argv.slice(2)) {
214 try {
215 const {
216 exitCode = 0,
217 ...result
218 } = await run(args);
219 process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
220 process.exitCode = exitCode;
221 return result;
222 } catch (error) {
223 // A generated builder or JSON parser may include source values in its message.
224 const known = new Set(['usage', 'entity_required', 'project_file_required', 'entity_decision_invalid', 'source_key_invalid', 'source_key_missing', 'builder_required', 'builder_export_missing', 'builder_not_pure', 'source_location_invalid', 'selector_template_ambiguous', 'applies_when_invalid', 'builder_hold_invalid', 'builder_hold_unknown_decision', 'builder_hold_retired', 'extract_record_too_large', 'extract_record_invalid', 'extract_json_invalid', 'extract_json_trailing_data', 'extract_json_array_required', 'extract_json_incomplete', 'extract_options_invalid']);
225 const result = {
226 status: 'failed',
227 code: known.has(error.code) ? error.code : 'read_back_command_failed',
228 // Source ids and toolkit remediation only: never the builder's message (it may carry values).
229 ...(error.code === 'builder_hold_retired' ? { retired: error.retired, remediation: error.remediation } : {})
230 };
231 process.stdout.write(`${JSON.stringify(result)}\n`);
232 process.exitCode = 2;
233 return result;
234 }
235}
236if (require.main === module) main();
237module.exports = {
238 main,
239 run,
240 parse,
241 sourceKey
242};