From 2147aeffc3b35cbe16d962e2d72aa832a6e37409 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 07:58:43 +0000 Subject: [PATCH] fix: win32 NUL discard for the Go build check; unique Health Stack names (rule 23 grant) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/checks.ts | 10 +++++---- test/gstack-checks-go.test.ts | 39 +++++++++++++++++++++++++++++++++++ test/gstack-checks.test.ts | 3 ++- 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 test/gstack-checks-go.test.ts diff --git a/lib/checks.ts b/lib/checks.ts index 24f567e15..93b42b314 100644 --- a/lib/checks.ts +++ b/lib/checks.ts @@ -15,7 +15,6 @@ */ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; -import * as os from 'node:os'; import * as path from 'node:path'; import { slugFromEnvironment } from './bin-context'; import { resolveStateRoot } from './state-root'; @@ -61,14 +60,17 @@ export function healthStack(claudeMd: string): Check[] { if (start < 0) return []; const checks: Check[] = []; const seen = new Map(); + const taken = new Set(); for (const line of lines.slice(start + 1)) { if (/^#{1,6}\s/.test(line)) break; const m = /^\s*[-*]\s*([A-Za-z0-9_-]+):\s*(.+?)\s*$/.exec(line); if (!m || ['test', TESTS].includes(m[1].toLowerCase())) continue; const key = m[1].toLowerCase(); - const n = (seen.get(key) ?? 0) + 1; + let n = seen.get(key) ?? 0; + let name = key; + while (taken.has(name)) name = `${key}-${++n + 1}`; seen.set(key, n); - const name = n === 1 ? key : `${key}-${n}`; + taken.add(name); checks.push({ name, command: m[2].replace(/`/g, ''), source: 'CLAUDE.md Health Stack' }); } return checks; @@ -106,7 +108,7 @@ export function manifestChecks(root: string): Check[] { const cargo = read(path.join(root, 'Cargo.toml')); if (cargo !== null) checks.push({ name: 'build', command: 'cargo check --all-targets', source: 'Cargo.toml' }); if (cargo !== null && (/^\[(workspace\.)?lints\.clippy\]/m.test(cargo) || ['clippy.toml', '.clippy.toml'].some(f => fs.existsSync(path.join(root, f))))) checks.push({ name: 'lint', command: 'cargo clippy --all-targets', source: 'Cargo.toml' }); - if (fs.existsSync(path.join(root, 'go.mod'))) checks.push({ name: 'vet', command: 'go vet ./...', source: 'go.mod' }, { name: 'build', command: `go build -o ${os.devNull} ./...`, source: 'go.mod' }); + if (fs.existsSync(path.join(root, 'go.mod'))) checks.push({ name: 'vet', command: 'go vet ./...', source: 'go.mod' }, { name: 'build', command: `go build -o ${process.platform === 'win32' ? 'NUL' : '/dev/null'} ./...`, source: 'go.mod' }); const seen = new Set(); return checks.filter(c => (seen.has(c.name) ? false : (seen.add(c.name), true))); } diff --git a/test/gstack-checks-go.test.ts b/test/gstack-checks-go.test.ts new file mode 100644 index 000000000..b1c22a479 --- /dev/null +++ b/test/gstack-checks-go.test.ts @@ -0,0 +1,39 @@ +/** + * The Go build check gstack-checks detects runs through the same bash invocation pregate's supervise uses (Git Bash on + * Windows), so its discard path must survive bash: os.devNull on Windows is \\.\nul, which bash mangles to \.nul. + * Kept out of test/gstack-checks.test.ts so a lane with Go can run it on its own. Skips where Go is not installed. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { manifestChecks } from '../lib/checks'; +import { bashCommandInvocation } from '../lib/script-invocation'; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-checks-go-')); +afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); +const write = (rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(tmp, rel)), { recursive: true }); fs.writeFileSync(path.join(tmp, rel), text); }; +const files = (dir: string): string[] => fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => e.isDirectory() ? files(path.join(dir, e.name)).map(f => `${e.name}/${f}`) : [e.name]).sort(); + +describe('the detected Go build check', () => { + // Value: protects=the build check passes and writes nothing on a one-main module and a three-main module; + // fails_when=the discard path is mangled (a false FAIL on several mains) or dropped (a binary lands in the tree); + // why_new=the -o discard is new; seam=the production bash invocation; no_claim=Go toolchains without go on PATH + test.skipIf(!Bun.which('go'))('builds every package through bash and leaves the tree unchanged', () => { + // One root main package (go build would write ./x) and three main packages (a mangled -o path fails with several). + for (const [mod, mains] of [['one', ['main.go']], ['many', ['main.go', 'cmd/a/main.go', 'cmd/b/main.go']]] as const) { + const dir = path.join(tmp, mod); + write(`${mod}/go.mod`, `module example.com/${mod}\n\ngo 1.21\n`); + for (const rel of mains) write(`${mod}/${rel}`, 'package main\n\nfunc main() {}\n'); + const before = files(dir); + const build = manifestChecks(dir).find(c => c.name === 'build')!; + const inv = bashCommandInvocation(build.command); + if (!inv.ok) throw new Error(inv.error); + const r = spawnSync(inv.cmd, inv.argv, { cwd: dir, encoding: 'utf8', timeout: 120_000, env: { ...process.env, GOWORK: 'off' } }); + expect(`${r.stdout}${r.stderr}`).toBe(''); + expect(r.status).toBe(0); + expect(files(dir)).toEqual(before); + } + }, 150_000); +}); diff --git a/test/gstack-checks.test.ts b/test/gstack-checks.test.ts index 85ee083dc..b42ba7cbb 100644 --- a/test/gstack-checks.test.ts +++ b/test/gstack-checks.test.ts @@ -60,6 +60,7 @@ describe('detection', () => { .toEqual([['typecheck', 'tsc --noEmit'], ['shell', 'shellcheck *.sh']]); // A repeated key keeps both checks under their own names, and a tests row is pregate's, so status never reads one verdict for two checks. expect(healthStack('## Health Stack\n- lint: eslint .\n- lint: stylelint "**/*.css"\n- lint: oxlint\n- tests: npm test\n').map(c => c.name)).toEqual(['lint', 'lint-2', 'lint-3']); + expect(healthStack('## Health Stack\n- lint: eslint .\n- lint-2: oxlint\n- lint: stylelint x\n').map(c => c.name)).toEqual(['lint', 'lint-2', 'lint-3']); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-checks-manifest-')); dirs.push(dir); fs.writeFileSync(path.join(dir, 'tsconfig.json'), '{}'); @@ -68,7 +69,7 @@ describe('detection', () => { fs.writeFileSync(path.join(dir, 'bun.lock'), ''); fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ scripts: { lint: 'eslint .' }, devDependencies: { typescript: '5' } })); fs.writeFileSync(path.join(dir, 'go.mod'), 'module x\n'); - expect(manifestChecks(dir).map(c => c.command)).toEqual(['bunx tsc --noEmit', 'bun run lint', 'go vet ./...', `go build -o ${os.devNull} ./...`]); + expect(manifestChecks(dir).map(c => c.command)).toEqual(['bunx tsc --noEmit', 'bun run lint', 'go vet ./...', `go build -o ${process.platform === 'win32' ? 'NUL' : '/dev/null'} ./...`]); // Clippy is a rustup component Cargo.toml does not declare: it runs only when the repo opts in. const rs = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-checks-rs-')); dirs.push(rs);