Files
OwnCord/scripts/run.mjs
T
Claude 63b522494c refactor: move the protocol schema to protocol/schema.json (RL-09)
The WebSocket message-type schema is the one artifact in this repository that
neither component owns: `Server/ws/message_types.go` and
`Client/src/lib/protocolTypes.ts` are both generated from it, and neither may
be hand-edited. It nonetheless lived at `docs/protocol-schema.json` — filed
under the directory for prose, whose own README calls it "Reference" material
— and its generator lived at `Server/scripts/genprotocol/`, i.e. inside one of
the two consumers. Ownership was legible from neither location.

The obvious fix — move the generator to the repository root alongside the
schema, so the whole tool is at the cross-component boundary — is wrong here.
The generator is a Go `package main`, and Go modules are directory-rooted:
`Server/go.mod` roots at `Server/`, so a root-level Go program needs a second
module or a `go.work`. That second module would sit outside every path filter
this repository already has — `golangci-lint` runs with `working-directory:
Server/` (ci.yml), `go vet ./...` runs from `Server/` (scripts/run.mjs,
.githooks/pre-commit), `.githooks/pre-commit` selects Go files with
`^Server/.*\.go$`, `.githooks/pre-push` sets `server_changed` on `^Server/`,
setup-go caches on `Server/go.sum`, and dependabot has one gomod block for
`/Server`. Six gates would silently stop covering the generator, each failing
open. The schema is data and moves freely; the generator is Go and stays where
the Go toolchain already runs.

Done instead:
- `docs/protocol-schema.json` -> `protocol/schema.json`. A new top-level
  `protocol/` is the cross-component boundary, with a `README.md` naming the
  two generated consumers, the one command, and the four gates.
- `Server/scripts/genprotocol/` -> `Server/cmd/genprotocol/`, the module's
  conventional home for an executable. This also empties `Server/scripts/` of
  Go entry points except `seed.go`, which RL-10 moves next.
- `Server/cmd/` added to `Server/.dockerignore` and `Server/.air.toml`, which
  both already excluded `Server/scripts/`. Without this the move would have
  silently widened the Docker build context and the air watch set.

27 files, 115 insertions, 76 deletions. Two runtime path resolvers re-pointed
(`cmd/genprotocol/main.go:41` `-schema` default, `ws/protocol_contract_test.go:67`
`filepath.Join`); two git-hook grep patterns (`pre-commit:53`, `pre-push:57`);
eight generator call sites across five files (Makefile x2, scripts/run.mjs x2,
pre-commit x2, ci-check skill, bughunt-fix.js); two broken relative markdown
links (docs/README.md:47, docs/protocol.md:1497); two generated files
regenerated, header lines only, zero constants changed; two ledger prose hits
plus a `render-ledger.mjs` re-render. No new verify was written: the
regenerate-and-diff check is already enforced three times (CI `make
protocol-verify`, `.githooks/pre-commit`, `npm run check:server`) and
`ws/protocol_contract_test.go` independently checks the schema against the
constants a fourth time.

Verified: both directions, for both resolvers. With `protocol/schema.json`
removed, `go test ./ws/ -run TestProtocol` fails with `reading protocol schema
at /home/user/OwnCord/protocol/schema.json: no such file or directory` (two
tests) and `go run ./cmd/genprotocol` exits 1 with `read schema: open
../protocol/schema.json: no such file or directory`; with the file restored
both pass. So the new path is genuinely resolved, not merely spelled in a
comment. The hook patterns were exercised directly: the pre-commit pattern
matches `protocol/schema.json` and `Server/cmd/genprotocol/main.go` and no
longer matches `docs/protocol-schema.json`; the pre-push pattern matches
`protocol/schema.json`. `go run ./cmd/genprotocol` twice in a row leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the committed outputs are exactly what the generator emits.
`go build ./...` and `go vet ./...` pass; `npx prettier --check .`,
`npm run typecheck` and `npm run lint` pass; `node .superpowers/render-ledger.mjs
--check` reports 348 findings valid.

Not included: the four dated `docs/audit-*.md` files, the older
`docs/plans/*`, and `CHANGELOG.md` keep the old path — they are point-in-time
records, and `.prettierignore` and `scripts/check-doc-counts.mjs` already
treat them as deliberately unmaintained. The B1 plan itself keeps its own
wording, since it states intent rather than current state. `Server/scripts/`
is not deleted: it still holds `seed.go` (RL-10), `k6/`, `toxiproxy/` and two
shell scripts. `Server/telemetry/metrics.go:19` declares a scope for a
`Server/voice` package that does not exist — spotted here, unrelated to this
move, left for RL-13's sweep to carry forward verbatim rather than fixed
inside a relocation. No `seed:` Make target was added.

Refs RL-09, L-09
2026-08-26 19:37:11 +00:00

259 lines
9.3 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",
),
];
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,
];
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"),
];
// 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"], ".")];
// 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",
),
];
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",
),
],
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);