mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
feat: bind gate verdicts to hermetic candidates
This commit is contained in:
1 parent
df05193e4f
commit
4f930dedb8
6 files changed
+684
-2
No files matched your search
@@ -0,0 +1,194 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const GATE_SCRIPT = fileURLToPath(new URL("./gate-lane.mjs", import.meta.url));
|
||||
|
||||
function write(path, text) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, text);
|
||||
}
|
||||
|
||||
function git(root, ...args) {
|
||||
return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function makeRepo() {
|
||||
const root = mkdtempSync(join(tmpdir(), "gate-candidate-"));
|
||||
write(join(root, ".gitignore"), "node_modules/\npackages/cli/daemon/\n");
|
||||
write(join(root, "tracked.txt"), "one\n");
|
||||
write(join(root, "packages/cli/index.js"), "export {};\n");
|
||||
const packages = {
|
||||
"": { workspaces: ["packages/cli"] },
|
||||
"node_modules/@openrig/cli": { resolved: "packages/cli", link: true },
|
||||
"packages/cli": { name: "@openrig/cli" },
|
||||
};
|
||||
write(join(root, "package-lock.json"), `${JSON.stringify({ name: "fixture", lockfileVersion: 3, packages }, null, 2)}\n`);
|
||||
mkdirSync(join(root, "node_modules/@openrig"), { recursive: true });
|
||||
symlinkSync("../../packages/cli", join(root, "node_modules/@openrig/cli"));
|
||||
git(root, "init", "-q");
|
||||
git(root, "config", "user.email", "[email protected]");
|
||||
git(root, "config", "user.name", "Gate Test");
|
||||
git(root, "add", ".");
|
||||
git(root, "commit", "-qm", "fixture");
|
||||
return root;
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const { port } = server.address();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
async function runGate(root, extraEnv = {}) {
|
||||
const port = await freePort();
|
||||
return spawnSync(process.execPath, [GATE_SCRIPT], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENRIG_GATE_LANE_PORT: String(port),
|
||||
OPENRIG_GATE_LANE_SMOKE: "1",
|
||||
OPENRIG_GATE_VERDICT: join(root, "gate-lane-verdict.json"),
|
||||
...extraEnv,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function assertCandidateRefusal(result, pattern) {
|
||||
assert.notEqual(result.status, 0, `gate must refuse\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
assert.match(`${result.stdout}\n${result.stderr}`, pattern);
|
||||
}
|
||||
|
||||
test("candidate binding: tracked dirt refuses before the first cleanup mutation", async () => {
|
||||
const root = makeRepo();
|
||||
const sentinel = join(root, "packages/cli/daemon/sentinel.txt");
|
||||
write(sentinel, "must survive refusal\n");
|
||||
write(join(root, "tracked.txt"), "dirty\n");
|
||||
const result = await runGate(root);
|
||||
assertCandidateRefusal(result, /candidate.*dirty|tracked.*change/i);
|
||||
assert.equal(existsSync(sentinel), true, "pre-mutation refusal preserves the stale-bundle sentinel");
|
||||
});
|
||||
|
||||
test("candidate binding: a Git-visible untracked file refuses", async () => {
|
||||
const root = makeRepo();
|
||||
write(join(root, "untracked.test.mjs"), "// visible\n");
|
||||
assertCandidateRefusal(await runGate(root), /candidate.*dirty|untracked/i);
|
||||
});
|
||||
|
||||
test("candidate binding: same verdict basename at a different path is not excluded", async () => {
|
||||
const root = makeRepo();
|
||||
write(join(root, "sub/dir/gate-lane-verdict.json"), "{}\n");
|
||||
assertCandidateRefusal(await runGate(root), /sub\/dir\/gate-lane-verdict\.json|candidate.*dirty/i);
|
||||
});
|
||||
|
||||
test("candidate binding: only the canonical verdict path may be present and the verdict binds HEAD", async () => {
|
||||
const root = makeRepo();
|
||||
const verdictPath = join(root, "receipts/current-verdict.json");
|
||||
write(verdictPath, "{}\n");
|
||||
const head = git(root, "rev-parse", "HEAD");
|
||||
const result = await runGate(root, { OPENRIG_GATE_VERDICT: verdictPath });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const verdict = JSON.parse(readFileSync(verdictPath, "utf8"));
|
||||
assert.equal(verdict.candidateSha, head);
|
||||
assert.equal(verdict.gate, "pass");
|
||||
});
|
||||
|
||||
test("candidate binding: HEAD drift during the gate refuses before writing a verdict", async () => {
|
||||
const root = makeRepo();
|
||||
const verdictPath = join(root, "gate-lane-verdict.json");
|
||||
const fakeBin = mkdtempSync(join(tmpdir(), "gate-fake-npm-"));
|
||||
const marker = join(tmpdir(), `gate-drift-marker-${process.pid}-${Date.now()}`);
|
||||
const fakeNpm = join(fakeBin, "npm");
|
||||
write(fakeNpm, `#!/bin/sh\n: > "$OPENRIG_TEST_MARKER"\nsleep 1\nexit 0\n`);
|
||||
chmodSync(fakeNpm, 0o755);
|
||||
const port = await freePort();
|
||||
|
||||
const child = spawn(process.execPath, [GATE_SCRIPT], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeBin}:${process.env.PATH}`,
|
||||
OPENRIG_TEST_MARKER: marker,
|
||||
OPENRIG_GATE_LANE_PORT: String(port),
|
||||
OPENRIG_GATE_VERDICT: verdictPath,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
const closed = new Promise((resolve) => child.on("close", resolve));
|
||||
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!existsSync(marker) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.equal(existsSync(marker), true, "the real gate reached its first injected leg");
|
||||
write(join(root, "tracked.txt"), "two\n");
|
||||
git(root, "add", "tracked.txt");
|
||||
git(root, "commit", "-qm", "move head during gate");
|
||||
|
||||
const status = await closed;
|
||||
assertCandidateRefusal({ status, stdout, stderr }, /HEAD.*(drift|changed)|candidateSha/i);
|
||||
assert.equal(existsSync(verdictPath), false, "a drifted run writes no verdict");
|
||||
});
|
||||
|
||||
test("candidate binding: Git-visible input added during the gate refuses before writing a verdict", async () => {
|
||||
const root = makeRepo();
|
||||
const verdictPath = join(root, "gate-lane-verdict.json");
|
||||
const fakeBin = mkdtempSync(join(tmpdir(), "gate-fake-npm-"));
|
||||
const marker = join(tmpdir(), `gate-dirty-marker-${process.pid}-${Date.now()}`);
|
||||
const release = join(tmpdir(), `gate-dirty-release-${process.pid}-${Date.now()}`);
|
||||
const fakeNpm = join(fakeBin, "npm");
|
||||
write(fakeNpm, `#!/bin/sh\n: > "$OPENRIG_TEST_MARKER"\nwhile [ ! -e "$OPENRIG_TEST_RELEASE" ]; do sleep 0.02; done\nexit 0\n`);
|
||||
chmodSync(fakeNpm, 0o755);
|
||||
const port = await freePort();
|
||||
|
||||
const child = spawn(process.execPath, [GATE_SCRIPT], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeBin}:${process.env.PATH}`,
|
||||
OPENRIG_TEST_MARKER: marker,
|
||||
OPENRIG_TEST_RELEASE: release,
|
||||
OPENRIG_GATE_LANE_PORT: String(port),
|
||||
OPENRIG_GATE_VERDICT: verdictPath,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
const closed = new Promise((resolve) => child.on("close", resolve));
|
||||
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!existsSync(marker) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
if (!existsSync(marker)) write(release, "abort\n");
|
||||
assert.equal(existsSync(marker), true, "the real gate reached its first injected leg");
|
||||
write(join(root, "late-input.test.mjs"), "// introduced while the gate was running\n");
|
||||
write(release, "continue\n");
|
||||
|
||||
const status = await closed;
|
||||
assertCandidateRefusal({ status, stdout, stderr }, /candidate.*dirty|late-input\.test\.mjs/i);
|
||||
assert.equal(existsSync(verdictPath), false, "a late-dirty run writes no verdict");
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function refuse(message) {
|
||||
throw new Error(`[gate-consume] REFUSED: ${message}`);
|
||||
}
|
||||
|
||||
/** Honor one exact real-gate verdict, then remove it so it cannot be reused. */
|
||||
export function consumeGateVerdict({ verdictPath, headSha, log = console.log }) {
|
||||
if (!existsSync(verdictPath)) refuse(`verdict is missing: ${verdictPath}`);
|
||||
let verdict;
|
||||
try {
|
||||
verdict = JSON.parse(readFileSync(verdictPath, "utf8"));
|
||||
} catch (error) {
|
||||
refuse(`verdict is not valid JSON: ${error?.message ?? error}`);
|
||||
}
|
||||
if (typeof verdict?.candidateSha !== "string" || verdict.candidateSha.length === 0) {
|
||||
refuse("candidateSha is required");
|
||||
}
|
||||
if (verdict.candidateSha !== headSha) {
|
||||
refuse(`candidateSha does not match current HEAD (verdict=${verdict.candidateSha}, HEAD=${headSha})`);
|
||||
}
|
||||
if (verdict.gate !== "pass") refuse(`gate must be "pass" (received ${JSON.stringify(verdict.gate)})`);
|
||||
if (verdict.smoke !== false) refuse("smoke must be strictly false; only a real gate may be consumed");
|
||||
|
||||
unlinkSync(verdictPath);
|
||||
if (existsSync(verdictPath)) refuse(`consumed verdict still exists after unlink: ${verdictPath}`);
|
||||
log(`[gate-consume] honored and consumed candidate ${headSha}; removed ${verdictPath}`);
|
||||
return verdict;
|
||||
}
|
||||
|
||||
function readHead(root) {
|
||||
const result = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" });
|
||||
if (result.status !== 0) refuse(`cannot derive current HEAD: ${result.stderr.trim()}`);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function main() {
|
||||
const root = process.cwd();
|
||||
const verdictPath = resolve(process.argv[2] ?? process.env.OPENRIG_GATE_VERDICT ?? join(root, "gate-lane-verdict.json"));
|
||||
consumeGateVerdict({ verdictPath, headSha: readHead(root) });
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error?.message ?? error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const CONSUMER = fileURLToPath(new URL("./gate-lane-consume.mjs", import.meta.url));
|
||||
|
||||
function write(path, text) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, text);
|
||||
}
|
||||
|
||||
function git(root, ...args) {
|
||||
return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function makeRepo() {
|
||||
const root = mkdtempSync(join(tmpdir(), "gate-consume-"));
|
||||
write(join(root, "tracked.txt"), "one\n");
|
||||
git(root, "init", "-q");
|
||||
git(root, "config", "user.email", "[email protected]");
|
||||
git(root, "config", "user.name", "Gate Test");
|
||||
git(root, "add", ".");
|
||||
git(root, "commit", "-qm", "fixture");
|
||||
return { root, head: git(root, "rev-parse", "HEAD"), verdictPath: join(root, "gate-lane-verdict.json") };
|
||||
}
|
||||
|
||||
function runConsumer(root, verdictPath) {
|
||||
return spawnSync(process.execPath, [CONSUMER, verdictPath], { cwd: root, encoding: "utf8" });
|
||||
}
|
||||
|
||||
function assertRefused(result, verdictPath, pattern) {
|
||||
assert.notEqual(result.status, 0, `consumer must refuse\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
assert.match(`${result.stdout}\n${result.stderr}`, pattern);
|
||||
if (verdictPath) assert.equal(existsSync(verdictPath), true, "a refused verdict is retained");
|
||||
}
|
||||
|
||||
function put(path, value) {
|
||||
write(path, `${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
test("consumer refuses loudly when no verdict exists", () => {
|
||||
const { root, verdictPath } = makeRepo();
|
||||
const result = runConsumer(root, verdictPath);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(`${result.stdout}\n${result.stderr}`, /verdict.*(missing|does not exist)/i);
|
||||
});
|
||||
|
||||
test("consumer refuses and retains a verdict with no candidateSha", () => {
|
||||
const { root, verdictPath } = makeRepo();
|
||||
put(verdictPath, { gate: "pass", smoke: false });
|
||||
assertRefused(runConsumer(root, verdictPath), verdictPath, /candidateSha.*required|missing.*candidateSha/i);
|
||||
});
|
||||
|
||||
test("consumer derives current HEAD and refuses a stale candidateSha", () => {
|
||||
const { root, head, verdictPath } = makeRepo();
|
||||
put(verdictPath, { candidateSha: head, gate: "pass", smoke: false });
|
||||
write(join(root, "tracked.txt"), "two\n");
|
||||
git(root, "add", "tracked.txt");
|
||||
git(root, "commit", "-qm", "move head");
|
||||
assertRefused(runConsumer(root, verdictPath), verdictPath, /candidateSha.*(mismatch|does not match)|current HEAD/i);
|
||||
});
|
||||
|
||||
test("consumer refuses and retains a matching-SHA failed verdict", () => {
|
||||
const { root, head, verdictPath } = makeRepo();
|
||||
put(verdictPath, { candidateSha: head, gate: "fail", smoke: false });
|
||||
assertRefused(runConsumer(root, verdictPath), verdictPath, /gate.*pass/i);
|
||||
});
|
||||
|
||||
test("consumer refuses and retains a smoke:true verdict", () => {
|
||||
const { root, head, verdictPath } = makeRepo();
|
||||
put(verdictPath, { candidateSha: head, gate: "pass", smoke: true });
|
||||
assertRefused(runConsumer(root, verdictPath), verdictPath, /smoke.*false|real gate/i);
|
||||
});
|
||||
|
||||
test("consumer refuses and retains a verdict whose smoke field is absent", () => {
|
||||
const { root, head, verdictPath } = makeRepo();
|
||||
put(verdictPath, { candidateSha: head, gate: "pass" });
|
||||
assertRefused(runConsumer(root, verdictPath), verdictPath, /smoke.*false|missing.*smoke/i);
|
||||
});
|
||||
|
||||
test("consumer honors only a matching real PASS and removes the consumed artifact", () => {
|
||||
const { root, head, verdictPath } = makeRepo();
|
||||
put(verdictPath, { candidateSha: head, gate: "pass", smoke: false });
|
||||
const result = runConsumer(root, verdictPath);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /consum|honor/i);
|
||||
assert.match(result.stdout, new RegExp(head));
|
||||
assert.equal(existsSync(verdictPath), false, "successful consume removes the verdict");
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { lstatSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(`[gate-hermeticity] REFUSED: ${message}`);
|
||||
}
|
||||
|
||||
function inside(parent, child) {
|
||||
const rel = relative(parent, child);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that this worktree owns the dependency root and every lock-derived
|
||||
* @openrig workspace link. The check reports only; it never repairs.
|
||||
*/
|
||||
export function checkDependencyRoot(repoRoot, { log = console.log } = {}) {
|
||||
const root = realpathSync(repoRoot);
|
||||
const nodeModules = join(root, "node_modules");
|
||||
let nodeModulesStat;
|
||||
try {
|
||||
nodeModulesStat = lstatSync(nodeModules);
|
||||
} catch {
|
||||
fail(`node_modules is absent at ${nodeModules}`);
|
||||
}
|
||||
if (nodeModulesStat.isSymbolicLink()) {
|
||||
let actual = "<unresolved>";
|
||||
try { actual = realpathSync(nodeModules); } catch { /* reported below */ }
|
||||
log(`[gate-hermeticity] node_modules actual=${actual} expected=${nodeModules}`);
|
||||
fail(`node_modules is a symlink: ${nodeModules}`);
|
||||
}
|
||||
if (!nodeModulesStat.isDirectory()) fail(`node_modules is not a directory: ${nodeModules}`);
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(join(root, "package-lock.json"), "utf8"));
|
||||
} catch (error) {
|
||||
fail(`cannot read package-lock.json: ${error?.message ?? error}`);
|
||||
}
|
||||
const entries = Object.entries(lock?.packages ?? {})
|
||||
.filter(([path]) => path.startsWith("node_modules/@openrig/"))
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
if (entries.length === 0) fail("zero scoped @openrig links were derived from package-lock.json");
|
||||
|
||||
const packagesRoot = realpathSync(join(root, "packages"));
|
||||
const pairs = [];
|
||||
const failures = [];
|
||||
for (const [linkPath, metadata] of entries) {
|
||||
const name = linkPath.slice("node_modules/".length);
|
||||
if (metadata?.link !== true || typeof metadata.resolved !== "string" || metadata.resolved.trim() === "") {
|
||||
log(`[gate-hermeticity] ${name} actual=<unchecked> expected=<missing lock-derived target>`);
|
||||
failures.push(`${name} has no lock-derived workspace target`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedPath = join(root, metadata.resolved);
|
||||
let expected;
|
||||
try {
|
||||
expected = realpathSync(expectedPath);
|
||||
} catch (error) {
|
||||
log(`[gate-hermeticity] ${name} actual=<unchecked> expected=<unresolved:${expectedPath}>`);
|
||||
failures.push(`${name} expected target cannot be resolved: ${error?.message ?? error}`);
|
||||
continue;
|
||||
}
|
||||
if (!inside(packagesRoot, expected)) {
|
||||
log(`[gate-hermeticity] ${name} actual=<unchecked> expected=${expected}`);
|
||||
failures.push(`${name} lock-derived target escapes ${packagesRoot}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const actualPath = join(root, linkPath);
|
||||
let actual;
|
||||
try {
|
||||
actual = realpathSync(actualPath);
|
||||
} catch (error) {
|
||||
log(`[gate-hermeticity] ${name} actual=<unresolved:${actualPath}> expected=${expected}`);
|
||||
failures.push(`${name} actual target cannot be resolved: ${error?.message ?? error}`);
|
||||
continue;
|
||||
}
|
||||
const line = `[gate-hermeticity] ${name} actual=${actual} expected=${expected}`;
|
||||
log(line);
|
||||
pairs.push({ name, actual, expected });
|
||||
if (actual !== expected) failures.push(`${name} actual target does not equal its lock-derived expected target`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) fail(failures.join("; "));
|
||||
return { checked: pairs.length, pairs };
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const GATE_SCRIPT = fileURLToPath(new URL("./gate-lane.mjs", import.meta.url));
|
||||
const CONSUMER_SCRIPT = fileURLToPath(new URL("./gate-lane-consume.mjs", import.meta.url));
|
||||
|
||||
function write(path, text) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, text);
|
||||
}
|
||||
|
||||
function git(root, ...args) {
|
||||
return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function makeRepo({ lockEntries = true, dependencyRoot = "exact" } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "gate-hermeticity-"));
|
||||
write(join(root, ".gitignore"), "node_modules/\npackages/cli/daemon/\n");
|
||||
write(join(root, "package.json"), '{"private":true}\n');
|
||||
write(join(root, "packages/cli/index.js"), "export {};\n");
|
||||
write(join(root, "packages/other/index.js"), "export {};\n");
|
||||
const packages = lockEntries
|
||||
? {
|
||||
"": { workspaces: ["packages/cli"] },
|
||||
"node_modules/@openrig/cli": { resolved: "packages/cli", link: true },
|
||||
"packages/cli": { name: "@openrig/cli" },
|
||||
}
|
||||
: { "": { workspaces: [] } };
|
||||
write(join(root, "package-lock.json"), `${JSON.stringify({ name: "fixture", lockfileVersion: 3, packages }, null, 2)}\n`);
|
||||
|
||||
if (dependencyRoot === "symlink") {
|
||||
const foreign = mkdtempSync(join(tmpdir(), "gate-hermeticity-root-"));
|
||||
mkdirSync(join(foreign, "node_modules"), { recursive: true });
|
||||
symlinkSync(join(foreign, "node_modules"), join(root, "node_modules"));
|
||||
} else if (dependencyRoot !== "absent") {
|
||||
mkdirSync(join(root, "node_modules/@openrig"), { recursive: true });
|
||||
const target = dependencyRoot === "swapped"
|
||||
? "../../packages/other"
|
||||
: dependencyRoot === "cross-tree"
|
||||
? join(mkdtempSync(join(tmpdir(), "gate-hermeticity-cross-")), "packages/cli")
|
||||
: "../../packages/cli";
|
||||
if (dependencyRoot === "cross-tree") mkdirSync(target, { recursive: true });
|
||||
symlinkSync(target, join(root, "node_modules/@openrig/cli"));
|
||||
}
|
||||
|
||||
git(root, "init", "-q");
|
||||
git(root, "config", "user.email", "[email protected]");
|
||||
git(root, "config", "user.name", "Gate Test");
|
||||
git(root, "add", ".");
|
||||
git(root, "commit", "-qm", "fixture");
|
||||
return root;
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const { port } = server.address();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
async function runGate(root) {
|
||||
const port = await freePort();
|
||||
return spawnSync(process.execPath, [GATE_SCRIPT], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENRIG_GATE_LANE_PORT: String(port),
|
||||
OPENRIG_GATE_LANE_SMOKE: "1",
|
||||
OPENRIG_GATE_VERDICT: join(root, "gate-lane-verdict.json"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function assertHermeticRefusal(result, pattern) {
|
||||
assert.notEqual(result.status, 0, `gate must refuse\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
assert.match(`${result.stdout}\n${result.stderr}`, pattern);
|
||||
}
|
||||
|
||||
test("dependency root: absent node_modules refuses before the gate mutates", async () => {
|
||||
const result = await runGate(makeRepo({ dependencyRoot: "absent" }));
|
||||
assertHermeticRefusal(result, /node_modules.*absent|missing dependency root/i);
|
||||
});
|
||||
|
||||
test("dependency root: a symlinked node_modules root refuses", async () => {
|
||||
const root = makeRepo({ dependencyRoot: "symlink" });
|
||||
const sentinel = join(root, "packages/cli/daemon/sentinel.txt");
|
||||
write(sentinel, "must survive refusal\n");
|
||||
const result = await runGate(root);
|
||||
assertHermeticRefusal(result, /node_modules.*symlink/i);
|
||||
assert.equal(existsSync(sentinel), true, "the hermeticity refusal runs before cleanup mutation");
|
||||
});
|
||||
|
||||
test("dependency root: a scoped link escaping to another tree refuses and prints both operands", async () => {
|
||||
const result = await runGate(makeRepo({ dependencyRoot: "cross-tree" }));
|
||||
assertHermeticRefusal(result, /@openrig\/cli[\s\S]*actual[\s\S]*expected/i);
|
||||
});
|
||||
|
||||
test("dependency root: a scoped link swapped to another in-tree package refuses", async () => {
|
||||
const result = await runGate(makeRepo({ dependencyRoot: "swapped" }));
|
||||
assertHermeticRefusal(result, /@openrig\/cli[\s\S]*packages\/other[\s\S]*packages\/cli/i);
|
||||
});
|
||||
|
||||
test("dependency root: a lock deriving zero scoped links refuses instead of vacuously passing", async () => {
|
||||
const result = await runGate(makeRepo({ lockEntries: false }));
|
||||
assertHermeticRefusal(result, /zero|no scoped @openrig/i);
|
||||
});
|
||||
|
||||
test("dependency root: exact lock-derived target passes and prints the actual/expected pair", async () => {
|
||||
const result = await runGate(makeRepo());
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /@openrig\/cli[\s\S]*actual[\s\S]*expected/i);
|
||||
assert.match(result.stdout, /packages\/cli/i);
|
||||
});
|
||||
|
||||
test("dependency root: a later refusal invalidates a same-HEAD prior PASS", async () => {
|
||||
const root = makeRepo();
|
||||
const verdictPath = join(root, "gate-lane-verdict.json");
|
||||
const first = await runGate(root);
|
||||
assert.equal(first.status, 0, first.stderr);
|
||||
assert.equal(existsSync(verdictPath), true, "the first gate writes a PASS receipt");
|
||||
|
||||
rmSync(join(root, "node_modules/@openrig/cli"), { force: true });
|
||||
symlinkSync("../../packages/other", join(root, "node_modules/@openrig/cli"));
|
||||
|
||||
const second = await runGate(root);
|
||||
assertHermeticRefusal(second, /@openrig\/cli[\s\S]*packages\/other[\s\S]*packages\/cli/i);
|
||||
assert.equal(existsSync(verdictPath), false, "the refused retry invalidates the prior PASS");
|
||||
const consume = spawnSync(process.execPath, [CONSUMER_SCRIPT, verdictPath], { cwd: root, encoding: "utf8" });
|
||||
assert.notEqual(consume.status, 0, "the prior PASS is no longer consumable");
|
||||
assert.match(`${consume.stdout}\n${consume.stderr}`, /verdict.*missing/i);
|
||||
});
|
||||
|
||||
test("dependency root: drift during the gate refuses before writing a verdict", async () => {
|
||||
const root = makeRepo();
|
||||
const verdictPath = join(root, "gate-lane-verdict.json");
|
||||
const fakeBin = mkdtempSync(join(tmpdir(), "gate-hermeticity-fake-npm-"));
|
||||
const marker = join(tmpdir(), `gate-root-marker-${process.pid}-${Date.now()}`);
|
||||
const release = join(tmpdir(), `gate-root-release-${process.pid}-${Date.now()}`);
|
||||
const fakeNpm = join(fakeBin, "npm");
|
||||
write(fakeNpm, `#!/bin/sh\n: > "$OPENRIG_TEST_MARKER"\nwhile [ ! -e "$OPENRIG_TEST_RELEASE" ]; do sleep 0.02; done\nexit 0\n`);
|
||||
chmodSync(fakeNpm, 0o755);
|
||||
const port = await freePort();
|
||||
|
||||
const child = spawn(process.execPath, [GATE_SCRIPT], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeBin}:${process.env.PATH}`,
|
||||
OPENRIG_TEST_MARKER: marker,
|
||||
OPENRIG_TEST_RELEASE: release,
|
||||
OPENRIG_GATE_LANE_PORT: String(port),
|
||||
OPENRIG_GATE_VERDICT: verdictPath,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
const closed = new Promise((resolve) => child.on("close", resolve));
|
||||
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!existsSync(marker) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
if (!existsSync(marker)) write(release, "abort\n");
|
||||
assert.equal(existsSync(marker), true, "the real gate reached its first injected leg");
|
||||
rmSync(join(root, "node_modules/@openrig/cli"), { force: true });
|
||||
symlinkSync("../../packages/other", join(root, "node_modules/@openrig/cli"));
|
||||
write(release, "continue\n");
|
||||
|
||||
const status = await closed;
|
||||
assertHermeticRefusal({ status, stdout, stderr }, /@openrig\/cli[\s\S]*packages\/other[\s\S]*packages\/cli|gate-hermeticity/i);
|
||||
assert.equal(existsSync(verdictPath), false, "a late dependency-root drift writes no verdict");
|
||||
});
|
||||
+64
-2
@@ -5,12 +5,13 @@
|
||||
// (advisory, recorded) → run BOTH legs (typecheck AND vitest AND vitest:ui — the two honesty gaps) →
|
||||
// write the C2 verdict (green carries the load it ran under) → release (kernel also frees on death).
|
||||
import { spawnSync, execSync } from "node:child_process";
|
||||
import { writeFileSync, readFileSync, mkdirSync } from "node:fs";
|
||||
import { writeFileSync, readFileSync, mkdirSync, realpathSync, unlinkSync } from "node:fs";
|
||||
import { tmpdir, loadavg } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { join, dirname, basename, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { acquireGateLane, GATE_LANE_PORT } from "./gate-lane-lock.mjs";
|
||||
import { renderRefusal, runGate, observeForeignLoad, cleanStaleVendoredBundle } from "./gate-lane-run.mjs";
|
||||
import { checkDependencyRoot } from "./gate-lane-hermeticity.mjs";
|
||||
|
||||
const RUNTIME_DIR = (() => { const d = join(tmpdir(), "openrig-gate"); mkdirSync(d, { recursive: true }); return d; })();
|
||||
const HOLDER_INFO = join(RUNTIME_DIR, "gate-lane.holder.json");
|
||||
@@ -37,6 +38,53 @@ function snapshotProcesses() {
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function runGit(args, cwd) {
|
||||
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
||||
if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim()}`);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function readCandidateSha(repoRoot) {
|
||||
const sha = runGit(["rev-parse", "HEAD"], repoRoot).trim();
|
||||
if (!sha) throw new Error("candidate HEAD is empty");
|
||||
return sha;
|
||||
}
|
||||
|
||||
function canonicalVerdictRelativePath(repoRoot) {
|
||||
const verdictPath = resolve(VERDICT_PATH);
|
||||
let canonicalPath = verdictPath;
|
||||
try {
|
||||
canonicalPath = realpathSync(verdictPath);
|
||||
} catch {
|
||||
try { canonicalPath = join(realpathSync(dirname(verdictPath)), basename(verdictPath)); } catch { /* absent parent: no status entry can match */ }
|
||||
}
|
||||
const rel = relative(realpathSync(repoRoot), canonicalPath);
|
||||
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
|
||||
return rel.split(sep).join("/");
|
||||
}
|
||||
|
||||
function assertCandidateClean(repoRoot) {
|
||||
const canonicalVerdict = canonicalVerdictRelativePath(repoRoot);
|
||||
const records = runGit(["status", "--porcelain=v1", "-z", "--untracked-files=all"], repoRoot)
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const unexpected = records.filter((record) => {
|
||||
if (record.length < 4 || record[2] !== " ") return true;
|
||||
return record.slice(3).split(sep).join("/") !== canonicalVerdict;
|
||||
});
|
||||
if (unexpected.length > 0) {
|
||||
throw new Error(`candidate worktree is dirty: ${unexpected.join(" | ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function invalidatePriorVerdict() {
|
||||
try {
|
||||
unlinkSync(VERDICT_PATH);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const startedAt = new Date().toISOString();
|
||||
const lane = await acquireGateLane({ port: GATE_LANE_PORT, holderInfoPath: HOLDER_INFO });
|
||||
@@ -46,8 +94,15 @@ async function main() {
|
||||
}
|
||||
let exitCode = 3;
|
||||
try {
|
||||
// This acquired run supersedes every earlier receipt, including for the same HEAD.
|
||||
invalidatePriorVerdict();
|
||||
const foreignLoad = observeForeignLoad({ loadavg: loadavg(), processes: snapshotProcesses() });
|
||||
if (foreignLoad.advisory.length) console.warn(`⚠ advisory (exit UNCHANGED, recorded in verdict): ${foreignLoad.advisory.join("; ")}`);
|
||||
// Bind the run to one clean, worktree-local candidate BEFORE the first repository mutation.
|
||||
const repoRoot = process.cwd();
|
||||
const candidateSha = readCandidateSha(repoRoot);
|
||||
assertCandidateClean(repoRoot);
|
||||
checkDependencyRoot(repoRoot);
|
||||
// SOURCE-TRUTH: drop any stale vendored daemon bundle (a gitignored build:package leftover) before
|
||||
// the legs run, so test:repo's freshness guard is never poisoned by a desk leftover. Real package-time
|
||||
// assembly is still guarded; a fresh checkout is a no-op.
|
||||
@@ -61,6 +116,13 @@ async function main() {
|
||||
// picks the executor AND flows into the verdict, so the two can never disagree. The gate's own
|
||||
// negative-control test calls THIS SAME runGate — it guards this shipped path, not a lookalike.
|
||||
const verdict = await runGate({ smoke: SMOKE, realExec, foreignLoad, startedAt, ledger, cutCeiling });
|
||||
const endingSha = readCandidateSha(repoRoot);
|
||||
if (endingSha !== candidateSha) {
|
||||
throw new Error(`candidate HEAD changed during gate (started ${candidateSha}, ended ${endingSha})`);
|
||||
}
|
||||
assertCandidateClean(repoRoot);
|
||||
checkDependencyRoot(repoRoot);
|
||||
verdict.candidateSha = candidateSha;
|
||||
mkdirSync(dirname(VERDICT_PATH), { recursive: true }); // ensure the verdict's home exists
|
||||
writeFileSync(VERDICT_PATH, JSON.stringify(verdict, null, 2));
|
||||
console.log(verdict.ledgerState); // in-band LOUD: name every exclusion (or "0 exclusions")
|
||||
|
||||
Reference in new issue
Block a user