SoCal, the whole bay, a moon, and gates that actually run
Six agents in parallel, and the two city packs independently reported the same blocker: `focusRegions` and `coarseFactor` existed on the `City` type and nothing implemented them. Uniform lattices would have been 2.9M points for Southern California and 3.7M for the expanded bay. Both packs were unloadable as written. `buildAxis` is the answer, and it is honest about its limits: refinement is per axis, not per rectangle, so a focus region sharpens its whole row *and* its whole column. Two regions at opposite corners refine nearly everything between them. Measured, not guessed — the bay went 0.53M points with one region and 1.64M with three, for detail nobody is looking at from a board this wide. One region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s. Then three things that were only ever right because San Francisco was the only city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a 230-unit board; the bay is 1003 units across and the camera physically could not retreat far enough to frame it. Fog distances were scene units pinned to the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest weather, which over ninety-four kilometres of bay correctly hides three quarters of it — the night view was a black rectangle for a completely reasonable reason. All three now derive from the board. The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a physical ratio of one to four hundred thousand. What is being reproduced is what a moonlit night looks like on a screen in a lit room. The CI gate caught itself, which is the part worth keeping. Port 8431 was already held by a server from an earlier session, so the boot check polled a healthy stranger while the process it started died on EADDRINUSE. It now refuses to run rather than pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The no-binary-art gate: `src/**` holds no committed binary art, ever.
|
||||
*
|
||||
* This is the check that backs the licensing argument in ARCHITECTURE.md §3 —
|
||||
* every mesh is a function composing cached unit primitives and every texture is
|
||||
* drawn on a 2D canvas from seeded noise, which is what gives the repo zero
|
||||
* asset-licensing exposure. A committed `.glb` or `.png` is a claim about
|
||||
* provenance that nobody in CI can verify, so the answer is that they do not
|
||||
* arrive at all.
|
||||
*
|
||||
* Two things about the scope, both of them corrections of an earlier design that
|
||||
* CONTRACT.md §7 calls out by name.
|
||||
*
|
||||
* The enumeration is `git ls-files` and never a filesystem walk. A self-hoster
|
||||
* is told, by CONTRIBUTING.md and by the office packs, to drop their own
|
||||
* legally-clean art into `public/props/` and friends; a walk would find it and
|
||||
* fail their build over files this repo does not distribute and has no opinion
|
||||
* about. Tracked files are the only files a licence claim can be made about, so
|
||||
* tracked files are the only files enumerated. If git is missing, this exits
|
||||
* non-zero rather than falling back to a walk — a gate that quietly changes what
|
||||
* it measures is worse than one that stops.
|
||||
*
|
||||
* And it is strict over `src/**` only. That is where the procedural-assets
|
||||
* promise lives. `public/props/`, `public/kits/`, `public/offices/` and `docs/`
|
||||
* are hard-exempt below and git-ignored besides: they are self-hoster space.
|
||||
*
|
||||
* Runs by hand as `node scripts/check-no-binaries.mjs` from anywhere in the
|
||||
* working tree.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
// ---- The rule ----
|
||||
|
||||
/**
|
||||
* Art and binary-asset extensions. Lowercased at the comparison, so a `.PNG`
|
||||
* off a Windows checkout is caught too.
|
||||
*/
|
||||
const DENIED_EXTENSIONS = new Set([
|
||||
".glb",
|
||||
".gltf",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".hdr",
|
||||
".exr",
|
||||
".ttf",
|
||||
".otf",
|
||||
".woff",
|
||||
".woff2",
|
||||
".mp3",
|
||||
".wav",
|
||||
".fbx",
|
||||
".obj",
|
||||
]);
|
||||
|
||||
/** The pathspec handed to `git ls-files`. Everything outside it is unexamined. */
|
||||
const SCOPE = "src";
|
||||
|
||||
/**
|
||||
* Self-hoster space, exempt even if something puts it inside the scope later.
|
||||
* These are repo-relative prefixes, matched against the paths git reports.
|
||||
*/
|
||||
const EXEMPT_PREFIXES = [
|
||||
"public/props/",
|
||||
"public/kits/",
|
||||
"public/offices/",
|
||||
"docs/",
|
||||
];
|
||||
|
||||
// ---- Enumeration ----
|
||||
|
||||
function repoRoot() {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch {
|
||||
fail(
|
||||
"could not ask git for the repo root.",
|
||||
"This check enumerates tracked files with `git ls-files` and deliberately has no",
|
||||
"filesystem-walk fallback, because a walk would fail a self-hoster's build over",
|
||||
"their own untracked art. Run it inside a git checkout.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function trackedFiles(root, pathspec) {
|
||||
const out = execFileSync("git", ["ls-files", "-z", "--", pathspec], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
// A NUL-separated listing of a repo this size is tiny, but the default 1 MB
|
||||
// ceiling is close enough to be worth not thinking about again.
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
return out.split("\0").filter((path) => path !== "");
|
||||
}
|
||||
|
||||
/**
|
||||
* The extension, lowercased, or the empty string. A leading dot with no stem —
|
||||
* `src/.keep` — is a dotfile and has no extension, which is why this compares
|
||||
* against the last slash rather than reaching for `path.extname`.
|
||||
*/
|
||||
function extensionOf(path) {
|
||||
const dot = path.lastIndexOf(".");
|
||||
const slash = path.lastIndexOf("/");
|
||||
if (dot <= slash + 1) return "";
|
||||
return path.slice(dot).toLowerCase();
|
||||
}
|
||||
|
||||
function isExempt(path) {
|
||||
return EXEMPT_PREFIXES.some((prefix) => path.startsWith(prefix));
|
||||
}
|
||||
|
||||
// ---- Reporting ----
|
||||
|
||||
function fail(...lines) {
|
||||
console.error(`\ncheck-no-binaries: FAIL — ${lines[0]}\n`);
|
||||
for (const line of lines.slice(1)) console.error(line);
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---- Run ----
|
||||
|
||||
const root = repoRoot();
|
||||
const tracked = trackedFiles(root, SCOPE);
|
||||
const offenders = tracked
|
||||
.filter((path) => !isExempt(path))
|
||||
.filter((path) => DENIED_EXTENSIONS.has(extensionOf(path)));
|
||||
|
||||
if (offenders.length > 0) {
|
||||
const listed = offenders.map((path) => ` ${path} (${extensionOf(path)})`);
|
||||
fail(
|
||||
`${offenders.length} binary asset${offenders.length === 1 ? "" : "s"} tracked under ${SCOPE}/.`,
|
||||
...listed,
|
||||
"",
|
||||
"Apache 2.0 is a promise that everything in this repo is ours to give away, and a",
|
||||
"committed binary asset is a provenance claim no reviewer can check. Assets here are",
|
||||
"procedural TypeScript: meshes compose cached unit primitives, textures are drawn on a",
|
||||
"2D canvas from seeded noise. See ARCHITECTURE.md §3 and CONTRACT.md §3.",
|
||||
"",
|
||||
"If this is your own art for your own deployment, it does not belong in src/ at all —",
|
||||
`put it under ${EXEMPT_PREFIXES.join(", ")}, which are git-ignored self-hoster space, and`,
|
||||
"this check will never see it.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`check-no-binaries: ok — ${tracked.length} tracked file${tracked.length === 1 ? "" : "s"} under ` +
|
||||
`${SCOPE}/, none matching ${DENIED_EXTENSIONS.size} denied extensions.`,
|
||||
);
|
||||
console.log(
|
||||
` enumerated with \`git ls-files\`, so untracked local art is invisible to this check.`,
|
||||
);
|
||||
console.log(` denied: ${[...DENIED_EXTENSIONS].join(" ")}`);
|
||||
console.log(` exempt: ${EXEMPT_PREFIXES.join(" ")}`);
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The zero-config boot gate: hand the API nothing at all and it still answers.
|
||||
*
|
||||
* This exists because two independent server designs made the same mistake —
|
||||
* defaulting the weather source to a provider that requires a contact string,
|
||||
* then failing hard when the contact was absent — which breaks the one
|
||||
* acceptance test the whole repo is built around: a stranger clones this, runs
|
||||
* one command, and gets a working box with no account, no key and no network.
|
||||
* CONTRACT.md §5.1 resolves it (a source configured without what it needs is
|
||||
* demoted, not fatal) and this script is what keeps the resolution honest.
|
||||
*
|
||||
* The environment handed to the server is genuinely empty — `env: {}`, the
|
||||
* in-process equivalent of `env -i` — which is possible only because the child
|
||||
* is launched by absolute path (`process.execPath`) and so needs no PATH to find
|
||||
* itself. Nothing is stubbed and no config object is constructed by hand: this
|
||||
* starts the real entry point and asks the real socket, which is the difference
|
||||
* between this and `server/src/test/boot.test.ts`, which asserts the same
|
||||
* property one layer down.
|
||||
*
|
||||
* node scripts/check-zero-config-boot.mjs
|
||||
* node scripts/check-zero-config-boot.mjs --compose
|
||||
*
|
||||
* The second form is CONTRACT.md §0's literal wording — `docker compose up` under
|
||||
* an empty environment — and needs a working Docker. CI runs the first form,
|
||||
* because a gate that depends on docker-in-docker being present on the runner is
|
||||
* measuring the runner rather than the repo. The two assert the same thing: the
|
||||
* compose file adds only `TERA_HOST` and container hardening, and every other
|
||||
* variable in it carries a `:-` default precisely so that an empty environment
|
||||
* resolves it to the empty string.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { connect } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// ---- Where and what ----
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
||||
const HEALTH_HOST = "127.0.0.1";
|
||||
const HEALTH_PORT = 8431;
|
||||
const HEALTH_URL = `http://${HEALTH_HOST}:${HEALTH_PORT}/api/v1/health`;
|
||||
|
||||
/** How long the server gets to bind and answer before this gives up. */
|
||||
const PROCESS_DEADLINE_MS = 20_000;
|
||||
/** Compose has to build an image first, which is a different order of patience. */
|
||||
const COMPOSE_DEADLINE_MS = 300_000;
|
||||
|
||||
const useCompose = process.argv.slice(2).includes("--compose");
|
||||
|
||||
// ---- Reporting ----
|
||||
|
||||
function fail(headline, detail = [], log = []) {
|
||||
console.error(`\ncheck-zero-config-boot: FAIL — ${headline}\n`);
|
||||
for (const line of detail) console.error(line);
|
||||
if (log.length > 0) {
|
||||
console.error("\n--- what the server said ---");
|
||||
console.error(log.join("").trimEnd());
|
||||
console.error("--- end ---");
|
||||
}
|
||||
console.error("");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// ---- Making sure we are testing our own server ----
|
||||
|
||||
/**
|
||||
* Is anything already listening on the port we are about to claim?
|
||||
*
|
||||
* This guard is here because the check silently passed without it. The port was
|
||||
* occupied by an unrelated process, the server we spawned died on `EADDRINUSE`
|
||||
* within a second, and the poll cheerfully collected a 200 from the stranger —
|
||||
* a green gate asserting nothing at all. A check that can pass against a server
|
||||
* it did not start is worse than no check, so an occupied port is a hard stop.
|
||||
*/
|
||||
function portInUse() {
|
||||
return new Promise((resolve) => {
|
||||
const socket = connect({ host: HEALTH_HOST, port: HEALTH_PORT });
|
||||
const settle = (answer) => {
|
||||
socket.destroy();
|
||||
resolve(answer);
|
||||
};
|
||||
socket.setTimeout(1_500);
|
||||
socket.once("connect", () => settle(true));
|
||||
socket.once("timeout", () => settle(false));
|
||||
socket.once("error", () => settle(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function assertPortFree() {
|
||||
if (!(await portInUse())) return true;
|
||||
fail(`something is already listening on ${HEALTH_HOST}:${HEALTH_PORT}.`, [
|
||||
"This check has to own that port, because otherwise it polls whatever is there and",
|
||||
"reports a healthy stranger while the server it started is dead in a ditch. It cannot",
|
||||
"move to another port either: choosing one would mean setting TERA_PORT, and the empty",
|
||||
"environment is the thing under test.",
|
||||
"",
|
||||
"Stop whatever is on the port and run it again.",
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A second line of defence against answering for somebody else's process: if the
|
||||
* server on the other end has been up longer than this check has been running,
|
||||
* it is not the one we started.
|
||||
*/
|
||||
function uptimeLooksLikeOurs(body, startedAt) {
|
||||
const allowed = Math.ceil((Date.now() - startedAt) / 1000) + 2;
|
||||
return typeof body.uptimeSeconds !== "number" || body.uptimeSeconds <= allowed;
|
||||
}
|
||||
|
||||
// ---- The assertion ----
|
||||
|
||||
/**
|
||||
* Poll until health answers or the deadline passes. `stillRunning` lets the
|
||||
* caller abort early when the thing under test has already died, so a server
|
||||
* that exits on boot reports its own error instead of a twenty-second timeout.
|
||||
*/
|
||||
async function waitForHealth(deadlineMs, stillRunning) {
|
||||
const deadline = Date.now() + deadlineMs;
|
||||
let lastError = "nothing was listening";
|
||||
while (Date.now() < deadline) {
|
||||
if (!stillRunning()) return { dead: true, lastError };
|
||||
try {
|
||||
const response = await fetch(HEALTH_URL, {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, text };
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
return { timedOut: true, lastError };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the health body has to say on a box that was handed nothing.
|
||||
*
|
||||
* The source and mode checks are not padding. A default that needs configuration
|
||||
* is exactly the bug this gate was written for, and it would still return 200
|
||||
* while being wrong — the failure showed up as an empty sky, not as a dead
|
||||
* process. `degraded` being non-empty means the config layer demoted something,
|
||||
* which on an empty environment means a default asked for something it was never
|
||||
* going to be given.
|
||||
*/
|
||||
function checkPosture(body) {
|
||||
const problems = [];
|
||||
if (body.ok !== true) {
|
||||
problems.push(` health reported ok: ${JSON.stringify(body.ok)}, expected true`);
|
||||
}
|
||||
if (body.sources?.weather !== "none") {
|
||||
problems.push(
|
||||
` weather source defaulted to ${JSON.stringify(body.sources?.weather)}; CONTRACT.md §5.1`,
|
||||
` requires "none", because every other source wants a contact string or a key.`,
|
||||
);
|
||||
}
|
||||
if (body.auth?.mode !== "none") {
|
||||
problems.push(
|
||||
` auth mode defaulted to ${JSON.stringify(body.auth?.mode)}; CONTRACT.md §6 requires "none"`,
|
||||
` so that a self-hoster never creates an account anywhere.`,
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(body.degraded)) {
|
||||
problems.push(` health body has no degraded array; it is how demotions become visible.`);
|
||||
} else if (body.degraded.length > 0) {
|
||||
problems.push(
|
||||
` the config layer demoted ${body.degraded.length} source(s) on an empty environment:`,
|
||||
...body.degraded.map((line) => ` ${line}`),
|
||||
` A demotion here means a default was chosen that needs configuration to work.`,
|
||||
);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function report(body) {
|
||||
console.log("check-zero-config-boot: ok — 200 from /api/v1/health on an empty environment.");
|
||||
console.log(` service: ${body.service} ${body.version}`);
|
||||
console.log(
|
||||
` sources: weather=${body.sources?.weather} flights=${body.sources?.flights} ` +
|
||||
`markers=${body.sources?.markers}`,
|
||||
);
|
||||
console.log(` auth: ${body.auth?.mode}`);
|
||||
console.log(` degraded: none`);
|
||||
}
|
||||
|
||||
/** Shared tail of both modes: parse, assert, print. */
|
||||
function finish(result, log, startedAt) {
|
||||
if (result.dead) {
|
||||
fail(
|
||||
"the server exited before it ever answered.",
|
||||
[
|
||||
"It was started with a genuinely empty environment, which is the whole point: a box",
|
||||
"that needs a variable set before it will boot is not self-hostable. See CONTRACT.md §5.1.",
|
||||
` last connection attempt: ${result.lastError}`,
|
||||
],
|
||||
log,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (result.timedOut) {
|
||||
fail(
|
||||
`nothing answered ${HEALTH_URL} before the deadline.`,
|
||||
[` last connection attempt: ${result.lastError}`],
|
||||
log,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (result.status !== 200) {
|
||||
fail(
|
||||
`${HEALTH_URL} returned ${result.status}, expected 200.`,
|
||||
[
|
||||
"Health touches no upstream and reads no file by design, so a non-200 here is the",
|
||||
"server refusing to be healthy without configuration it should not need.",
|
||||
` body: ${result.text.slice(0, 500)}`,
|
||||
],
|
||||
log,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.text);
|
||||
} catch {
|
||||
fail("health answered 200 but the body was not JSON.", [` body: ${result.text.slice(0, 500)}`], log);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!uptimeLooksLikeOurs(body, startedAt)) {
|
||||
fail(
|
||||
`${HEALTH_URL} answered, but from a server this check did not start.`,
|
||||
[
|
||||
` it reports ${body.uptimeSeconds}s of uptime; this check has been running for`,
|
||||
` ${Math.ceil((Date.now() - startedAt) / 1000)}s. Something else claimed the port first.`,
|
||||
],
|
||||
log,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const problems = checkPosture(body);
|
||||
if (problems.length > 0) {
|
||||
fail("health answered 200, but not from a zero-config box.", problems, log);
|
||||
return;
|
||||
}
|
||||
report(body);
|
||||
}
|
||||
|
||||
// ---- Mode: the real entry point under an empty environment ----
|
||||
|
||||
async function runProcessMode() {
|
||||
if (!(await assertPortFree())) return;
|
||||
console.log("check-zero-config-boot: starting server/src/index.ts with env -i (no variables at all)");
|
||||
|
||||
const startedAt = Date.now();
|
||||
const child = spawn(process.execPath, ["server/src/index.ts"], {
|
||||
cwd: REPO_ROOT,
|
||||
// The empty environment is the test. `process.execPath` is absolute, so the
|
||||
// child needs no PATH to exist, and Node needs nothing else to run.
|
||||
env: {},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const log = [];
|
||||
child.stdout.on("data", (chunk) => log.push(String(chunk)));
|
||||
child.stderr.on("data", (chunk) => log.push(String(chunk)));
|
||||
|
||||
let alive = true;
|
||||
let exitInfo = "";
|
||||
child.on("exit", (code, signal) => {
|
||||
alive = false;
|
||||
exitInfo = signal ? `killed by ${signal}` : `exited with code ${code}`;
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
alive = false;
|
||||
exitInfo = `could not spawn: ${err.message}`;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await waitForHealth(PROCESS_DEADLINE_MS, () => alive);
|
||||
if (result.dead) log.push(`\n(process ${exitInfo})\n`);
|
||||
finish(result, log, startedAt);
|
||||
} finally {
|
||||
if (alive) {
|
||||
child.kill("SIGTERM");
|
||||
// The entry point closes on SIGTERM; if it does not, this is a check, not a
|
||||
// supervisor, and a lingering child would hang CI.
|
||||
for (let waited = 0; alive && waited < 5_000; waited += 100) await sleep(100);
|
||||
if (alive) child.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mode: docker compose, for whoever has a Docker ----
|
||||
|
||||
function docker(args, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("docker", args, {
|
||||
cwd: REPO_ROOT,
|
||||
// PATH and HOME are for the Docker CLI itself — finding its binary and its
|
||||
// config — and reach the container through nothing. The compose file
|
||||
// interpolates only TERA_* variables, all with `:-` defaults, so the
|
||||
// container's own environment is empty either way.
|
||||
env: { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
const out = [];
|
||||
child.stdout.on("data", (chunk) => out.push(String(chunk)));
|
||||
child.stderr.on("data", (chunk) => out.push(String(chunk)));
|
||||
child.on("close", (code) => resolve({ code, out: out.join("") }));
|
||||
child.on("error", (err) => resolve({ code: -1, out: `could not run docker: ${err.message}` }));
|
||||
});
|
||||
}
|
||||
|
||||
async function runComposeMode() {
|
||||
if (!(await assertPortFree())) return;
|
||||
const compose = ["compose", "-f", "deploy/docker-compose.yml"];
|
||||
console.log("check-zero-config-boot: docker compose up, with no .env file and no TERA_* set");
|
||||
|
||||
const up = await docker([...compose, "up", "-d", "--build"], COMPOSE_DEADLINE_MS);
|
||||
if (up.code !== 0) {
|
||||
fail("`docker compose up` failed.", [" " + up.out.trim().split("\n").join("\n ")]);
|
||||
return;
|
||||
}
|
||||
// After the build, not before it: `up -d` returns with the container just
|
||||
// started, so the uptime it reports is measured from about here.
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const result = await waitForHealth(60_000, () => true);
|
||||
const logs = await docker([...compose, "logs", "--no-color"], 30_000);
|
||||
finish(result, [logs.out], startedAt);
|
||||
} finally {
|
||||
await docker([...compose, "down", "-v"], 60_000);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Run ----
|
||||
|
||||
await (useCompose ? runComposeMode() : runProcessMode());
|
||||
Reference in New Issue
Block a user