155 lines
6.0 KiB
JavaScript
155 lines
6.0 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
const crypto = require("crypto");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { readAreaConfig } = require("./lib/area-config");
|
|
const { importNativeRoadPackage } = require("./lib/native-road-package");
|
|
|
|
const repoRoot = path.resolve(__dirname, "..");
|
|
|
|
function parseArgs(argv) {
|
|
const result = {};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
if (!argv[index].startsWith("--")) continue;
|
|
const key = argv[index].slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
result[key] = argv[index + 1] && !argv[index + 1].startsWith("--") ? argv[++index] : true;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function usage() {
|
|
return "Usage: node scripts/road-parity.js --config <area-config> (--snapshot <out.json> | --compare <baseline.json>)";
|
|
}
|
|
|
|
function hash(value) {
|
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function stable(value) {
|
|
if (Array.isArray(value)) return value.map(stable);
|
|
if (!value || typeof value !== "object") return normalizeString(value);
|
|
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
|
}
|
|
|
|
function normalizeString(value) {
|
|
if (typeof value !== "string") return value;
|
|
const normalized = value.replace(/([\\/])native-road-[^\\/]+/g, "$1<staging>");
|
|
if (!path.isAbsolute(normalized)) return normalized;
|
|
const relative = path.relative(repoRoot, normalized);
|
|
return relative && !relative.startsWith("..") && !path.isAbsolute(relative)
|
|
? `<repo>/${relative.split(path.sep).join("/")}`
|
|
: `<external>/${path.basename(normalized)}`;
|
|
}
|
|
|
|
function encoded(value) {
|
|
return JSON.stringify(stable(value));
|
|
}
|
|
|
|
function featureKey(feature) {
|
|
const nativeId = feature?.properties?.native_id ?? feature?.properties?.nativeId ?? feature?.id;
|
|
if (nativeId !== undefined && nativeId !== null) return `id:${nativeId}`;
|
|
return `geometry:${encoded(feature?.geometry ?? null)}`;
|
|
}
|
|
|
|
function geoJsonRecord(value, bytes) {
|
|
if (value?.type !== "FeatureCollection" || !Array.isArray(value.features)) return null;
|
|
const normalized = { ...value, features: value.features.map(stable) };
|
|
const ordered = encoded(normalized);
|
|
const content = encoded({ ...normalized, features: [...normalized.features].sort((left, right) => featureKey(left).localeCompare(featureKey(right))) });
|
|
return { contentHash: hash(content), orderHash: hash(ordered), features: value.features.length, bytes };
|
|
}
|
|
|
|
function jsonRecord(text, bytes) {
|
|
const value = JSON.parse(text);
|
|
return geoJsonRecord(value, bytes) || { contentHash: hash(encoded(value)), bytes };
|
|
}
|
|
|
|
function filesBelow(directory) {
|
|
const result = [];
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const full = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) result.push(...filesBelow(full));
|
|
else if (entry.isFile()) result.push(full);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function snapshot(configPath) {
|
|
const area = readAreaConfig(configPath, { repoRoot });
|
|
const nativePackage = importNativeRoadPackage(area);
|
|
const nativeRoadDir = nativePackage.rootDir;
|
|
const files = {};
|
|
for (const file of filesBelow(nativeRoadDir).sort()) {
|
|
const relative = path.relative(nativeRoadDir, file).split(path.sep).join("/");
|
|
const text = fs.readFileSync(file, "utf8");
|
|
files[relative] = jsonRecord(text, Buffer.byteLength(text));
|
|
}
|
|
if (fs.existsSync(area.outputs.nativeTrafficSignals)) {
|
|
const text = fs.readFileSync(area.outputs.nativeTrafficSignals, "utf8");
|
|
files["../native-traffic-signals.json"] = jsonRecord(text, Buffer.byteLength(text));
|
|
}
|
|
return {
|
|
contract: "native-road-package/v1",
|
|
areaId: area.id,
|
|
files: Object.fromEntries(Object.keys(files).sort().map((file) => [file, files[file]])),
|
|
volatileExcluded: ["absolute paths -> <repo> or <external>", "native-road-* staging directory -> <staging>"],
|
|
};
|
|
}
|
|
|
|
function compare(actual, baseline) {
|
|
const differences = [];
|
|
if (actual.contract !== baseline.contract) differences.push(`contract: expected ${baseline.contract}, got ${actual.contract}`);
|
|
if (actual.areaId !== baseline.areaId) differences.push(`areaId: expected ${baseline.areaId}, got ${actual.areaId}`);
|
|
const names = new Set([...Object.keys(baseline.files || {}), ...Object.keys(actual.files || {})]);
|
|
for (const name of [...names].sort()) {
|
|
const expected = baseline.files?.[name];
|
|
const received = actual.files?.[name];
|
|
if (!expected) { differences.push(`${name}: unexpected file`); continue; }
|
|
if (!received) { differences.push(`${name}: missing file`); continue; }
|
|
for (const field of ["contentHash", "orderHash", "features", "bytes"]) {
|
|
if ((expected[field] ?? null) !== (received[field] ?? null)) {
|
|
differences.push(`${name}: ${field} expected ${expected[field] ?? "<none>"}, got ${received[field] ?? "<none>"}`);
|
|
}
|
|
}
|
|
}
|
|
return differences;
|
|
}
|
|
|
|
function writeJson(file, value) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (!args.config || Boolean(args.snapshot) === Boolean(args.compare)) throw new Error(usage());
|
|
const actual = snapshot(path.resolve(args.config));
|
|
if (args.snapshot) {
|
|
const output = path.resolve(args.snapshot);
|
|
writeJson(output, actual);
|
|
console.log(`ROAD_PARITY_SNAPSHOT ${JSON.stringify({ areaId: actual.areaId, output, files: Object.keys(actual.files).length })}`);
|
|
return;
|
|
}
|
|
const baseline = JSON.parse(fs.readFileSync(path.resolve(args.compare), "utf8"));
|
|
const differences = compare(actual, baseline);
|
|
if (differences.length) {
|
|
for (const difference of differences) console.error(`ROAD_PARITY_DIFF ${difference}`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
console.log(`ROAD_PARITY_OK ${JSON.stringify({ areaId: actual.areaId, baseline: path.resolve(args.compare), files: Object.keys(actual.files).length })}`);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
module.exports = { compare, snapshot };
|