openclaw/scripts/check.mts
Ayaan Zaidi 2f595930f1
fix(hooks): catch stale generated files and oversized AGENTS.md before commit (#176065)
Related: #176053 (merged; it trims root `AGENTS.md` under the cap)

## What Problem This Solves

Fixes: PRs fail CI on their first push for generated files, docs lint and size caps that a seconds-fast local check would have caught, and root `AGENTS.md` regrows past the 20,000-character cap that OpenClaw loads whole.

## User Impact

No product change. Contributors and agents see these failures at commit time with the regen command, not after a CI round trip. A tracked `AGENTS.md` over the workspace bootstrap cap now fails CI.

## Why This Change Was Made

- **Pre-commit freshness checks.** `git-hooks/pre-commit` (installed by `pnpm install`) now runs existing `--check` scripts, chosen from staged paths and run in parallel: `lint:docs` on changed docs, `protocol:check`, `native:i18n:verify`, `config:docs:check`, the line-cap ratchet, and the new AGENTS.md size check. Each failure prints its fix command. Typecheck, lint and tests stay with hosted CI, and commits replayed by merge, rebase, cherry-pick or revert skip the checks. The content guard (`guard-staged-content.mjs`) runs the step and redacts its output with its existing redactor, so blocked private literals are never echoed. The hook judges only the bytes being committed: checks that import the working-tree code graph (protocol, native i18n, config docs, AGENTS.md size) are skipped with a one-line note when any tracked file has unstaged edits, `lint:docs` is skipped only when its own files or config are dirty, and the line-cap ratchet reads the index and always runs. Plain `node` package scripts start without pnpm. Path predicates live in their owners: `changed-lanes.mts` (also used by `check:changed` for the line-cap ratchet) and `hasProtocolGenerationInput` shared with CI's check family scope.
- **AGENTS.md size check.** `scripts/check-agents-md-size.mts` reads the cap from `resolveBootstrapMaxChars()` instead of copying the number. It runs in the hook, `pnpm check` and the CI check-docs job.

Left out on purpose, because each is too slow or already red on `main` for unrelated files: max-lines ratchet (~30 s), knip unused files (92 s), madge cycles (22 s), Control UI i18n (16 s), plugin SDK API diff (two full builds).

## Evidence

Back-test against first-push CI failures from a 90-day sample of merged PRs (26 rows; 2 retired checks; detached worktrees at each failing head):

| Row | CI failure | Hook before | Hook after |
|---|---|---|---|
| #122960 | docs MD051 link fragments | pass | **fail**, same 3 errors as CI |
| #112339 | Swift protocol models drift | pass | **fail**, same drift |
| #115577 | native i18n inventory drift | pass | **fail**, same message |
| #165543 | config baseline hash | pass | **fail**, same hash mismatch |
| #151896 | line-cap growth 3557 → 3676 | pass | **fail**, same counts |

The other 19 live rows need typecheck, tests, UI build or slow audits, which stay in hosted CI by design.

Size check back-test: it first fails at bb7d32e55d (#167739, 20,414 chars), and fails again at b19d5542f6 (#173903) after the earlier trim.

Freshness step wall time, median of 3 on a warm checkout (before = previous head 7aae97981b, after = this head):

| Staged change | Checks | Before | After |
|---|---|---|---|
| nothing | none | 0.17 s | 0.28 s |
| `CHANGELOG.md` | none | 0.20 s | 0.24 s |
| one docs page | lint:docs | 0.92 s | 1.13 s |
| one `src/**/*.ts` | line-cap | 1.29 s | 1.42 s |
| a scoped `AGENTS.md` | size check | 0.92 s | 1.07 s |
| protocol schema `.ts` | protocol, line-cap | 4.77 s | 4.39 s |
| Android Kotlin source | protocol, native i18n | 4.41 s | 4.48 s |
| plugin manifest | config docs | 8.19 s | 8.97 s |

Spawning `node` directly saves about 0.1–0.3 s per check against `pnpm -s`; the after column also pays one extra `git diff` for the unstaged-input rule. Most of the remaining time is in the checks themselves. `protocol:check` and `config:docs:check` are not cached: their inputs are transitive imports across `src/`, `packages/` and every plugin manifest, and no existing owner lists them reliably.

Checks run: new and existing hook tests, including `test/git-hooks-pre-commit-freshness.test.ts` (real guard, real freshness step and a real `node` runner: a partly staged docs page with a blocked literal in its unstaged edit is skipped; a staged protocol schema with an unstaged edit in an imported owner outside the trigger list skips `protocol:check` while the line-cap ratchet still runs; and a failing check that prints a private literal is redacted), `test/scripts/check-staged-freshness.test.ts`, `changed-lanes.test.ts`, `check.test.ts`, `tsgo:scripts`, `tsgo:test:root`, oxlint, oxfmt. All commits went through the new hook.

**Merge danger:** two-way door; a revert restores the previous hook and CI step. No state, config or published contract changes. Blast radius: every local commit in this repository (adds about 0.3 s to 9 s depending on staged paths) and the CI check-docs job. #176053 has landed, so the size check passes on `main`.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-10-18 10:31:34 -07:00

221 lines
7.8 KiB
TypeScript

// Runs the repository check lanes selected by CLI arguments.
import { performance } from "node:perf_hooks";
import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mts";
import { printTimingSummary } from "./lib/check-timing-summary.mts";
import { runManagedCommand } from "./lib/managed-child-process.mts";
type CheckCommand = { name: string; args: string[]; usesBase?: boolean };
type RunManagedCheck = (options: { args: string[]; bin: string }) => Promise<number>;
export const PREFLIGHT_CHECKS: CheckCommand[] = [
{ name: "conflict markers", args: ["check:no-conflict-markers"] },
{ name: "script TypeScript erasability", args: ["check:script-erasability"] },
{ name: "line-cap growth ratchet", args: ["check:line-cap-ratchet"], usesBase: true },
{ name: "SQLite worker ratchet", args: ["check:database-worker-ratchet"], usesBase: true },
{ name: "SQLite dialect ratchet", args: ["check:database-dialect-ratchet"], usesBase: true },
{ name: "max-lines suppression ratchet", args: ["check:max-lines-ratchet"], usesBase: true },
{ name: "assertion SAFETY comment ratchet", args: ["check:assertion-safety"], usesBase: true },
{ name: "test timeout race ratchet", args: ["check:test-timeout-race-ratchet"], usesBase: true },
{ name: "first-party mock export ratchet", args: ["check:test-mock-exports"], usesBase: true },
{ name: "changelog attributions", args: ["check:changelog-attributions"] },
{ name: "AGENTS.md bootstrap size", args: ["check:agents-md-size"] },
{ name: "database-first legacy-store guard", args: ["check:database-first-legacy-stores"] },
{ name: "doctor deprecation registry", args: ["check:doctor-deprecation-registry"] },
{
name: "guarded extension wildcard re-exports",
args: ["lint:extensions:no-guarded-wildcard-reexports"],
},
{
name: "plugin-sdk wildcard re-exports",
args: ["lint:extensions:no-plugin-sdk-wildcard-reexports"],
},
{
name: "deprecated channel access seams",
args: ["lint:extensions:no-deprecated-channel-access"],
},
{ name: "media download helper guard", args: ["check:media-download-helpers"] },
{ name: "runtime sidecar loader guard", args: ["check:runtime-sidecar-loaders"] },
{ name: "tool display", args: ["tool-display:check"] },
{ name: "host env policy", args: ["check:host-env-policy:swift"] },
{ name: "browser inspect script", args: ["check:browser-inspect-script:swift"] },
{ name: "native conversation privacy defaults", args: ["native-catalogs:check"] },
{ name: "opengrep rule metadata", args: ["check:opengrep-rule-metadata"] },
{ name: "duplicate scan target coverage", args: ["dup:check:coverage"] },
{ name: "npm package-lock guard", args: ["deps:npm-lock:check"] },
{ name: "package patch guard", args: ["deps:patches:check"] },
];
export function usage() {
return [
"Usage: node --import tsx scripts/check.mts [--base <ref>] [--timed] [--include-architecture] [--include-test-types]",
"",
"Runs the local check graph: guard preflights, typecheck, lint, and policy guards.",
"",
"Options:",
" --base <ref> Compare ratchets against the fork with this Git ref.",
" --timed Print timing summary even when checks pass.",
" --include-architecture Run architecture import-cycle checks instead of runtime cycles.",
" --include-test-types Typecheck production and test sources.",
" -h, --help Show this help.",
].join("\n");
}
function parseCheckArgs(argv: string[]) {
return parseFlagArgs(
argv,
{ base: "", help: false, includeArchitecture: false, includeTestTypes: false, timed: false },
[
stringFlag("--base", "base", { rejectShortOptions: true }),
booleanFlag("--timed", "timed", true, { repeatable: true }),
booleanFlag("--include-architecture", "includeArchitecture", true, { repeatable: true }),
booleanFlag("--include-test-types", "includeTestTypes", true, { repeatable: true }),
booleanFlag("--help", "help", true, { repeatable: true }),
booleanFlag("-h", "help", true, { repeatable: true }),
],
{
ignoreDoubleDash: false,
onUnhandledArg(arg: string) {
throw new Error(`unknown argument: ${arg}\n\n${usage()}`);
},
},
);
}
export async function main(argv = process.argv.slice(2)) {
let args;
try {
args = parseCheckArgs(argv);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 2;
return;
}
if (args.help) {
console.log(usage());
process.exitCode = 0;
return;
}
const tailChecks = [
{ name: "webhook body guard", args: ["lint:webhook:no-low-level-body-read"] },
{ name: "runtime action config guard", args: ["check:no-runtime-action-load-config"] },
!args.includeArchitecture
? {
name: "deprecated API usage guard",
args: ["check:deprecated-api-usage"],
}
: null,
!args.includeArchitecture
? {
name: "wrapper shadowing guard",
args: ["check:wrapper-shadowing"],
}
: null,
{ name: "temp path guard", args: ["check:temp-path-guardrails"] },
{ name: "pairing store guard", args: ["lint:auth:no-pairing-store-group"] },
{ name: "pairing account guard", args: ["lint:auth:pairing-account-scope"] },
args.includeArchitecture
? { name: "architecture import cycles", args: ["check:architecture"] }
: { name: "runtime import cycles", args: ["check:import-cycles"] },
].filter((command) => command !== null);
const stages = [
{
name: "preflight guards",
parallel: true,
commands: PREFLIGHT_CHECKS.map((command) =>
command.usesBase && args.base
? { name: command.name, args: [...command.args, "--base", args.base] }
: command,
),
},
{
name: "typecheck",
parallel: false,
commands: args.includeTestTypes
? [{ name: "typecheck all", args: ["tsgo:all"] }]
: [
{ name: "typecheck prod", args: ["tsgo:prod"] },
{ name: "typecheck scripts", args: ["tsgo:scripts"] },
{ name: "typecheck test root", args: ["tsgo:test:root"] },
],
},
{
name: "lint",
parallel: false,
commands: [
{ name: "lint", args: ["lint", ...(args.base ? ["--base", args.base] : [])] },
{ name: "format", args: ["format:check"] },
],
},
{
name: "policy guards",
parallel: true,
commands: tailChecks,
},
];
const timings = [];
let exitCode = 0;
for (const stage of stages) {
console.error(`\n[check] ${stage.name}`);
const results = stage.parallel
? await Promise.all(stage.commands.map((command) => runCommand(command)))
: await runSerial(stage.commands);
timings.push(...results);
const failed = results.find((result) => result.status !== 0);
if (failed) {
exitCode = failed.status;
break;
}
}
if (args.timed || exitCode !== 0) {
printSummary(timings);
}
process.exitCode = exitCode;
}
async function runSerial(commands: CheckCommand[]) {
const results: Array<Awaited<ReturnType<typeof runCommand>>> = [];
for (const command of commands) {
const result = await runCommand(command);
results.push(result);
if (result.status !== 0) {
break;
}
}
return results;
}
export async function runCommand(
command: CheckCommand,
runManagedCommandImpl: RunManagedCheck = runManagedCommand,
) {
const startedAt = performance.now();
let status = 1;
try {
status = await runManagedCommandImpl({
args: command.args,
bin: "pnpm",
});
} catch (error) {
console.error(error);
}
return {
name: command.name,
durationMs: performance.now() - startedAt,
status,
};
}
function printSummary(timings: Array<Awaited<ReturnType<typeof runCommand>>>) {
printTimingSummary("check", timings);
}
if (import.meta.main) {
await main();
}