Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion .github/scripts/pr-hygiene.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions scripts/check-hygiene.mjs
Original file line number Diff line number Diff line change
@@ -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);
Loading