From 763ffd0fb8c76ddb70fde12577323777d661f862 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:52:02 -0600 Subject: [PATCH 1/2] fix(ci): harden hygiene gate with local pre-push check - add scripts/check-hygiene.mjs reusing pr-hygiene.cjs (stdlib only, no new deps) so contributors catch missing_regression_test locally instead of via CI red - wire bun run check:hygiene into prepush and package.json, enhance HYGIENE_FAILURE_HINTS with local command hint - update PULL_REQUEST_TEMPLATE checklist and AGENTS.md commands to surface the gate - fixes dominant deterministic CI failure (behaviorChanged without test) seen on #209, #184, #183; SKIPPED shard is intentional via changes.ci gate (already documented in ci.yml) --- .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/scripts/pr-hygiene.cjs | 2 +- AGENTS.md | 1 + package.json | 5 ++-- scripts/check-hygiene.mjs | 42 ++++++++++++++++++++++++++++++++ 5 files changed, 48 insertions(+), 3 deletions(-) create mode 100755 scripts/check-hygiene.mjs diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 512a92c1d0..ef3e1608ee 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,5 +10,6 @@ ## Checklist - [ ] Scope stays focused and avoids unrelated cleanup. +- [ ] Added/updated tests for behavior changes under `src/` or `gui/src/`, or obtained `test-exception-approved` (check: `bun run check:hygiene`). - [ ] Docs or release notes were updated when needed. - [ ] Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. diff --git a/.github/scripts/pr-hygiene.cjs b/.github/scripts/pr-hygiene.cjs index 738e0af535..a3ee75fd1b 100644 --- a/.github/scripts/pr-hygiene.cjs +++ b/.github/scripts/pr-hygiene.cjs @@ -226,7 +226,7 @@ function assessHygiene({ files = [], labels = [] }) { */ const HYGIENE_FAILURE_HINTS = { missing_regression_test: - "Behavior changed under `src/` or `gui/src/` without a test change. Add focused coverage or obtain `test-exception-approved`.", + "Behavior changed under `src/` or `gui/src/` without a test change. Add focused coverage or obtain `test-exception-approved`. Local: bun run check:hygiene.", generated_output: "Generated build output is committed. Remove it or obtain `generated-change-approved`.", orphan_lockfile: diff --git a/AGENTS.md b/AGENTS.md index 90ff7fb67b..0c025b1a6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,6 +176,7 @@ Required merge checks are `ci`, `hygiene`, `enforce-target`, and `mergeable` fro bun install bun run typecheck # bun x tsc --noEmit (strict) bun run test # full tests/ suite +bun run check:hygiene # local hygiene gate (same as CI hygiene/enforce-target) bun run test:container # macOS with Apple Container: isolated container suite bun scripts/test.ts --shard=1/4 # supported isolated manual shard bun run lint:gui # GUI eslint diff --git a/package.json b/package.json index 3c69b0b51a..031e69292a 100644 --- a/package.json +++ b/package.json @@ -53,14 +53,15 @@ "prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui", "release": "bun scripts/release.ts", "release:watch": "bun scripts/release.ts watch", - "prepush": "bun run typecheck && bun run lint:gui:if-changed && bun run test && bun run privacy:scan && bun run doctor:gui:if-changed", + "prepush": "bun run check:hygiene && bun run typecheck && bun run lint:gui:if-changed && bun run test && bun run privacy:scan && bun run doctor:gui:if-changed", "lint:gui": "cd gui && bun run lint", "lint:gui:if-changed": "bun scripts/lint-gui-if-changed.ts", "postmerge": "bun scripts/build-gui-if-changed.ts", "doctor:gui": "cd gui && bun run doctor", "doctor:gui:full": "cd gui && bun run doctor:full", "doctor:gui:if-changed": "bun scripts/doctor-gui-if-changed.ts", - "setup:hooks": "bun scripts/setup-hooks.ts" + "setup:hooks": "bun scripts/setup-hooks.ts", + "check:hygiene": "node scripts/check-hygiene.mjs" }, "dependencies": { "@bufbuild/protobuf": "^2.14.0", diff --git a/scripts/check-hygiene.mjs b/scripts/check-hygiene.mjs new file mode 100755 index 0000000000..31ce37fe6f --- /dev/null +++ b/scripts/check-hygiene.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const { collectDeterministicHygieneFailures, HYGIENE_FAILURE_HINTS } = require("../.github/scripts/pr-hygiene.cjs"); +function sh(cmd){ return execSync(cmd, {encoding:"utf8"}).trim(); } +let base = "origin/dev"; +try{ sh("git rev-parse --verify "+base); }catch{ base = "dev"; } +let statusOut=""; +try{ statusOut = sh("git diff --name-status --diff-filter=ACMRT "+base+"...HEAD"); }catch{ /* fallback */ } +if(!statusOut) try{ statusOut = sh("git diff --name-status --diff-filter=ACMRT "+base); }catch{ /* fallback */ } +const files=[]; +for(const line of (statusOut||"").split("\n").filter(Boolean)){ + const parts=line.split("\t"); + const stat=parts[0]; + let filename, prev; + if(stat.startsWith("R")){ prev=parts[1]; filename=parts[2]; } + else{ filename=parts[1] ?? parts[0].slice(1).trim(); } + let patch=""; + try{ patch=execSync("git diff -U0 "+base+"...HEAD -- "+JSON.stringify(filename), {encoding:"utf8", maxBuffer:10*1024*1024}).toString(); if(!patch) patch=execSync("git diff -U0 "+base+" -- "+JSON.stringify(filename), {encoding:"utf8", maxBuffer:10*1024*1024}).toString(); }catch{ /* fallback */ } + const e={filename, patch}; + if(prev) e.previous_filename=prev; + files.push(e); +} +// include untracked files (new test not yet staged) so local check is not blind +try{ + const untracked = sh("git ls-files --others --exclude-standard"); + for(const f of untracked.split("\n").filter(Boolean)){ + if(files.some(x=>x.filename===f)) continue; + // synthesize file entry; patch empty but filename drives behavior/test path checks + // for new test files, empty patch still counts as testsChanged (isTestPath on filename) + // for behavior files untracked, patch empty would be comment-only false, but we treat as behavior change + files.push({filename:f, patch:"@@ -0,0 +1 @@\n+new file"}); + } +}catch{ /* fallback */ } +if(files.length===0){ console.log("check:hygiene \u2014 no changes vs "+base); process.exit(0); } +const failures=collectDeterministicHygieneFailures({files, labels:[], authorHasPushPermission:true}); +if(failures.length===0){ console.log("check:hygiene \u2014 ok"); process.exit(0); } +console.error("check:hygiene \u2014 failed:"); +for(const f of failures){ const h=HYGIENE_FAILURE_HINTS[f.code]??f.code; console.error("- "+f.code+": "+h+(f.paths?" ("+f.paths.join(", ")+")":"")); } +console.error("\nFix: add test under tests/ or label test-exception-approved. Local: bun run check:hygiene"); +process.exit(1); From 3f81d48a5af71b7fe6461af9ccd28fb7a18a8f9b Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:53:31 -0600 Subject: [PATCH 2/2] chore: re-trigger enforce-target after fixing body newlines