| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889 |
- import fs from 'node:fs';
- import path from 'node:path';
- import vm from 'node:vm';
- import { createHash } from 'node:crypto';
- import { fileURLToPath, pathToFileURL } from 'node:url';
- import ts from 'typescript';
- import { teacherEarningsSupport } from './teacher-earnings-transform.mjs';
- // Builds reviewable private artifacts only. No credentials, network, SQL or deployment.
- export const MOBILE_HELPERS = Object.freeze(['appCompanionOverview', 'appAccountOverview', 'appLiveScopeVersion', 'appReviewPage']);
- export const MONTHLY_HELPERS = Object.freeze(['teacherEarningsCalculator', 'teacherEarningsSourceRows', 'teacherEarningsInputs', 'teacherPrepareEarnings', 'teacherCalculatePreparedEarnings', 'teacherLessonEarnings', 'teacherEarningsFingerprint', 'teacherMonthlyEarningsInputs', 'teacherMonthlyLessonEarnings', 'teacherIncomeSummary']);
- export const OPERATIONS_HELPERS = Object.freeze(['payrollSyncStatusFromCutoff', 'payrollSettlementSyncStatus', 'payrollFreshReadSnapshot', 'payrollReadUsers', 'payrollCanonicalSnapshot', 'payrollPreviewUncached']);
- const sha256 = value => createHash('sha256').update(value).digest('hex');
- const parse = code => ts.createSourceFile('gateway.js', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
- const named = (source, name) => source.statements.filter(node => ts.isFunctionDeclaration(node) && node.name?.text === name);
- export function patchFunctions(baseline, candidate, names) {
- if (new Set(names).size !== names.length) throw Error('Duplicate helper in allowlist');
- const before = parse(baseline), local = parse(candidate), replacements = [], additions = [], audit = [];
- for (const name of names) {
- const existing = named(before, name), desired = named(local, name);
- if (existing.length > 1 || desired.length !== 1) throw Error(`Expected unique helper ${name}: baseline=${existing.length}, candidate=${desired.length}`);
- const replacement = desired[0].getText(local), old = existing[0]?.getText(before);
- if (old === replacement) { audit.push({ name, action: 'unchanged', hash: sha256(replacement) }); continue; }
- if (existing.length) replacements.push({ start: existing[0].getStart(before), end: existing[0].end, replacement });
- else additions.push(replacement);
- audit.push({ name, action: existing.length ? 'replace' : 'add', beforeHash: old ? sha256(old) : null, afterHash: sha256(replacement) });
- }
- let code = baseline;
- for (const edit of replacements.sort((a, b) => b.start - a.start)) code = code.slice(0, edit.start) + edit.replacement + code.slice(edit.end);
- if (additions.length) code += '\n\n' + additions.join('\n\n') + '\n';
- const after = parse(code), allowed = new Set(names);
- const unselected = source => source.statements.filter(node => !(ts.isFunctionDeclaration(node) && allowed.has(node.name?.text))).map(node => node.getText(source));
- const oldRest = JSON.stringify(unselected(before)), newRest = JSON.stringify(unselected(after));
- if (oldRest !== newRest) throw Error('Patch changed a declaration or executable statement outside the helper allowlist');
- for (const name of names) {
- const target = named(after, name);
- if (target.length !== 1 || target[0].getText(after) !== named(local, name)[0].getText(local)) throw Error(`Final helper mismatch: ${name}`);
- }
- new vm.Script(code, { filename: 'reviewable-gateway-candidate.js' });
- return { code, audit: { baselineHash: sha256(baseline), candidateHash: sha256(code), unchangedStatementsHash: sha256(oldRest), helperAllowlist: [...names], functions: audit } };
- }
- export function localCandidates(root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')) {
- const read = name => fs.readFileSync(path.join(root, name), 'utf8');
- const deploy = read('scripts/deploy-admin-functions.mjs');
- const template = name => {
- const match = deploy.match(new RegExp('const ' + name + ' = String.raw`([\\s\\S]*?)\\n`;'));
- if (!match) throw Error('Cannot find authoritative gateway template ' + name);
- return match[1];
- };
- const operations = template('operationsGatewayCode'), integrity = read('scripts/cloud/payroll-integrity.js');
- // Exactly the same generator and arguments as deploy-admin-functions.mjs.
- const calculator = teacherEarningsSupport(operations, integrity);
- return {
- mobile: template('appGatewayCode') + '\n' + read('scripts/cloud/mobile-teaching.js') + '\n' + calculator + '\n' + read('scripts/cloud/teacher-earnings.js'),
- operations: operations + '\n' + integrity,
- };
- }
- function main(args) {
- const values = new Map(); let monthly = false;
- for (let index = 0; index < args.length; index++) {
- const flag = args[index];
- if (flag === '--monthly') { monthly = true; continue; }
- if (!['--mobile-baseline', '--operations-baseline', '--output-dir'].includes(flag) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Usage: node scripts/speed-round3-patch.mjs [--mobile-baseline private.json] [--operations-baseline private.json] --output-dir private-directory [--monthly]');
- if (values.has(flag)) throw Error('Duplicate option ' + flag);
- values.set(flag, args[++index]);
- }
- if (!values.has('--output-dir') || !values.has('--mobile-baseline') && !values.has('--operations-baseline')) throw Error('Provide explicit baseline file(s) and private --output-dir');
- if (monthly && !values.has('--mobile-baseline')) throw Error('--monthly requires --mobile-baseline');
- const candidates = localCandidates(), out = path.resolve(values.get('--output-dir')), manifest = { monthly, scopes: {} }, prepared = [];
- for (const scope of ['mobile', 'operations']) {
- const sourcePath = values.get(`--${scope}-baseline`); if (!sourcePath) continue;
- const saved = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
- if (typeof saved.code !== 'string' || !saved.code.trim()) throw Error(`${scope} baseline must be a saved Function object with nonempty code`);
- const names = scope === 'mobile' ? [...MOBILE_HELPERS, ...(monthly ? MONTHLY_HELPERS : [])] : OPERATIONS_HELPERS;
- const result = patchFunctions(saved.code, candidates[scope], names);
- prepared.push({ filename: `${scope}-candidate.json`, value: { ...saved, code: result.code } });
- manifest.scopes[scope] = result.audit;
- }
- // Validate every candidate before writing any file. Baseline files are never overwritten.
- const baselinePaths = ['--mobile-baseline', '--operations-baseline'].map(flag => values.get(flag)).filter(Boolean).map(value => path.resolve(value));
- for (const filename of [...prepared.map(item => item.filename), 'patch-audit.json']) if (baselinePaths.includes(path.join(out, filename))) throw Error('Output must not replace a baseline');
- fs.mkdirSync(out, { recursive: true, mode: 0o700 });
- for (const item of [...prepared, { filename: 'patch-audit.json', value: manifest }]) fs.writeFileSync(path.join(out, item.filename), JSON.stringify(item.value, null, 2) + '\n', { mode: 0o600 });
- console.log(JSON.stringify({ outputDir: out, scopes: Object.fromEntries(Object.entries(manifest.scopes).map(([scope, audit]) => [scope, { baselineHash: audit.baselineHash, candidateHash: audit.candidateHash, functions: audit.functions.map(({ name, action }) => ({ name, action })) }])) }, null, 2));
- }
- if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) main(process.argv.slice(2));
|