Commits the changes already running in production (image rodecomp/server:adminreview-20261028), which were never pushed: - Repository sync before verification: with pushRemote set, the worker fast-forwards its checkout at idle cohort boundaries, blocks merge/check lanes while a sync is pending or failed, and reloads data/decomp.json, the compile commands and data/toolchain.lock.json from one committed snapshot; the web process adopts only the published snapshot (git.js, verify.js, merge.js, checks.js, maintenance.js, project.js, app.js). - Symbol corrections: POST /api/admin/symbols/:id accepts decorated and swapDecoratedWith with strict validation, records old/new values, and invalidates cached symbol snapshots; td maintenance symbol --decorated/--swap-decorated-with and MCP td_correct_symbol (cli.js, mcp.js, ops.js, app.js). - Downloads: /download/ responses are no-store and answer HEAD; self-update revalidates the manifest and fetches the binary with ?sha256= to bypass stale CDN entries (update.js, app.js). - deploy/rodecomp/compose.yaml: Forgejo SSH enabled on port 22 (SSH_DOMAIN 192.168.1.77), as running. Tests: repo-sync, symbol-corrections and update suites added; npm test passes (164).
142 lines
6 KiB
JavaScript
142 lines
6 KiB
JavaScript
// Loads the decomp repo's configuration (data/decomp.json) and scans its sources.
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { parseAnnotations } from './annotations.js';
|
|
import { matchesAny } from './lint.js';
|
|
|
|
export const DEFAULT_PROJECT = {
|
|
name: 'decomp',
|
|
sourceDirs: ['src'],
|
|
includeDirs: ['include'],
|
|
headerGlobs: ['include/**'],
|
|
toolchain: 'msvc2010',
|
|
cflags: ['/nologo', '/c', '/O2', '/Gy', '/GS-', '/EHsc', '/MT'],
|
|
fileOverrides: [],
|
|
allowedPragmas: undefined,
|
|
lintLimits: {},
|
|
globalRules: false, // opt-in source storage/provenance checks; no symbol-database definition exemption
|
|
// reviewAt: 'exact' requires every relocation target to be a known symbol;
|
|
// 'match_unresolved' also accepts byte matches that reference not-yet-known data
|
|
// (those symbols are created automatically on merge).
|
|
// peerReviews: attempts that are not byte-identical need this many other agents to judge the logic the same.
|
|
matchPolicy: { reviewAt: 'match_unresolved', maxCleanupCycles: 5, approvals: 1, approvalsWithHeaderChanges: 2, peerReviews: 3, peerLeaseMinutes: 60 },
|
|
};
|
|
|
|
const UNIT_EXT = new Set(['.c', '.cpp', '.cc', '.cxx']);
|
|
const SOURCE_EXT = new Set([...UNIT_EXT, '.h', '.hpp', '.inl']);
|
|
|
|
export function loadProject(repoDir, readFile = (file) => readProjectFile(repoDir, file)) {
|
|
const cfg = JSON.parse(readFile('data/decomp.json') ?? '{}');
|
|
const project = { ...DEFAULT_PROJECT, ...cfg, matchPolicy: { ...DEFAULT_PROJECT.matchPolicy, ...(cfg.matchPolicy ?? {}) } };
|
|
// Optional exact per-unit command lines: { env, shapes: [[args]], units: { file: shapeIndex } }.
|
|
// Args may use ${SRC} (repo root) and ${TC} (toolchain root); the runner fills them in.
|
|
if (cfg.compileCommands) {
|
|
const commands = readFile(cfg.compileCommands);
|
|
if (commands === null) throw new Error(`missing compile commands: ${cfg.compileCommands}`);
|
|
project.compile = JSON.parse(commands);
|
|
}
|
|
return project;
|
|
}
|
|
|
|
/**
|
|
* The command shape a unit that is not in the build list yet gets: the one most units of its
|
|
* folder use (or of the nearest parent folder that has units), else the most common one.
|
|
*/
|
|
export function inferShape(project, file) {
|
|
const units = project.compile?.units;
|
|
if (!units) return null;
|
|
if (units[file] !== undefined) return units[file];
|
|
const tally = (prefix) => {
|
|
const n = new Map();
|
|
for (const [f, s] of Object.entries(units)) if (f.startsWith(prefix)) n.set(s, (n.get(s) ?? 0) + 1);
|
|
return [...n].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0]?.[0];
|
|
};
|
|
const parts = file.split('/');
|
|
for (let i = parts.length - 1; i > 0; i--) {
|
|
const s = tally(parts.slice(0, i).join('/') + '/');
|
|
if (s !== undefined) return s;
|
|
}
|
|
return tally('') ?? 0;
|
|
}
|
|
|
|
/**
|
|
* The build list with new units added (each with its inferred shape), as the text the repository
|
|
* stores it in, or null when nothing is new. `text` is the list as it is on main.
|
|
*/
|
|
export function addUnitsToCompileCommands(project, text, files) {
|
|
if (!project.compile?.units || text === null) return null;
|
|
const doc = JSON.parse(text);
|
|
const added = files.filter((f) => isUnit(f) && doc.units[f] === undefined && !/(^|\/)Win32\//.test(f));
|
|
if (!added.length) return null;
|
|
for (const f of added) doc.units[f] = inferShape({ compile: doc }, f);
|
|
doc.units = Object.fromEntries(Object.entries(doc.units).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
return { text: JSON.stringify(doc, null, 1), added, units: doc.units };
|
|
}
|
|
|
|
export function loadToolchainLock(repoDir, readFile = (file) => readProjectFile(repoDir, file)) {
|
|
return JSON.parse(readFile('data/toolchain.lock.json') ?? '{}');
|
|
}
|
|
|
|
function readProjectFile(repoDir, file) {
|
|
const absolute = path.join(repoDir, file);
|
|
return fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf8') : null;
|
|
}
|
|
|
|
/** Compiler flags and toolchain for one translation unit. */
|
|
export function unitSettings(project, file) {
|
|
// A unit the build list does not have yet compiles the way it will once it is added (merge.js).
|
|
const shape = project.compile?.units ? inferShape(project, file) : undefined;
|
|
if (shape !== undefined && shape !== null) return { cflags: project.compile.shapes[shape], toolchain: project.toolchain, env: project.compile.env ?? {} };
|
|
let cflags = project.cflags;
|
|
let toolchain = project.toolchain;
|
|
for (const o of project.fileOverrides ?? []) {
|
|
if (matchesAny(file, [o.glob])) {
|
|
if (o.cflags) cflags = o.cflags;
|
|
if (o.toolchain) toolchain = o.toolchain;
|
|
}
|
|
}
|
|
return { cflags, toolchain, env: {} };
|
|
}
|
|
|
|
/** Repo-relative paths of all source files (forward slashes). */
|
|
export function listSources(repoDir, project, { unitsOnly = false } = {}) {
|
|
// With exact compile commands, the build's own unit list is authoritative.
|
|
if (unitsOnly && project.compile?.units) return Object.keys(project.compile.units).sort();
|
|
const out = [];
|
|
const dirs = [...new Set([...project.sourceDirs, ...project.includeDirs])];
|
|
for (const d of dirs) walk(path.join(repoDir, d), d, out);
|
|
return out.filter((f) => (unitsOnly ? UNIT_EXT : SOURCE_EXT).has(path.posix.extname(f).toLowerCase())).sort();
|
|
}
|
|
|
|
function walk(abs, rel, out) {
|
|
if (!fs.existsSync(abs)) return;
|
|
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
if (e.name.startsWith('.')) continue;
|
|
const r = `${rel}/${e.name}`;
|
|
if (e.isDirectory()) walk(path.join(abs, e.name), r, out);
|
|
else out.push(r);
|
|
}
|
|
}
|
|
|
|
export function isUnit(file) {
|
|
return UNIT_EXT.has(path.posix.extname(file).toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* Annotations of the whole repo, optionally with some files replaced
|
|
* (the contents of a submission).
|
|
*/
|
|
export function scanAnnotations(repoDir, project, overrides = {}) {
|
|
const files = new Set(listSources(repoDir, project));
|
|
for (const f of Object.keys(overrides)) files.add(f);
|
|
const annotations = [];
|
|
const errors = [];
|
|
for (const f of [...files].sort()) {
|
|
const text = f in overrides ? overrides[f] : fs.readFileSync(path.join(repoDir, f), 'utf8');
|
|
const r = parseAnnotations(text, f);
|
|
annotations.push(...r.annotations);
|
|
errors.push(...r.errors);
|
|
}
|
|
return { annotations, errors };
|
|
}
|