Skip to content

Commit 3329290

Browse files
bketelsenclaude
andauthored
ci: add Bash parse-only syntax gate for tracked scripts/*.sh (#115)
Adds scripts/check-scripts.mjs to the root check gate (npm run verify), running `bash -n` over every git-tracked scripts/*.sh file so a malformed script fails CI before merge. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent aa5d11b commit 3329290

3 files changed

Lines changed: 93 additions & 1 deletion

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
},
99
"scripts": {
1010
"check": "npm run verify",
11-
"verify": "npm run check:docs && npm run check:organization && npm test",
11+
"verify": "npm run check:docs && npm run check:organization && npm run check:scripts && npm test",
1212
"check:docs": "node scripts/check-docs.mjs",
1313
"check:organization": "node scripts/check-organization.mjs",
14+
"check:scripts": "node scripts/check-scripts.mjs",
1415
"test": "node --test test/*.test.mjs"
1516
},
1617
"dependencies": {

scripts/check-scripts.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env node
2+
// Shell-syntax gate: every tracked scripts/*.sh file parses as valid Bash
3+
// (ADR-0021 CI). Parse-only (`bash -n`) — it never executes a script.
4+
import { execFileSync } from "node:child_process";
5+
import { dirname, resolve } from "node:path";
6+
7+
const root = resolve(dirname(new URL(import.meta.url).pathname), "..");
8+
9+
const files = execFileSync("git", ["ls-files", "scripts/*.sh"], {
10+
cwd: root,
11+
encoding: "utf8",
12+
})
13+
.split("\n")
14+
.filter(Boolean);
15+
16+
const failures = [];
17+
for (const file of files) {
18+
try {
19+
execFileSync("bash", ["-n", file], { cwd: root, stdio: "pipe" });
20+
} catch (error) {
21+
const message = String(error.stderr || error.message).trim().split("\n")[0];
22+
failures.push(`syntax: ${file}: ${message}`);
23+
}
24+
}
25+
26+
for (const failure of failures) console.error(`FAIL ${failure}`);
27+
const ok = failures.length === 0;
28+
console.log(`${ok ? "ok " : "FAIL"} bash_syntax: ${files.length - failures.length}/${files.length}`);
29+
process.exit(ok ? 0 : 1);

test/check-scripts.test.mjs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import assert from "node:assert/strict";
2+
import { execFile } from "node:child_process";
3+
import { copyFile, mkdir, mkdtemp, readdir, writeFile } from "node:fs/promises";
4+
import os from "node:os";
5+
import path from "node:path";
6+
import test from "node:test";
7+
import { promisify } from "node:util";
8+
import { fileURLToPath } from "node:url";
9+
10+
const execFileAsync = promisify(execFile);
11+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12+
13+
test("bash syntax gate passes on the repository's current tracked scripts", async () => {
14+
const root = await createFixture();
15+
for (const name of await readdir(path.join(repoRoot, "scripts"))) {
16+
if (name.endsWith(".sh")) {
17+
await copyFile(
18+
path.join(repoRoot, "scripts", name),
19+
path.join(root, "scripts", name),
20+
);
21+
}
22+
}
23+
await track(root);
24+
25+
const { stdout } = await runScriptsGate(root);
26+
assert.match(stdout, /ok {3}bash_syntax: 3\/3/);
27+
});
28+
29+
test("bash syntax gate fails a tracked script with invalid Bash syntax", async () => {
30+
const root = await createFixture();
31+
await writeFile(
32+
path.join(root, "scripts/broken.sh"),
33+
"#!/usr/bin/env bash\nif [ -z \"$1\" ]; then\n echo missing\n",
34+
);
35+
await track(root);
36+
37+
await assert.rejects(
38+
runScriptsGate(root),
39+
(error) => error.stderr.includes("FAIL syntax: scripts/broken.sh"),
40+
);
41+
});
42+
43+
async function createFixture() {
44+
const root = await mkdtemp(path.join(os.tmpdir(), "core-scripts-gate-"));
45+
await mkdir(path.join(root, "scripts"), { recursive: true });
46+
await copyFile(
47+
path.join(repoRoot, "scripts/check-scripts.mjs"),
48+
path.join(root, "scripts/check-scripts.mjs"),
49+
);
50+
await execFileAsync("git", ["init", "--quiet"], { cwd: root });
51+
return root;
52+
}
53+
54+
async function track(root) {
55+
await execFileAsync("git", ["add", "-A"], { cwd: root });
56+
}
57+
58+
function runScriptsGate(root) {
59+
return execFileAsync(process.execPath, [
60+
path.join(root, "scripts/check-scripts.mjs"),
61+
], { cwd: root });
62+
}

0 commit comments

Comments
 (0)