Files
OwnCord/scripts/run.mjs
J3vbandClaude Fable 5 8cb0ec9e35 feat(b3-6): contract drift — generated route, table and config-key indexes with a CI drift check (#1456)
* feat(b3-6): contract drift — generated route, table and config-key indexes

B3-6 item 9 (workstream 10). `check:server` already diffs the two
generators; this adds a third for the three server contracts that only
prose described until now.

`Server/cmd/gendocs` rewrites one marked block per document:

- `docs/api.md` "Route index (generated)" — 111 rows from `chi.Walk` over
  the production router built with uploads, voice and the GIF proxy on,
  the same scaffolding `api/absence_contract_test.go` uses. Carries that
  test's vacuity guards: fewer than 100 routes, or no `/admin/` route,
  fails the run.
- `docs/schema.md` "Table index (generated)" — 34 rows from `sqlite_master`
  and `pragma_table_info` on an in-memory database with the migrations
  applied. sqlc exposes no catalog, so the migrated schema is the catalog.
- `docs/server-configuration.md` "Key index (generated)" — 56 keys from the
  koanf struct tags, each mapped to the `###` section of the hand-written
  reference that names it. A key documented nowhere fails the run by name.

Output is padded exactly the way Prettier formats a table, so the drift
check and the hygiene gate agree instead of undoing each other.

Wiring, copied from protocol-verify: `make docs-generate` / `make
docs-verify`, a `DOCS_VERIFY` step in `check:server` and the generator in
`generate` (`scripts/run.mjs`), a CI step on the ubuntu leg of
`server-build-test`, and a `.githooks/pre-commit` block on router, handler,
migration, config and generator paths.

Everything hand-written in the three documents is untouched. The new
`cmd/gendocs` file imports `db` for the catalog, so it takes a boundary row
in the B3-0 inventory and `server-boundaries.md` is regenerated with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 9 (machine-readable contract drift)

Records the three RED controls and their restore, the counts (111 routes,
34 tables, 56 config keys, 0 undocumented), and two corrections to the item's
spec: the configuration reference table lives in docs/server-configuration.md,
not docs/deployment.md, and sqlc exposes no catalog — the migrated in-memory
schema is the catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): gendocs — exclude ANALYZE artifacts, honest hook message, admin routes trigger the hook, generate order, width ceiling

Review findings on item 9.

1. The table index dropped `sqlite_stat1` / `sqlite_stat4`. `db.Migrate` runs
   ANALYZE after applying migrations, so those hold planner statistics, not
   schema — and `sqlite_stat4` exists only because the current
   modernc.org/sqlite build has STAT4, so a driver bump would have failed the
   docs drift check on an unrelated dependency PR. Filtered with GLOB (LIKE's
   `_` is a wildcard), block regenerated, header line's justification
   corrected: 34 -> 32 tables.
2. The pre-commit message now covers both failure modes — stale blocks are
   regenerated and staged, a key the tool named as undocumented is documented
   in docs/server-configuration.md.
3. `Server/admin/.*\.go` added to the hook's trigger: the 34 `/admin/api/*`
   routes are registered there, not in api/router.go, so a new admin route
   could commit stale docs locally.
4. `run.mjs` `generate` runs gendocs after `sqlc generate` — gendocs compiles
   the api package, which imports db/dbgen.
5. The vacuity guard now requires a traversed `/admin/api/` subroute rather
   than any `/admin/` path, which the per-method mount catch-alls satisfied on
   their own, so its message is true. `writeTable` gained a comment naming its
   ceiling: padding counts runes, Prettier counts display width, so a
   full-width cell would diverge — none exists in the generated content.

Evidence block updated for the new table count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): gendocs — generate the route index from the full-tag build with telemetry on; the hook triggers on every api/ and admin/ Go file (Codex P2s on #1456)

1. `/metrics` was missing from the route index. It mounts only when
   `telemetry.PrometheusHandler()` returns non-nil (api/router.go:431-437),
   which needs the otel build tag AND telemetry enabled at runtime; the
   generator ran in the default build with telemetry unset, so the index
   omitted a production route.

   The route index is now the superset build. The scaffold config enables
   telemetry with the Prometheus exporter and the tool calls telemetry.Init
   the way main.go does, and every invocation passes -tags otel,wazero:
   Makefile docs-generate/docs-verify, scripts/run.mjs (DOCS_VERIFY and
   generate), .githooks/pre-commit, the regenCmd quoted into all three block
   header lines, and the CLAUDE.md row. ci.yml inherits it through
   `make docs-verify`. The route block's header line now says which build it
   came from and what is enabled.

   Rather than a build-tag constant, the tool checks the condition that
   actually gates the route: if telemetry.Init leaves no Prometheus handler
   it exits non-zero naming the tags, so the default build cannot quietly
   generate a short index.

   Nothing under Server/api or Server/admin carries a build constraint, so
   wazero adds and removes no route; it rides along so one build serves the
   whole repository. Route count 111 -> 121 (ten per-method rows for the
   /metrics mount, the same shape chi gives /admin and /livekit).

2. The pre-commit trigger named individual api/ files and missed
   client_update.go, whose MountClientUpdateRoute registers a route directly.
   It is now the whole of Server/api/ and Server/admin/ — naming files
   individually is how a trigger goes stale — plus the existing migrations/,
   config/config.go and cmd/gendocs/ patterns.

Evidence block updated: route count and the tagged-build decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:18:54 +00:00

298 lines
11 KiB
JavaScript

#!/usr/bin/env node
// Root command facade (RL-04 / L-04).
//
// One entry point for the checks CI runs, so a contributor does not have to
// know which directory each stack lives in. `node scripts/run.mjs --list`
// prints every task and the exact commands it runs.
//
// Two rules this file exists to keep:
//
// 1. Cross-platform. No `make`, no shell syntax, no `cd &&`. Every step is
// spawned directly with an explicit `cwd`, so there is no shell to quote
// for and nothing that behaves differently on Windows.
// 2. The facade orchestrates, it never becomes the only path. Each step
// prints the command it runs, in the directory it runs it in, so a
// Go-only contributor can read the output and type those commands
// instead — and never needs Node to work on the server.
//
// Dependency-free by design: Node's standard library only, like
// .superpowers/render-ledger.mjs. Adding a dependency here would mean
// `npm run check` could not run until `npm install` had.
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const WIN = process.platform === "win32";
// npm and npx are batch shims on Windows; everything else is a real binary.
const bin = (c) => (WIN && (c === "npm" || c === "npx") ? `${c}.cmd` : c);
// Node refuses to spawn a .cmd/.bat with shell:false (the CVE-2024-27980
// mitigation): it fails with EINVAL and a null exit status. So the Windows npm
// shims need a shell, and only they do -- every other command here is a real
// binary, and a shell would put its quoting rules between us and the arguments.
const needsShell = (c) => WIN && (c === "npm" || c === "npx");
/** A step that always runs. */
const step = (cmd, args, cwd = ".") => ({ cmd, args, cwd });
/**
* A step that is skipped, with a printed reason, when `probe` is not on PATH.
* Used for tools CI installs but a contributor may not have: golangci-lint has
* no wrapper in this repo at all, and sqlc is pinned by Server/sqlc.version.
*/
const optional = (probe, cmd, args, cwd, why) => ({ cmd, args, cwd, probe, why });
/**
* Tracked files matching `patterns`. shellcheck and actionlint take file lists,
* and the only correct list is the tracked one: `.claude/worktrees/` holds
* gitignored copies of the tree that a filesystem glob would happily lint.
*/
const tracked = (...patterns) => {
const r = spawnSync("git", ["ls-files", "-z", ...patterns], { cwd: ROOT, encoding: "utf8" });
return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : [];
};
// `git diff --exit-code` after regenerating is what `make protocol-verify` and
// `make sqlc-verify` reduce to. Inlined so neither needs make.
const PROTOCOL_VERIFY = [
step("go", ["run", "./cmd/genprotocol"], "Server"),
step(
"git",
["diff", "--exit-code", "ws/message_types.go", "../Client/src/lib/protocolTypes.ts"],
"Server",
),
];
// The route, table and config-key indexes in docs/. Same shape again: the
// generator rewrites the marked blocks, git reports any drift. cmd/gendocs
// also exits non-zero on its own when a config key is documented nowhere, or
// when it was built without -tags otel,wazero -- the superset build the route
// index is generated from, since /metrics mounts only under otel.
const DOCS_VERIFY = [
step("go", ["run", "-tags", "otel,wazero", "./cmd/gendocs"], "Server"),
step(
"git",
[
"diff",
"--exit-code",
"../docs/api.md",
"../docs/schema.md",
"../docs/server-configuration.md",
],
"Server",
),
];
const SQLC_VERIFY = [
optional(
"sqlc",
"sqlc",
["generate"],
"Server",
"sqlc not on PATH — install the version in Server/sqlc.version",
),
optional("sqlc", "git", ["diff", "--exit-code", "db/dbgen"], "Server", "sqlc not on PATH"),
];
const CHECK_SERVER = [
step("go", ["build", "./..."], "Server"),
step("go", ["build", "-tags", "otel", "./..."], "Server"),
step("go", ["build", "-tags", "wazero", "./..."], "Server"),
step("go", ["build", "-tags", "otel,wazero", "./..."], "Server"),
step("go", ["vet", "./..."], "Server"),
step("go", ["test", "-race", "./..."], "Server"),
step("go", ["test", "-tags", "deadlock", "-count=1", "./ws/"], "Server"),
optional(
"golangci-lint",
"golangci-lint",
["run", "./..."],
"Server",
"golangci-lint not on PATH — CI pins v2.11.3",
),
...PROTOCOL_VERIFY,
...SQLC_VERIFY,
...DOCS_VERIFY,
];
const CHECK_CLIENT = [
step("npm", ["run", "typecheck"], "Client"),
step("npm", ["run", "lint"], "Client"),
step("npm", ["test"], "Client"),
];
// Matches ci.yml's Rust Unit Tests job exactly: --lib for tests, --all-targets
// for clippy. They differ deliberately; do not "align" them.
const CHECK_RUST = [
step("cargo", ["fmt", "--all", "--", "--check"], "Client/src-tauri"),
step("cargo", ["test", "--lib"], "Client/src-tauri"),
step("cargo", ["clippy", "--all-targets", "--", "-D", "warnings"], "Client/src-tauri"),
];
// RL-07. FINDINGS.md is not tracked, so there is no committed rendering to
// drift — the gate is that generation must succeed. Rendering subsumes
// `--check`: main() validates and exits 1 on a schema problem before it writes.
// It also leaves the contributor a readable copy, which is the point of running
// it locally. CI additionally renders twice and compares, to prove the output
// is a pure function of the ledger; that needs a temp path, so it lives in
// ci.yml rather than here.
// `step`, not `optional`: this file is itself Node, so probing for it is theatre.
const LEDGER_VERIFY = [step("node", [".superpowers/render-ledger.mjs"], ".")];
// Fast and dependency-free, so it goes first: a contradicted count should not
// wait behind ten minutes of -race.
const CHECK_DOCS = [step("node", ["scripts/check-doc-counts.mjs"], "."), ...LEDGER_VERIFY];
// Repository-wide formatting and script/workflow lint (RL-19 / L-13, S-05).
//
// No `gofmt -l` step here on purpose: `gofmt -l` prints offenders and still
// exits 0, so it cannot fail a build. Go formatting is enforced by the
// `formatters` block in Server/.golangci.yml, which runs inside the pinned
// Lint check, and by .githooks/pre-commit on staged files.
const CHECK_HYGIENE = [
step("npx", ["prettier", "--check", "."], "."),
optional(
"shellcheck",
"shellcheck",
tracked("*.sh", ".githooks/pre-commit", ".githooks/pre-push"),
".",
"shellcheck not on PATH — no clean Windows install; CI runs it",
),
optional(
"actionlint",
"actionlint",
tracked(".github/workflows/*.yml"),
".",
"actionlint not on PATH — no clean Windows install; CI runs it",
),
// L-16. actionlint validates expression syntax and action inputs; it has no
// concept of who a condition admits or how long a job may run. This asserts
// the guards on workflows that spend. `step`, not `optional`: it is Node, and
// this file is Node. It lives in check:hygiene so it runs inside the pinned
// Repository Hygiene job rather than needing a new required check.
step("node", ["scripts/check-workflow-guards.mjs", "--selftest"], "."),
step("node", ["scripts/check-workflow-guards.mjs"], "."),
];
const TASKS = {
bootstrap: [
step("npm", ["ci"], "."),
step("npm", ["ci"], "Client"),
step("npm", ["ci"], "tools/mcp-introspect"),
],
"check:server": CHECK_SERVER,
"check:client": CHECK_CLIENT,
"check:rust": CHECK_RUST,
"check:docs": CHECK_DOCS,
"check:hygiene": CHECK_HYGIENE,
check: [...CHECK_DOCS, ...CHECK_HYGIENE, ...CHECK_SERVER, ...CHECK_CLIENT, ...CHECK_RUST],
generate: [
step("go", ["run", "./cmd/genprotocol"], "Server"),
optional(
"sqlc",
"sqlc",
["generate"],
"Server",
"sqlc not on PATH — install the version in Server/sqlc.version",
),
// After sqlc: gendocs compiles the api package, which imports db/dbgen.
step("go", ["run", "-tags", "otel,wazero", "./cmd/gendocs"], "Server"),
],
format: [
step("npx", ["prettier", "--write", "."], "."),
optional("gofmt", "gofmt", ["-w", "."], "Server", "gofmt not on PATH"),
step("cargo", ["fmt", "--all"], "Client/src-tauri"),
],
"release:preflight": [
...CHECK_DOCS,
...CHECK_HYGIENE,
...CHECK_SERVER,
...CHECK_CLIENT,
...CHECK_RUST,
step("npm", ["run", "build"], "Client"),
],
};
// Resolve against PATH directly instead of shelling out to `where`/`command`.
// Both probes were unreliable: `where.exe` lives in C:\WINDOWS\System32, which a
// Git Bash PATH does not always contain (it can carry only the subdirectories),
// and a probe that cannot start reports "not installed" for a tool that is. That
// turned every optional() step into a permanent SKIP on Windows.
function onPath(cmd) {
const exts = WIN ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""];
const dirs = (process.env.PATH || "").split(WIN ? ";" : ":");
return dirs.some((dir) => dir && exts.some((ext) => existsSync(join(dir, cmd + ext))));
}
function runTask(name) {
const steps = TASKS[name];
if (!steps) {
console.error(`unknown task: ${name}\nknown: ${Object.keys(TASKS).join(", ")}`);
process.exit(2);
}
const skipped = [];
for (const s of steps) {
if (s.probe && !onPath(s.probe)) {
console.log(`\n--- SKIP ${s.cmd} ${s.args.join(" ")} (${s.why})`);
skipped.push(s.probe);
continue;
}
const where = s.cwd === "." ? "" : ` [in ${s.cwd}]`;
console.log(`\n--- ${s.cmd} ${s.args.join(" ")}${where}`);
// With shell:true Node deprecates a separate args array (DEP0190), because it
// concatenates without escaping. So concatenate deliberately instead: the only
// commands that take this branch are the npm shims, and no argument in this
// file contains a space.
const shell = needsShell(s.cmd);
const r = shell
? spawnSync([bin(s.cmd), ...s.args].join(" "), {
cwd: join(ROOT, s.cwd),
stdio: "inherit",
shell: true,
})
: spawnSync(s.cmd, s.args, {
cwd: join(ROOT, s.cwd),
stdio: "inherit",
shell: false,
});
if (r.error && r.error.code !== "ENOENT") {
console.error(`\nFAILED: ${s.cmd} could not be started: ${r.error.code}`);
process.exit(1);
}
if (r.error && r.error.code === "ENOENT") {
console.error(`\nFAILED: ${s.cmd} is not installed or not on PATH.`);
process.exit(1);
}
if (r.status !== 0) {
console.error(`\nFAILED: ${s.cmd} ${s.args.join(" ")}${where} exited ${r.status}`);
process.exit(r.status ?? 1);
}
}
if (skipped.length) {
console.log(
`\n${name}: passed, with ${[...new Set(skipped)].join(", ")} skipped (not installed). CI runs them.`,
);
} else {
console.log(`\n${name}: passed`);
}
}
const arg = process.argv[2];
if (!arg || arg === "--list") {
for (const [name, steps] of Object.entries(TASKS)) {
console.log(`\n${name}`);
for (const s of steps) {
const where = s.cwd === "." ? "" : ` (in ${s.cwd})`;
console.log(` ${s.probe ? "[optional] " : ""}${s.cmd} ${s.args.join(" ")}${where}`);
}
}
console.log("");
process.exit(0);
}
if (!existsSync(join(ROOT, "Server")) || !existsSync(join(ROOT, "Client"))) {
console.error("run this from the repository root");
process.exit(2);
}
runTask(arg);