207 lines
7.8 KiB
JavaScript
207 lines
7.8 KiB
JavaScript
// Patch the existing CSV cells using Artifact Tool; never create a second requirements matrix.
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import assert from 'node:assert/strict';
|
|
import { createRequire } from 'node:module';
|
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
import crypto from 'node:crypto';
|
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
const DOC = path.join(ROOT, 'docs/investmoz/v2/auditoria-portal');
|
|
const PRIVATE = path.join(
|
|
ROOT,
|
|
'storage/app/private/investmoz-v2/portal-audit',
|
|
);
|
|
const runtime = path.join(PRIVATE, 'legacy-sector-reconciliation-20261010');
|
|
const requireRuntime = createRequire(path.join(runtime, 'package.json'));
|
|
const { Workbook } = await import(
|
|
pathToFileURL(requireRuntime.resolve('@oai/artifact-tool')).href
|
|
);
|
|
const packetName = process.argv[2] || 'legacy-sectors-finalize-20261010';
|
|
assert(
|
|
/^[a-z0-9-]+$/.test(packetName),
|
|
'Editing packet must remain inside the private audit directory',
|
|
);
|
|
const packetDirectory = path.join(PRIVATE, packetName);
|
|
const packet = JSON.parse(
|
|
await fs.readFile(path.join(packetDirectory, 'csv-edits.json'), 'utf8'),
|
|
);
|
|
const sha = (b) => crypto.createHash('sha256').update(b).digest('hex');
|
|
const serialize = (rows) =>
|
|
'\ufeff' +
|
|
rows
|
|
.map((row) =>
|
|
row
|
|
.map((v) =>
|
|
/[",\r\n]/.test(v)
|
|
? '"' + v.replaceAll('"', '""') + '"'
|
|
: v,
|
|
)
|
|
.join(','),
|
|
)
|
|
.join('\r\n') +
|
|
'\r\n';
|
|
const matches = (value, literal) =>
|
|
value instanceof Date
|
|
? value.getTime() === Date.parse(literal)
|
|
: (value == null ? '' : String(value)) === literal;
|
|
const outputs = [];
|
|
for (const edit of packet.files) {
|
|
const filename = edit.filename,
|
|
file = path.join(DOC, filename),
|
|
input = await fs.readFile(file, 'utf8');
|
|
assert.equal(
|
|
sha(Buffer.from(input)),
|
|
edit.input_sha256,
|
|
'Source changed since targeted editing was prepared',
|
|
);
|
|
const source = edit.rows,
|
|
strings = source.map((row) => [...row]);
|
|
const workbook = await Workbook.fromCSV(input.replace(/^\ufeff/, ''), {
|
|
sheetName: 'Registos',
|
|
});
|
|
const sheet = workbook.worksheets.getItem('Registos'),
|
|
headers = source[0];
|
|
const observed = sheet.getRangeByIndexes(
|
|
0,
|
|
0,
|
|
source.length,
|
|
headers.length,
|
|
).values;
|
|
assert(
|
|
observed.every((r, i) => r.every((v, c) => matches(v, source[i][c]))),
|
|
'Imported cells must match existing source',
|
|
);
|
|
const changes = [];
|
|
for (const patch of edit.patches) {
|
|
const row = patch.row;
|
|
assert(row > 0 && row < source.length);
|
|
const updated = [...strings[row]];
|
|
for (const [field, value] of Object.entries(patch.values)) {
|
|
const col = headers.indexOf(field);
|
|
assert(col >= 0 && edit.allowed_columns.includes(field));
|
|
updated[col] = value;
|
|
}
|
|
sheet.getRangeByIndexes(row, 0, 1, headers.length).values = [updated];
|
|
strings[row] = updated;
|
|
changes.push({ row: row + 1, fields: Object.keys(patch.values) });
|
|
}
|
|
if (edit.remove_rows?.length) {
|
|
assert.equal(filename, 'inventario-fontes.csv', 'Only redundant source alias rows may be consolidated');
|
|
for (const removal of [...edit.remove_rows].sort((a, b) => b.row - a.row)) {
|
|
assert(removal.row > 0 && removal.row < strings.length);
|
|
assert.equal(strings[removal.row][headers.indexOf('SHA256')], removal.sha256);
|
|
assert.equal(strings[removal.row][headers.indexOf('Exemplar/caminho')], removal.path);
|
|
assert(strings.some((row, index) => index > 0 && index !== removal.row &&
|
|
row[headers.indexOf('SHA256')] === removal.sha256 &&
|
|
row[headers.indexOf('Exemplar/caminho')] === removal.canonical_path), 'Canonical source must remain');
|
|
strings.splice(removal.row, 1);
|
|
}
|
|
sheet.getRangeByIndexes(0, 0, strings.length, headers.length).values = strings;
|
|
}
|
|
if (edit.append?.length) {
|
|
assert(
|
|
['backlog-priorizado.csv', 'matriz-requisitos.csv', 'inventario-fontes.csv'].includes(filename),
|
|
'Append only to an existing canonical registry or its backlog projection',
|
|
);
|
|
assert(edit.append.every(row => row.length === headers.length && row.every(value => typeof value === 'string')));
|
|
const identityColumns = filename === 'inventario-fontes.csv'
|
|
? ['Exemplar/caminho', 'SHA256'] : ['ID'];
|
|
const key = row => identityColumns.map(column => row[headers.indexOf(column)]).join('|');
|
|
const identifiers = new Set(strings.slice(1).map(key));
|
|
for (const row of edit.append) {
|
|
assert(!identifiers.has(key(row)), 'Appended registry entry already exists');
|
|
identifiers.add(key(row));
|
|
}
|
|
sheet.getRangeByIndexes(
|
|
strings.length,
|
|
0,
|
|
edit.append.length,
|
|
headers.length,
|
|
).values = edit.append;
|
|
strings.push(...edit.append);
|
|
}
|
|
// Source registry hashes refer to the final bytes authored above; original hashes remain in version history.
|
|
if (filename === 'inventario-fontes.csv') {
|
|
for (const patch of edit.source_hash_updates) {
|
|
const row = patch.row,
|
|
exemplar = strings[row][headers.indexOf('Exemplar/caminho')];
|
|
const final = outputs.find(
|
|
(out) =>
|
|
path.normalize(path.join(DOC, out.filename)) ===
|
|
path.normalize(exemplar),
|
|
);
|
|
const bytes = final
|
|
? Buffer.from(final.text)
|
|
: await fs.readFile(exemplar);
|
|
strings[row][headers.indexOf('SHA256')] = sha(bytes);
|
|
strings[row][headers.indexOf('Bytes')] = String(bytes.length);
|
|
sheet.getRangeByIndexes(row, 0, 1, headers.length).values = [
|
|
strings[row],
|
|
];
|
|
}
|
|
}
|
|
workbook.recalculate();
|
|
const result = sheet.getRangeByIndexes(
|
|
0,
|
|
0,
|
|
strings.length,
|
|
headers.length,
|
|
).values;
|
|
assert(
|
|
result.every((r, i) => r.every((v, c) => matches(v, strings[i][c]))),
|
|
'Authored fields differ from inspected workbook cells',
|
|
);
|
|
const inspected = await workbook.inspect({
|
|
kind: 'table',
|
|
range: 'Registos!A1:C3',
|
|
include: 'values',
|
|
tableMaxRows: 3,
|
|
tableMaxCols: 3,
|
|
});
|
|
assert(inspected.ndjson);
|
|
// Preserve all original literal identifiers/offset dates. CSV has no workbook formatting or rendering surface.
|
|
const text = serialize(strings),
|
|
temporary = path.join(packetDirectory, filename + '.verified');
|
|
await fs.writeFile(temporary, text, 'utf8');
|
|
outputs.push({
|
|
filename,
|
|
text,
|
|
temporary,
|
|
changes,
|
|
appended: edit.append?.length || 0,
|
|
});
|
|
}
|
|
// Commit only after every table and dependency has passed preservation checks.
|
|
for (const out of outputs)
|
|
await fs.copyFile(out.temporary, path.join(DOC, out.filename));
|
|
await fs.writeFile(
|
|
path.join(
|
|
packetDirectory,
|
|
packet.proof_filename || 'csv-authoring-proof.json',
|
|
),
|
|
JSON.stringify(
|
|
{
|
|
database_access: false,
|
|
changed: outputs.map(({ filename, text, changes, appended }) => ({
|
|
filename,
|
|
sha256: sha(Buffer.from(text)),
|
|
changes,
|
|
appended,
|
|
})),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
console.log(
|
|
JSON.stringify({
|
|
tables: outputs.length,
|
|
changed: outputs.map(({ filename, changes, appended }) => ({
|
|
filename,
|
|
rows: changes.length,
|
|
appended,
|
|
})),
|
|
database_writes: 0,
|
|
}),
|
|
);
|