mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-29 03:01:18 +00:00
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 atbb7d32e55d(#167739, 20,414 chars), and fails again atb19d5542f6(#173903) after the earlier trim. Freshness step wall time, median of 3 on a warm checkout (before = previous head7aae97981b, 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>
221 lines
7.8 KiB
TypeScript
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();
|
|
}
|