diff --git a/AGENTS.md b/AGENTS.md index 7aad9fef56..7dbaf5581a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,35 @@ Review gate: any UI predicate introduced to hide malformed data must cite an exp ## Default Delivery Flow +### Verification budget (required) + +Keep feedback proportional to the current edit. Agents MUST use the repository's +two verification entry points instead of repeatedly scanning the whole frontend: + +- During implementation, run `pnpm verify:quick -- `. It + lints only changed `src/` files (the repository's configured ESLint scope) + and runs only the explicitly supplied `src/**/*.test.ts` files supported by + the Vitest configuration. Include the relevant focused test paths even when + those tests were not edited. If changed `src/` code has no supplied test, the + command refuses to perform an implicit dependency-graph scan; `--no-tests` + requires a documented reason. Tooling, packages, and documentation do not + trigger a Vitest project scan. In a clean, task-isolated worktree the file + list may be omitted; if more than 80 dirty files are detected, automatic + scope discovery refuses to run so unrelated user work is not swept + accidentally. +- At final handoff, run `pnpm verify:final` once for the full TypeScript check. + `pnpm typecheck` uses the same guarded entry point. A successful result is + cached by the exact Git HEAD, changed TypeScript-relevant file contents, + runtime, platform, and TypeScript version; an unchanged state is skipped. +- A failed final check may be rerun only after a relevant code change. Do not + use `pnpm typecheck:raw` or `--force` unless the user explicitly requests a + fresh rerun or a release/debugging workflow requires it. +- CI always bypasses the local success cache and performs the real full check. + The cache contains one small local record under ignored `.orgii/` state; it + is never committed or uploaded. +- Delivery evidence MUST name the exact quick/final commands that ran and state + whether the final check executed or reused a matching successful result. + ### Touching `*.tsx` files (UI work) Before declaring a UI-touching task complete, ask: diff --git a/package.json b/package.json index aa8cd50987..f41678ccae 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,10 @@ "clean:cargo": "./scripts/maintenance/cargo-cleanup.sh", "clean:cargo:full": "./scripts/maintenance/cargo-cleanup.sh --full", "analyze": "webpack --mode production --profile --json > build/stats.json && webpack-bundle-analyzer build/stats.json", - "typecheck": "tsc --noEmit --pretty false", + "typecheck": "node scripts/quality/verify-final.mjs", + "typecheck:raw": "tsc --noEmit --pretty false", + "verify:quick": "node scripts/quality/verify-changed.mjs", + "verify:final": "node scripts/quality/verify-final.mjs", "lint": "eslint src/ --ext .ts,.tsx,.js,.jsx", "lint:fix": "eslint src/ --ext .ts,.tsx,.js,.jsx --fix", "lint:file": "eslint", diff --git a/scripts/quality/verification-policy.mjs b/scripts/quality/verification-policy.mjs new file mode 100644 index 0000000000..da0bffe046 --- /dev/null +++ b/scripts/quality/verification-policy.mjs @@ -0,0 +1,291 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import process from "node:process"; + +export const repoRoot = resolve(import.meta.dirname, "..", ".."); +export const verificationCacheDir = join( + repoRoot, + ".orgii", + "verification-cache" +); +export const typecheckCachePath = join(verificationCacheDir, "typecheck.json"); +export const typecheckLockPath = join(verificationCacheDir, "typecheck.lock"); + +export const MAX_AUTODETECTED_FILES = 80; + +const LINTABLE_EXTENSION = /\.(?:[cm]?[jt]sx?)$/i; +const TEST_RELATED_EXTENSION = /\.(?:[cm]?[jt]sx?)$/i; +const VITEST_TEST_FILE = /(?:^|\/).+\.test\.ts$/i; +const TYPECHECK_RELEVANT_EXTENSION = /\.(?:[cm]?[jt]sx?|json|ya?ml)$/i; + +function runGit(args, cwd = repoRoot) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function fromNullSeparated(output) { + return output.split("\0").filter(Boolean); +} + +function toRepoRelativePath(path, cwd = repoRoot) { + const absolutePath = isAbsolute(path) ? resolve(path) : resolve(cwd, path); + const relativePath = relative(cwd, absolutePath); + + if ( + relativePath === "" || + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + throw new Error(`Verification path is outside the repository: ${path}`); + } + + return relativePath.split(sep).join("/"); +} + +export function normalizeRequestedFiles(paths, cwd = repoRoot) { + return [ + ...new Set(paths.map((path) => toRepoRelativePath(path, cwd))), + ].sort(); +} + +export function exceedsAutodetectedFileLimit(paths) { + return paths.length > MAX_AUTODETECTED_FILES; +} + +export function listWorkspaceChanges(cwd = repoRoot) { + const tracked = fromNullSeparated( + runGit( + ["diff", "--name-only", "-z", "--diff-filter=ACDMRTUXB", "HEAD", "--"], + cwd + ) + ); + const untracked = fromNullSeparated( + runGit(["ls-files", "--others", "--exclude-standard", "-z"], cwd) + ); + + return normalizeRequestedFiles([...tracked, ...untracked], cwd); +} + +export function existingFiles(paths, cwd = repoRoot) { + return paths.filter((path) => { + const absolutePath = join(cwd, path); + return existsSync(absolutePath) && statSync(absolutePath).isFile(); + }); +} + +export function lintableFiles(paths) { + return paths.filter( + (path) => LINTABLE_EXTENSION.test(path) && path.startsWith("src/") + ); +} + +export function testRelatedFiles(paths) { + return paths.filter( + (path) => TEST_RELATED_EXTENSION.test(path) && path.startsWith("src/") + ); +} + +export function vitestTestFiles(paths) { + return paths.filter( + (path) => path.startsWith("src/") && VITEST_TEST_FILE.test(path) + ); +} + +export function isTypecheckRelevant(path) { + if ( + path === "package.json" || + path === "pnpm-lock.yaml" || + path.startsWith("tsconfig") + ) { + return true; + } + + return ( + TYPECHECK_RELEVANT_EXTENSION.test(path) && + (path.startsWith("src/") || path.startsWith("packages/")) + ); +} + +export function buildVerificationFingerprint({ + head, + files, + nodeVersion, + platform, + typescriptVersion, +}) { + const hash = createHash("sha256"); + hash.update("orgii-typecheck-v1\0"); + hash.update(`${head}\0${nodeVersion}\0${platform}\0${typescriptVersion}\0`); + + for (const file of [...files].sort((left, right) => + left.path.localeCompare(right.path) + )) { + hash.update(`${file.path}\0`); + hash.update(file.content === null ? "" : file.content); + hash.update("\0"); + } + + return hash.digest("hex"); +} + +function readTypescriptVersion(cwd = repoRoot) { + try { + const packageJson = JSON.parse( + readFileSync( + join(cwd, "node_modules", "typescript", "package.json"), + "utf8" + ) + ); + return String(packageJson.version ?? "unknown"); + } catch { + return "unknown"; + } +} + +export function createTypecheckFingerprint(cwd = repoRoot) { + const head = runGit(["rev-parse", "HEAD"], cwd).trim(); + const files = listWorkspaceChanges(cwd) + .filter(isTypecheckRelevant) + .map((path) => { + const absolutePath = join(cwd, path); + return { + path, + content: + existsSync(absolutePath) && statSync(absolutePath).isFile() + ? readFileSync(absolutePath) + : null, + }; + }); + + return buildVerificationFingerprint({ + head, + files, + nodeVersion: process.version, + platform: `${process.platform}-${process.arch}`, + typescriptVersion: readTypescriptVersion(cwd), + }); +} + +export function readSuccessfulTypecheck(path = typecheckCachePath) { + try { + const record = JSON.parse(readFileSync(path, "utf8")); + if ( + record?.schemaVersion !== 1 || + typeof record.fingerprint !== "string" || + typeof record.completedAt !== "string" + ) { + return null; + } + return record; + } catch { + return null; + } +} + +export function isSuccessfulTypecheckCached( + fingerprint, + path = typecheckCachePath +) { + return readSuccessfulTypecheck(path)?.fingerprint === fingerprint; +} + +export function writeSuccessfulTypecheck( + fingerprint, + path = typecheckCachePath +) { + mkdirSync(dirname(path), { recursive: true }); + const temporaryPath = `${path}.${process.pid}.tmp`; + writeFileSync( + temporaryPath, + `${JSON.stringify( + { + schemaVersion: 1, + fingerprint, + completedAt: new Date().toISOString(), + }, + null, + 2 + )}\n`, + "utf8" + ); + renameSync(temporaryPath, path); +} + +function isProcessAlive(pid) { + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +export function acquireTypecheckLock(path = typecheckLockPath) { + mkdirSync(dirname(path), { recursive: true }); + + function tryCreateLock() { + let descriptor; + try { + descriptor = openSync(path, "wx"); + writeFileSync( + descriptor, + JSON.stringify({ + pid: process.pid, + startedAt: new Date().toISOString(), + }) + ); + closeSync(descriptor); + descriptor = undefined; + return () => { + try { + unlinkSync(path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + }; + } catch (error) { + if (descriptor !== undefined) closeSync(descriptor); + if (error?.code === "EEXIST") return undefined; + try { + unlinkSync(path); + } catch (cleanupError) { + if (cleanupError?.code !== "ENOENT") throw cleanupError; + } + throw error; + } + } + + const initialLock = tryCreateLock(); + if (initialLock) return initialLock; + + try { + const lock = JSON.parse(readFileSync(path, "utf8")); + if (isProcessAlive(lock?.pid)) return null; + } catch { + // An unreadable lock cannot prove that a verification process is active. + } + + try { + unlinkSync(path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + return tryCreateLock() ?? null; +} diff --git a/scripts/quality/verification-policy.test.mjs b/scripts/quality/verification-policy.test.mjs new file mode 100644 index 0000000000..7642ec200e --- /dev/null +++ b/scripts/quality/verification-policy.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + acquireTypecheckLock, + buildVerificationFingerprint, + exceedsAutodetectedFileLimit, + isSuccessfulTypecheckCached, + isTypecheckRelevant, + lintableFiles, + testRelatedFiles, + vitestTestFiles, + writeSuccessfulTypecheck, +} from "./verification-policy.mjs"; + +function fingerprint(overrides = {}) { + return buildVerificationFingerprint({ + head: "head-1", + files: [{ path: "src/example.ts", content: "export const value = 1;" }], + nodeVersion: "v22", + platform: "test-platform", + typescriptVersion: "5.7.3", + ...overrides, + }); +} + +test("fingerprint changes with source, compiler, and deletion state", () => { + const baseline = fingerprint(); + + assert.notEqual( + baseline, + fingerprint({ + files: [{ path: "src/example.ts", content: "export const value = 2;" }], + }) + ); + assert.notEqual(baseline, fingerprint({ typescriptVersion: "5.8.0" })); + assert.notEqual( + baseline, + fingerprint({ files: [{ path: "src/example.ts", content: null }] }) + ); +}); + +test("test and typecheck scopes exclude unrelated repository tooling", () => { + assert.equal(exceedsAutodetectedFileLimit(Array.from({ length: 80 })), false); + assert.equal(exceedsAutodetectedFileLimit(Array.from({ length: 81 })), true); + assert.deepEqual( + lintableFiles([ + "src/example.ts", + "packages/ui/example.tsx", + "scripts/quality/example.mjs", + ]), + ["src/example.ts"] + ); + assert.deepEqual( + vitestTestFiles([ + "src/example.ts", + "src/example.test.ts", + "src/example.spec.tsx", + "scripts/example.test.mjs", + ]), + ["src/example.test.ts"] + ); + assert.deepEqual( + testRelatedFiles([ + "src/example.ts", + "packages/ui/example.tsx", + "scripts/quality/example.mjs", + "tests/e2e/example.spec.mjs", + ]), + ["src/example.ts"] + ); + assert.equal(isTypecheckRelevant("src/example.ts"), true); + assert.equal(isTypecheckRelevant("packages/ui/example.tsx"), true); + assert.equal(isTypecheckRelevant("package.json"), true); + assert.equal(isTypecheckRelevant("scripts/quality/example.mjs"), false); + assert.equal( + isTypecheckRelevant("tmp/copied-worktree/src/example.ts"), + false + ); +}); + +test("successful cache contains only the latest fingerprint", () => { + const directory = mkdtempSync(join(tmpdir(), "orgii-verification-")); + const cachePath = join(directory, "typecheck.json"); + + try { + const first = fingerprint(); + const second = fingerprint({ head: "head-2" }); + + writeSuccessfulTypecheck(first, cachePath); + assert.equal(isSuccessfulTypecheckCached(first, cachePath), true); + assert.equal(isSuccessfulTypecheckCached(second, cachePath), false); + + writeSuccessfulTypecheck(second, cachePath); + assert.equal(isSuccessfulTypecheckCached(first, cachePath), false); + assert.equal(isSuccessfulTypecheckCached(second, cachePath), true); + assert.equal(JSON.parse(readFileSync(cachePath, "utf8")).schemaVersion, 1); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("typecheck lock blocks a duplicate process and recovers a stale lock", () => { + const directory = mkdtempSync(join(tmpdir(), "orgii-verification-lock-")); + const lockPath = join(directory, "typecheck.lock"); + + try { + const release = acquireTypecheckLock(lockPath); + assert.equal(typeof release, "function"); + assert.equal(acquireTypecheckLock(lockPath), null); + release(); + + writeFileSync(lockPath, JSON.stringify({ pid: 999_999_999 }), "utf8"); + const releaseRecovered = acquireTypecheckLock(lockPath); + assert.equal(typeof releaseRecovered, "function"); + releaseRecovered(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/scripts/quality/verify-changed.mjs b/scripts/quality/verify-changed.mjs new file mode 100644 index 0000000000..975ba80b35 --- /dev/null +++ b/scripts/quality/verify-changed.mjs @@ -0,0 +1,76 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +import { + exceedsAutodetectedFileLimit, + existingFiles, + lintableFiles, + listWorkspaceChanges, + normalizeRequestedFiles, + repoRoot, + testRelatedFiles, + vitestTestFiles, +} from "./verification-policy.mjs"; + +const rawArguments = process.argv.slice(2).filter((path) => path !== "--"); +const skipTests = rawArguments.includes("--no-tests"); +const requestedFiles = rawArguments.filter((path) => path !== "--no-tests"); +const autodetected = requestedFiles.length === 0; +const scopedFiles = autodetected + ? listWorkspaceChanges() + : normalizeRequestedFiles(requestedFiles); + +if (autodetected && exceedsAutodetectedFileLimit(scopedFiles)) { + process.stderr.write( + `Refusing to scan ${scopedFiles.length} workspace changes automatically. ` + + `Pass this task's exact files: pnpm verify:quick -- \n` + ); + process.exit(2); +} + +const files = existingFiles(scopedFiles); +const lintFiles = lintableFiles(files); +const frontendFiles = testRelatedFiles(files); +const testFiles = vitestTestFiles(frontendFiles); +const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + +function run(args) { + const result = spawnSync(pnpm, args, { + cwd: repoRoot, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +process.stdout.write( + `Quick verification scope: ${files.length} existing file(s)` + + `${autodetected ? " (auto-detected)" : " (explicit task scope)"}\n` +); + +if (lintFiles.length > 0) { + process.stdout.write(`Linting ${lintFiles.length} changed file(s)...\n`); + run(["exec", "eslint", ...lintFiles]); +} else { + process.stdout.write( + "Lint skipped: no changed files in the configured src/ scope.\n" + ); +} + +if (testFiles.length > 0) { + process.stdout.write( + `Running ${testFiles.length} explicitly scoped Vitest file(s)...\n` + ); + run(["exec", "vitest", "run", ...testFiles]); +} else if (frontendFiles.length > 0 && !skipTests) { + process.stderr.write( + "No focused Vitest file was supplied for changed src/ code. Add the relevant *.test.ts file, or pass --no-tests with a documented reason.\n" + ); + process.exit(2); +} else { + process.stdout.write( + skipTests + ? "Focused tests explicitly skipped with --no-tests.\n" + : "Focused tests skipped: no changed files in the configured src/ scope.\n" + ); +} diff --git a/scripts/quality/verify-final.mjs b/scripts/quality/verify-final.mjs new file mode 100644 index 0000000000..3109d6e6d5 --- /dev/null +++ b/scripts/quality/verify-final.mjs @@ -0,0 +1,75 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +import { + acquireTypecheckLock, + createTypecheckFingerprint, + isSuccessfulTypecheckCached, + repoRoot, + writeSuccessfulTypecheck, +} from "./verification-policy.mjs"; + +function runFinalVerification() { + const force = process.argv.includes("--force"); + const isCi = Boolean(process.env.CI && process.env.CI !== "false"); + const useLocalCache = !isCi && !force; + const fingerprintBefore = useLocalCache ? createTypecheckFingerprint() : null; + + if (fingerprintBefore && isSuccessfulTypecheckCached(fingerprintBefore)) { + process.stdout.write( + "Full TypeScript check skipped: this exact code state already passed.\n" + ); + return 0; + } + + const releaseLock = useLocalCache ? acquireTypecheckLock() : () => {}; + if (useLocalCache && !releaseLock) { + process.stderr.write( + "A full TypeScript check is already running in this workspace; duplicate run blocked.\n" + ); + return 2; + } + + try { + if (fingerprintBefore && isSuccessfulTypecheckCached(fingerprintBefore)) { + process.stdout.write( + "Full TypeScript check skipped: another process already verified this code state.\n" + ); + return 0; + } + + process.stdout.write( + isCi + ? "Running full TypeScript check (CI cache bypassed)...\n" + : force + ? "Running full TypeScript check (--force)...\n" + : "Running final full TypeScript check...\n" + ); + + const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + const result = spawnSync( + pnpm, + ["exec", "tsc", "--noEmit", "--pretty", "false"], + { cwd: repoRoot, stdio: "inherit" } + ); + if (result.error) throw result.error; + if (result.status !== 0) return result.status ?? 1; + + if (fingerprintBefore) { + const fingerprintAfter = createTypecheckFingerprint(); + if (fingerprintAfter !== fingerprintBefore) { + process.stderr.write( + "Code changed while TypeScript was running; result was not cached. Run the final check again after edits stop.\n" + ); + return 2; + } + writeSuccessfulTypecheck(fingerprintAfter); + } + + return 0; + } finally { + releaseLock?.(); + } +} + +process.exitCode = runFinalVerification();