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
45 changes: 13 additions & 32 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,19 @@
# gates run via `node gate-engine/...` rather than the published `bunx guard-*`. Do not run
# `devkit init` in this repo — this hand-authored hook IS the canonical chain.
#
# Shape mirrors the generated consumer block: deterministic gates run skip-aware (the prefix
# cache clears an identical staged tree on a devkit ship retry) and ACCUMULATE their failures
# into one aggregated report; the AI gate (decisions) stays last and fail-fast.

# Deterministic-prefix cache (ship runs only — no-ops without DEVKIT_SHIP=1). Sets a flag,
# never exits: the decisions gate below always still runs.
DK_PREFIX_SKIP=""
DK_DET_FAILS=""
if node gate-engine/prefix-cache/cli.mjs check --hook "${DK_HOOK_PATH:-$0}"; then DK_PREFIX_SKIP=1; fi

if [ -z "${DK_PREFIX_SKIP:-}" ]; then
# Formatter + linter (Biome) — hard gate.
bun run lint || DK_DET_FAILS="${DK_DET_FAILS:-} lint"

# Structure governance (folder-structure rule compiled from guard.config.json `structure`) — hard
# gate. devkit governs its own cli/ + gate-engine/ layout; an unregistered domain or misplaced
# file blocks the commit. See docs/structure-governance.md.
bun run lint:structure || DK_DET_FAILS="${DK_DET_FAILS:-} lint:structure"

# Ratchets (baselines in eslint/baselines/, grandfathered via `bun run guard:freeze`) — hard gate.
node gate-engine/ratchets/folder-fanout.mjs gate || DK_DET_FAILS="${DK_DET_FAILS:-} guard-fanout"
node gate-engine/ratchets/size-disable.mjs gate || DK_DET_FAILS="${DK_DET_FAILS:-} guard-size"
fi

# Aggregated deterministic verdict: every failure above reported at once (one fix cycle), then
# on all-green record the prefix key so an identical re-ship skips straight to the AI gate.
if [ -n "${DK_DET_FAILS:-}" ]; then
echo "✗ deterministic gates failed:${DK_DET_FAILS}"
echo " Every deterministic failure is listed above — fix them together, then commit once."
exit 1
fi
if [ -z "${DK_PREFIX_SKIP:-}" ]; then node gate-engine/prefix-cache/cli.mjs record --hook "${DK_HOOK_PATH:-$0}" || true; fi
# The deterministic set runs through the SAME `guard-deterministic` orchestrator the generated
# consumer hooks use: it owns the prefix-cache check/record (skips an identical staged tree on a
# devkit ship retry), runs each gate under the shared trichotomy, and aggregates every failure
# into ONE report + ONE exit code. devkit's set is a SUBSET of the guard registry plus two
# non-guard commands, expressed as flags:
# --only size,fanout the two ratchet guards (baselines in eslint/baselines/)
# --extra "lint=bun run lint" Biome format+lint — hard gate (any non-zero blocks)
# --structure "bun run lint:structure" folder-structure governance — hard gate
# The AI gate (decisions) stays last and fail-fast, below.
node gate-engine/deterministic/run.mjs --hook "${DK_HOOK_PATH:-$0}" \
--only size,fanout \
--extra "lint=bun run lint" \
--structure "bun run lint:structure" || exit 1

# Decision-log gate (capture B) — smells an architectural diff with no decision staged and blocks
# until a Target is recorded (or GUARD_NO_LOG=1 bypasses). Dogfooded via the local engine; fail-open
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ drop the `//`-comment keys). The shared loader `@norvalbv/devkit/gate-engine/con
| `guard-review` | in-chain reviewer gate — headless domain judges over the staged diff (sonnet → opus on FAIL), diff-keyed PASS cache |
| `guard-structure` | folder-structure lint via devkit's own eslint + plugin (config-driven stacks) |
| `guard-prefix` | deterministic-prefix pass cache for `devkit ship` retries (`check`/`record`/`clear` — an identical staged tree skips the deterministic gates) |
| `guard-deterministic` | orchestrates the deterministic set in one gate (prefix check → size/fanout/dup/clone → aggregate → record) — one exit code, one report, replacing the per-guard hook protocol |
| `guard-deterministic` | orchestrates the deterministic set in one gate (prefix check → size/fanout/dup/clone → aggregate → record) — one exit code, one report, replacing the per-guard hook protocol. `--structure "<cmd>"` folds structure-lint into the same report (config-driven `guard-structure gate` / electron `bunx eslint src`); `--extra "<label>=<cmd>"` (repeatable) adds a repo-specific gate; `--only "<id,id>"` restricts the built-in set |

## Onboarding — `devkit init`

Expand Down
95 changes: 46 additions & 49 deletions cli/__tests__/husky-block-exec.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ import { afterEach, describe, expect, it } from 'vitest';
import { buildFullHook } from '../lib/husky/husky-block.mjs';

// Execute the ASSEMBLED hook under a real `sh -e` with a stub `bunx` that dispatches per tool
// (exit codes via env knobs) and logs every invocation — proving the aggregation, prefix-skip,
// and AI-gate ordering contracts end-to-end rather than by string inspection.
// (exit codes via env knobs) and logs every invocation. The hook now delegates the whole
// deterministic set (prefix cache → guards → structure → aggregation) to the single
// `guard-deterministic` orchestrator, so its internal trichotomy/aggregation is proven in
// gate-engine/deterministic/__tests__/run.test.mjs. THIS harness proves the SHELL contract the
// hook still owns: the orchestrator gates the AI fragments (`|| exit 1`), the AI gates stay
// fail-fast with their outage remedies, and it all survives dash + a hook path with spaces.

const homes = [];
afterEach(() => {
Expand All @@ -30,7 +34,7 @@ const hasDash = existsSync('/bin/dash');

function runHook(
env = {},
guards = ALL_GUARDS,
selection = { biome: false, guards: ALL_GUARDS },
{ shell = 'sh', dirPrefix = 'dk-hook-exec-' } = {},
) {
const home = mkdtempSync(join(tmpdir(), dirPrefix));
Expand All @@ -43,20 +47,16 @@ function runHook(
tool="$1"; shift
echo "$tool $*" >> "$HOME/calls.log"
case "$tool" in
guard-size) exit \${SIZE_RC:-0};;
guard-fanout) exit \${FANOUT_RC:-0};;
guard-dup) exit \${DUP_RC:-0};;
guard-clone) exit \${CLONE_RC:-0};;
guard-deterministic) exit \${DET_RC:-0};;
guard-decisions) exit \${DEC_RC:-0};;
guard-review) exit \${REVIEW_RC:-0};;
guard-prefix) case "$1" in check) exit \${PREFIX_CHECK_RC:-1};; *) exit 0;; esac;;
*) exit 0;;
esac
`,
);
chmodSync(join(bin, 'bunx'), 0o755);
const hookPath = join(home, 'pre-commit');
writeFileSync(hookPath, buildFullHook({ biome: false, guards }));
writeFileSync(hookPath, buildFullHook(selection));
let status = 0;
let stdout = '';
try {
Expand All @@ -79,39 +79,35 @@ esac
}

describe('assembled hook execution (stubbed bunx, sh -e)', () => {
it('TWO deterministic failures are BOTH reported in one aggregated block (single exit 1)', () => {
const r = runHook({ SIZE_RC: '1', DUP_RC: '1' });
it('a deterministic failure blocks the hook (exit 1) and the AI gates never run', () => {
const r = runHook({ DET_RC: '1' });
expect(r.status).toBe(1);
expect(r.stdout).toContain('deterministic gates failed: guard-size guard-dup');
// fanout/clone still ran (no fail-fast between deterministic gates)…
expect(r.calls).toContain('guard-fanout');
expect(r.calls).toContain('guard-clone');
// …but the AI gates never spend on a doomed commit, and nothing is recorded.
expect(r.calls).toContain('guard-deterministic');
// `guard-deterministic … || exit 1` — a doomed commit never pays for a judge.
expect(r.calls).not.toContain('guard-decisions');
expect(r.calls).not.toContain('guard-prefix record');
expect(r.calls).not.toContain('guard-review');
});

it('exit-2 gates fail open: hook exits 0 and records the prefix key', () => {
const r = runHook({ SIZE_RC: '2', DUP_RC: '2' });
it('a clean deterministic run lets the AI gates run', () => {
const r = runHook({ DET_RC: '0' });
expect(r.status).toBe(0);
expect(r.calls).toContain('guard-prefix record');
});

it('a prefix-cache hit skips every deterministic gate but the AI gates still run', () => {
const r = runHook({ PREFIX_CHECK_RC: '0' });
expect(r.status).toBe(0);
expect(r.calls).not.toContain('guard-size');
expect(r.calls).not.toContain('guard-dup');
expect(r.calls).toContain('guard-deterministic');
expect(r.calls).toContain('guard-decisions');
expect(r.calls).toContain('guard-review');
// already cached — never re-recorded
expect(r.calls).not.toContain('guard-prefix record');
});

it('an unexpected deterministic code is aggregated with its code named', () => {
const r = runHook({ FANOUT_RC: '127' });
expect(r.status).toBe(1);
expect(r.stdout).toContain('guard-fanout(unexpected:127)');
it('passes the resolved structure command through to the orchestrator', () => {
const r = runHook(
{ DET_RC: '0' },
{
biome: false,
guards: ALL_GUARDS,
structureCmd: 'guard-structure gate',
},
);
expect(r.status).toBe(0);
expect(r.calls).toContain('guard-deterministic --hook');
expect(r.calls).toContain('--structure guard-structure gate');
});

it('guard-review exit 3 (strict fail-closed) blocks with the outage remedy, not a violation banner', () => {
Expand All @@ -134,25 +130,26 @@ describe('assembled hook execution (stubbed bunx, sh -e)', () => {
});

describe('assembled hook — shell/OS variants', () => {
it.runIf(hasDash)(
'dash (Debian/Ubuntu /bin/sh): aggregation, prefix skip and fail-open all hold',
() => {
const opts = { shell: '/bin/dash' };
const agg = runHook({ SIZE_RC: '1', DUP_RC: '1' }, ALL_GUARDS, opts);
expect(agg.status).toBe(1);
expect(agg.stdout).toContain('deterministic gates failed: guard-size guard-dup');
expect(runHook({ SIZE_RC: '2' }, ALL_GUARDS, opts).status).toBe(0); // exit-2 fail-open under dash -e
const skip = runHook({ PREFIX_CHECK_RC: '0' }, ALL_GUARDS, opts);
expect(skip.status).toBe(0);
expect(skip.calls).not.toContain('guard-size');
expect(skip.calls).toContain('guard-review');
},
);
it.runIf(hasDash)('dash (Debian/Ubuntu /bin/sh): det-gate blocking + AI ordering hold', () => {
const opts = { shell: '/bin/dash' };
const fail = runHook({ DET_RC: '1' }, { biome: false, guards: ALL_GUARDS }, opts);
expect(fail.status).toBe(1);
expect(fail.calls).not.toContain('guard-decisions');
const clean = runHook({ DET_RC: '0' }, { biome: false, guards: ALL_GUARDS }, opts);
expect(clean.status).toBe(0);
expect(clean.calls).toContain('guard-review');
});

it('a hook path containing SPACES survives every "$0"-derived quoting seam', () => {
// devkit itself lives under "Personal and learning/" — the harness dir gets a space too.
const r = runHook({ PREFIX_CHECK_RC: '0' }, ALL_GUARDS, { dirPrefix: 'dk hook exec-' });
const r = runHook(
{ DET_RC: '0' },
{ biome: false, guards: ALL_GUARDS },
{
dirPrefix: 'dk hook exec-',
},
);
expect(r.status).toBe(0);
expect(r.calls).toContain('guard-prefix check');
expect(r.calls).toContain('guard-deterministic --hook');
});
});
Loading