Gate nested projects in bash-guard instead of passing them ungated
The build-and-test gate searched for marker files only two levels below the repo root. This repo keeps its python projects at plugins/<name>/skills/<skill>/, four levels down, so every push reported "no recognized project layout" and pushed without running a single test. A gate that announces it did nothing is worse than no gate, because the announcement scrolls past and the push still succeeds. Walk to depth 4, and resolve python tooling properly. A bare `ruff` or `pytest` is usually absent from PATH, and run() treats ENOENT as a pass, so an unresolved tool would have skipped the check just as silently. Resolution now tries the project's own .venv, then uv (which needs a [project] table that audit-terraform does not have), then the interpreter's -m form. A tool that is genuinely not installed is reported as an advisory note rather than blocking. Its absence is a gap in coverage, not a defect in the change being pushed. Silence is the one outcome that is never acceptable. Verified by injecting a failing test into each skill and confirming the guard blocks: audit-code resolves through its .venv, audit-terraform through python -m pytest. The new regression tests fail against the old depth-2 walk and pass against this one. Tests: 8 passing (bash-guard), 197 (audit-code), 106 (audit-terraform).
This commit is contained in:
@@ -175,8 +175,9 @@ const SKIP = new Set([
|
||||
'.venv', 'venv', '__pycache__', 'vendor', '.next', 'out', 'packages',
|
||||
]);
|
||||
|
||||
// Marker files are searched to depth 2 so monorepo layouts (back-end/, front-end/,
|
||||
// services/*) are found without hardcoding any particular directory naming.
|
||||
// Marker files are searched to depth 4 so monorepo layouts (back-end/, front-end/,
|
||||
// services/*) and plugin trees (plugins/<name>/skills/<skill>/) are found without
|
||||
// hardcoding any particular directory naming. SKIP keeps the walk cheap.
|
||||
function findProjects(root, depth = 0) {
|
||||
let found = [];
|
||||
let entries;
|
||||
@@ -193,7 +194,7 @@ function findProjects(root, depth = 0) {
|
||||
if (names.includes('CMakeLists.txt')) found.push({ kind: 'cmake', dir: root });
|
||||
else if (names.includes('Makefile') || names.includes('makefile')) found.push({ kind: 'make', dir: root });
|
||||
|
||||
if (depth < 2) {
|
||||
if (depth < 4) {
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory() && !SKIP.has(e.name) && !e.name.startsWith('.')) {
|
||||
found = found.concat(findProjects(join(root, e.name), depth + 1));
|
||||
@@ -208,6 +209,34 @@ function npmScripts(dir) {
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
// A bare `ruff`/`pytest` is usually absent from PATH, and run() treats ENOENT as a pass —
|
||||
// so an unresolved tool would silently skip the gate. Resolve against the project's own
|
||||
// venv first, then uv (which needs a [project] table), then the interpreter's -m fallback.
|
||||
// An absent tool is reported rather than blocking: it is a gap in coverage, not a defect
|
||||
// in the change being pushed. Silence is the one outcome that is never acceptable.
|
||||
const skipped = [];
|
||||
|
||||
function pyTool(dir, tool, args) {
|
||||
const venv = join(dir, '.venv', 'bin', tool);
|
||||
if (existsSync(venv)) return [venv, args];
|
||||
if (existsSync(join(dir, 'uv.lock')) && pyprojectHasProject(dir)) return ['uv', ['run', tool, ...args]];
|
||||
if (pyModuleAvailable(tool)) return ['python', ['-m', tool, ...args]];
|
||||
skipped.push({ dir, tool });
|
||||
return null;
|
||||
}
|
||||
|
||||
function pyprojectHasProject(dir) {
|
||||
try { return /^\[project\]/m.test(readFileSync(join(dir, 'pyproject.toml'), 'utf8')); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
function pyModuleAvailable(tool) {
|
||||
try {
|
||||
execFileSync('python', ['-c', `import ${tool}`], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// `-warnaserror` / `-D warnings` are deliberate: CLAUDE.md treats warnings as errors.
|
||||
function checksFor(p) {
|
||||
switch (p.kind) {
|
||||
@@ -226,7 +255,7 @@ function checksFor(p) {
|
||||
case 'rust':
|
||||
return [['cargo', ['clippy', '--all-targets', '--', '-D', 'warnings']], ['cargo', ['test']]];
|
||||
case 'python':
|
||||
return [['ruff', ['check', '.']], ['pytest', ['-q']]];
|
||||
return [pyTool(p.dir, 'ruff', ['check', '.']), pyTool(p.dir, 'pytest', ['-q'])].filter(Boolean);
|
||||
case 'cmake':
|
||||
// cmake needs a configured build dir; without one there is nothing safe to drive.
|
||||
return existsSync(join(p.dir, 'build', 'CMakeCache.txt'))
|
||||
@@ -273,6 +302,11 @@ for (const p of projects) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of skipped) {
|
||||
const rel = s.dir === ROOT ? '.' : s.dir.slice(ROOT.length + 1);
|
||||
notes.push(`${s.tool} is not installed for ${rel}, so that check did not run.`);
|
||||
}
|
||||
|
||||
// Secrets: assume every repo is public.
|
||||
const leaks = run('gitleaks', ['dir', ROOT, '--no-banner', '--redact', '-v'], ROOT);
|
||||
if (leaks !== null) {
|
||||
|
||||
Reference in New Issue
Block a user