Files
osmWorkflow/scripts/road-parity.js

153 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 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 });
if (!fs.existsSync(area.outputs.nativeRoadDir)) throw new Error(`Native road output not found: ${area.outputs.nativeRoadDir}`);
const files = {};
for (const file of filesBelow(area.outputs.nativeRoadDir).sort()) {
const relative = path.relative(area.outputs.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 };