ci(guards): run the architecture guards in the pre-push check (#10948)

* ci(guards): run the architecture guards in the pre-push check

* docs(limits): name lint as the numeric-limit check's owner

* ci(lint): cache the numeric-limit check by every file its restart-field scan reads
This commit is contained in:
morluto authored and GitHub committed 2026-11-15 21:38:59 +08:00
1 parent bd46f3c1ee
commit ecf5372627
8 files changed
+67 -31

No files matched your search

+5 -4
View File
@@ -236,10 +236,11 @@ control-reply budget stays separate from
## Static check
`npm run lint` runs `scripts/verify/rea-numeric-limits.mjs` after
dependencies are installed. `scripts/verify/architecture-guards.mjs` runs the
same check when `typescript6` is already installed; the pre-install CI step
cannot. The check scans `src` excluding tests,
`npm run lint` owns this check: it runs
`scripts/verify/rea-numeric-limits.mjs` after dependencies are installed, so
`npm run check:fast`, the pre-push hook and CI's static checks all include it.
`scripts/verify/architecture-guards.mjs` does not run it. The check scans `src`
excluding tests,
keeps names that are numeric limits, and requires each name to be an
`environment` on an analysis setting or a registry entry with a reason.
The registry accepts only `restart-only`. A limit checked on each call has to
+1 -1
View File
@@ -42,7 +42,7 @@ that invoke PowerShell themselves remain subject to their own policy checks.
| `npm run test:python` | LLDB and mitmproxy producer contracts plus analysis-efficiency checks; no real debugger or proxy required |
| `npm run test:watch` | Dirty source tests in watch mode, without build |
| `npm run test:watch:all` | Changed tests from every project; builds at startup, so rebuild after production edits before relying on compiled tests |
| `npm run check:fast` | Cached typecheck and lint |
| `npm run check:fast` | Cached typecheck and lint, then the architecture guards and their counted baselines |
| `npm run check:changed` | Cached typecheck/lint and branch-related source feedback |
| `npm run check:pr` | Opt-in complete local deterministic gate and generated-file checks |
| `npm run docs:check` | Generated-document validation and fresh onboarding example consistency checks |
+1 -1
View File
@@ -130,7 +130,7 @@
"precheck": "npm run deps:check",
"check": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint format:check knip metadata:check && npm run verify:architecture-guards && npm run knip:production && npm run lint:typed",
"precheck:fast": "npm run deps:check",
"check:fast": "node scripts/run-turbo.mjs run typecheck lint --output-logs=errors-only",
"check:fast": "node scripts/run-turbo.mjs run typecheck lint --output-logs=errors-only && npm run verify:architecture-guards",
"precheck:changed": "npm run deps:check",
"check:changed": "npm run check:fast && npm run test:changed",
"precheck:test": "npm run deps:check",
-21
View File
@@ -1,5 +1,3 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, readdir, writeFile } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
@@ -692,23 +690,4 @@ if (failures.length > 0) {
console.error(`Architecture guards failed:\n\n${failures.join("\n\n")}\n`);
process.exit(1);
}
// CI runs this file before `npm ci`. The numeric-limit parser needs
// typescript6, so that check waits until dependencies exist. `npm run lint`
// runs it after install.
const typescript6 = new URL(
"../../node_modules/typescript6/package.json",
import.meta.url,
);
if (!existsSync(typescript6)) {
console.log(
"Numeric REA_ limit check waits until dependencies are installed.",
);
} else {
const numericLimits = spawnSync(
process.execPath,
[fileURLToPath(new URL("./rea-numeric-limits.mjs", import.meta.url))],
{ stdio: "inherit" },
);
if (numericLimits.status !== 0) process.exit(numericLimits.status ?? 1);
}
console.log(`Architecture guards passed across ${files.length} source files.`);
+6
View File
@@ -1,6 +1,12 @@
/** Relative path of the registry. Its text is a declaration, not a producer. */
export const NUMERIC_LIMIT_DECLARATION: "src/domain/numericLimitRegistry.ts";
/** Roots scanned for the removed restart-field names. */
export const FORBIDDEN_FIELD_SCAN_ROOTS: readonly string[];
/** File extensions, without the dot, scanned under those roots. */
export const FORBIDDEN_FIELD_SCAN_EXTENSIONS: readonly string[];
/** True for a numeric `REA_` limit, including the documented endpoint-candidate count. */
export function isNumericLimit(name: string): boolean;
+26 -4
View File
@@ -36,6 +36,29 @@ const listFiles = async (dir) => {
/** Registry text is a declaration, not a producer that uses the limit. */
export const NUMERIC_LIMIT_DECLARATION = "src/domain/numericLimitRegistry.ts";
/**
* Where the removed restart-field names cannot return: these roots, files with
* these extensions. The cached `lint` task's inputs in turbo.json must cover
* every such file, or a cached pass would hide a reintroduced name.
*/
export const FORBIDDEN_FIELD_SCAN_ROOTS = [
"src",
"tests",
".agents",
"scripts",
];
export const FORBIDDEN_FIELD_SCAN_EXTENSIONS = [
"ts",
"tsx",
"mjs",
"js",
"md",
"json",
"py",
"yml",
"yaml",
];
/**
* These limits are moving onto runtime analysis settings on their own PRs.
* A producer or a registry entry cannot satisfy that requirement.
@@ -1037,9 +1060,8 @@ if (direct) {
/(?<![A-Za-z0-9_])(?:rea_restart_required|restartRequired|restart_required)(?![A-Za-z0-9_])|effect:\s*"(?:immediate|next_process)"|SettingChangeEffect/gu;
// Docs and verification receipts keep the field names they recorded.
// Source, tests, skills, and scripts cannot reintroduce those names.
const scanRoots = ["src", "tests", ".agents", "scripts"].map((dir) =>
join(root, dir),
);
const scanRoots = FORBIDDEN_FIELD_SCAN_ROOTS.map((dir) => join(root, dir));
const scanned = new Set(FORBIDDEN_FIELD_SCAN_EXTENSIONS);
const skipDir = new Set([
"node_modules",
"dist",
@@ -1054,7 +1076,7 @@ if (direct) {
if (skipDir.has(entry.name)) continue;
const path = join(dir, entry.name);
if (entry.isDirectory()) output.push(...(await listText(path)));
else if (/\.(?:ts|tsx|mjs|js|md|json|py|yml|yaml)$/u.test(entry.name))
else if (scanned.has(entry.name.slice(entry.name.lastIndexOf(".") + 1)))
output.push(path);
}
return output;
@@ -1,5 +1,9 @@
import { readFile } from "node:fs/promises";
import { matchesGlob } from "node:path";
import { expect, it } from "vitest";
import {
FORBIDDEN_FIELD_SCAN_EXTENSIONS,
FORBIDDEN_FIELD_SCAN_ROOTS,
NUMERIC_LIMIT_DECLARATION,
discoverNumericLimitUses,
isNumericLimit,
@@ -592,3 +596,23 @@ it("requires the tracked endpoint-candidate limit to be an analysis setting", ()
}),
).toMatch(/numericLimitRegistry/);
});
it("lets no cached lint pass hide a file the restart-field scan reads", async () => {
// `npm run lint` owns this check and turbo caches it by these inputs; a
// scanned file outside them would let a cached pass skip a new violation.
const turbo = JSON.parse(
await readFile(new URL("../../turbo.json", import.meta.url), "utf8"),
) as { tasks: Record<string, { inputs: readonly string[] }> };
const globs = turbo.tasks.lint!.inputs;
const cached = (path: string) =>
globs.some((glob) => !glob.startsWith("!") && matchesGlob(path, glob)) &&
!globs.some(
(glob) => glob.startsWith("!") && matchesGlob(path, glob.slice(1)),
);
const samples = FORBIDDEN_FIELD_SCAN_ROOTS.flatMap((root) =>
FORBIDDEN_FIELD_SCAN_EXTENSIONS.map(
(extension) => `${root}/nested/file.${extension}`,
),
);
expect(samples.filter((path) => !cached(path))).toEqual([]);
});
+4
View File
@@ -86,6 +86,10 @@
"docs/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
".github/actions/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"website/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"src/**/*.{json,md,py,yaml,yml}",
"tests/**/*.{json,md,py,yaml,yml}",
"scripts/**/*.{json,md,py,yaml,yml}",
".agents/**/*.{js,json,md,mjs,py,ts,tsx,yaml,yml}",
"!docs/.vitepress/cache/**",
"!docs/.vitepress/dist/**",
"!website/_site/**",