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
9 changes: 8 additions & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,14 @@ node gate-engine/qavis-advisory/cli.mts --gate || qarc=$?

# devkit:fallow-advisory
# fallow audit — dead-code / duplication / complexity on the changed set; advisory, never blocks.
command -v fallow >/dev/null 2>&1 && fallow audit || true
# DEVKIT_SHIP_BASE_SHA (exported by devkit ship — see commit-with-gate-capture.sh) pins the audit's
# comparison ref to the exact commit the ship worktree was cut from, instead of fallow's own
# main-autodetect: a --base ship off a long-lived/stacked branch would otherwise misreport that
# branch's own pre-existing findings vs main as "new" (DK-5). Unset on a plain `git commit` — fallow
# falls back to its own default.
FALLOW_BASE_ARGS=""
[ -n "${DEVKIT_SHIP_BASE_SHA:-}" ] && FALLOW_BASE_ARGS="--base $DEVKIT_SHIP_BASE_SHA"
command -v fallow >/dev/null 2>&1 && fallow audit $FALLOW_BASE_ARGS || true
# /devkit:fallow-advisory
# <<< devkit-guards <<<

Expand Down
41 changes: 41 additions & 0 deletions cli/__tests__/husky-block.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,47 @@ describe('buildOverlayHook — gates-only guard for the global init.sh shim', ()
});
});

// DK-5: overlay's fallow gate BLOCKS on new findings (unlike the self-host advisory twin), and it
// runs inline (core.hooksPath shadows fallow's own installed hook), so it must see the same
// DEVKIT_SHIP_BASE_SHA scoping — else a --base ship off a stacked branch fails the audit on that
// branch's own pre-existing findings vs main.
describe('buildOverlayHook — fallow gate (overlay)', () => {
const hook = buildOverlayHook({ guards: [...GUARD_IDS] }, '.husky/pre-commit', '', {
fallow: true,
});

it('emits the fallow gate scoped by DEVKIT_SHIP_BASE_SHA', () => {
expect(hook).toContain('[ -n "${DEVKIT_SHIP_BASE_SHA:-}" ]');
expect(hook).toContain('FALLOW_BASE_ARGS="--base $DEVKIT_SHIP_BASE_SHA"');
expect(hook).toContain('fallow audit $FALLOW_BASE_ARGS || exit 1');
});

it('omits the fallow gate entirely when fallow is not opted in', () => {
const withoutFallow = buildOverlayHook({ guards: [...GUARD_IDS] }, '.husky/pre-commit');
expect(withoutFallow).not.toContain('fallow audit');
});

it('passes the ship base through to a stubbed fallow (no real binary needed)', () => {
const fragment = hook.match(
/# devkit fallow gate \(overlay\)[\s\S]*?fallow audit \$FALLOW_BASE_ARGS \|\| exit 1; \}/,
)?.[0];
expect(fragment).toBeDefined();
const script = `fallow() { echo "FALLOW_ARGS:$*"; }\n${fragment}`;

const unset = execFileSync('sh', ['-c', script], {
encoding: 'utf8',
env: { PATH: process.env.PATH },
});
expect(unset.trim()).toBe('FALLOW_ARGS:audit');

const based = execFileSync('sh', ['-c', script], {
encoding: 'utf8',
env: { PATH: process.env.PATH, DEVKIT_SHIP_BASE_SHA: 'deadbeef' },
});
expect(based.trim()).toBe('FALLOW_ARGS:audit --base deadbeef');
});
});

describe('extras (--extra hard gates on the deterministic line)', () => {
it('emits `--extra "label=cmd"` per extra', () => {
const block = buildGuardBlock({
Expand Down
16 changes: 16 additions & 0 deletions cli/__tests__/overlay.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,28 @@ function seedLocalSettings(root, obj) {
writeFileSync(join(root, '.claude', 'settings.local.json'), JSON.stringify(obj));
}

// Unlike the ship-branch/reship/reconcile suites (which spawn devkit as a SUBPROCESS and isolate git
// via an explicit `env: GENV` per call), this file calls `applyInit`/`overlay.mts` IN-PROCESS — so
// its internal `execFileSync('git', …)` calls (e.g. installHealAlias's `alias.ci` collision check)
// inherit whatever `process.env` already is, not anything this test passes per-call. A developer
// machine with its OWN global `git ci` alias already set makes that check correctly (and
// deliberately — see overlay.mts) skip installing devkit's self-heal alias, which then reads as a
// false failure here. Isolate the whole process for the file's duration, restored after, so the
// suite is deterministic regardless of the host's real ~/.gitconfig.
const ORIGINAL_GIT_CONFIG_GLOBAL = process.env.GIT_CONFIG_GLOBAL;
const ORIGINAL_GIT_CONFIG_SYSTEM = process.env.GIT_CONFIG_SYSTEM;
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
process.env.GIT_CONFIG_GLOBAL = '/dev/null';
process.env.GIT_CONFIG_SYSTEM = '/dev/null';
});
afterEach(() => {
vi.restoreAllMocks();
cleanup();
if (ORIGINAL_GIT_CONFIG_GLOBAL === undefined) delete process.env.GIT_CONFIG_GLOBAL;
else process.env.GIT_CONFIG_GLOBAL = ORIGINAL_GIT_CONFIG_GLOBAL;
if (ORIGINAL_GIT_CONFIG_SYSTEM === undefined) delete process.env.GIT_CONFIG_SYSTEM;
else process.env.GIT_CONFIG_SYSTEM = ORIGINAL_GIT_CONFIG_SYSTEM;
});

describe('overlay (local-only) install', () => {
Expand Down
53 changes: 53 additions & 0 deletions cli/__tests__/reship.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,59 @@ describe('reship — untracked gate configs are linked into the re-ship worktree
});
});

// DK-5: reship's worktree is cut from the fetched PR-branch tip — in-chain gates (fallow) need that
// SAME commit to scope their own audit, not their own main-autodetect.
describe('reship — exports DEVKIT_SHIP_BASE_SHA (DK-5)', () => {
it('is the fetched PR-branch tip, not a stale local ref', () => {
const bare = mkdtempSync(join(tmpdir(), 'reshipbare-'));
dirs.push(bare);
execFileSync('git', ['init', '-q', '--bare', bare], { env: { ...process.env, ...GENV } });
const dir = mkdtempSync(join(tmpdir(), 'reshipwt-'));
dirs.push(dir);
const env = { ...process.env, ...GENV };
const g = (a, o = {}) =>
execFileSync('git', ['-C', dir, ...a], { env, encoding: 'utf8', ...o });
mkdirSync(join(dir, '.husky/_'), { recursive: true });
writeFileSync(join(dir, '.husky/.keep'), '');
for (const a of [
['init', '-q', '-b', 'work'],
['config', 'user.email', 'a@b.c'],
['config', 'user.name', 'a'],
['config', 'commit.gpgsign', 'false'],
['add', '.husky/.keep'],
['commit', '-q', '-m', 'base'],
['config', 'core.hooksPath', '.husky/_'],
['remote', 'add', 'origin', bare],
])
g(a, { stdio: 'ignore' });
writeFileSync(
join(dir, '.husky/_/pre-commit'),
'#!/bin/sh\necho "HOOK_BASE=$DEVKIT_SHIP_BASE_SHA"\nexit 0\n',
);
chmodSync(join(dir, '.husky/_/pre-commit'), 0o755);
writeFileSync(join(dir, 'a.ts'), 'v1\n');
g(['add', 'a.ts'], { stdio: 'ignore' });
g(['commit', '-q', '-m', 'first'], { stdio: 'ignore' });
g(['push', '-q', 'origin', 'HEAD:feat/pr'], { stdio: 'ignore' });
const prTip = execFileSync('git', ['-C', bare, 'rev-parse', 'feat/pr'], {
env,
encoding: 'utf8',
}).trim();
writeFileSync(join(dir, 'a.ts'), 'v2\n');

const r = run(['feat/pr', 'add v2', '--pr', '--', 'a.ts'], dir, { SHIP_DRY_RUN: '1' });
const wt = WT_RE.exec(r.stderr)?.[1];
try {
expect(r.status, r.stderr).toBe(0);
expect(readFileSync(join(dir, '.devkit/last-ship-gates-feat-pr.log'), 'utf8')).toContain(
`HOOK_BASE=${prTip}`,
);
} finally {
if (wt) g(['worktree', 'remove', '--force', wt], { stdio: 'ignore' });
}
});
});

describe('reship — repo path with a space (linked-worktree COMMIT_EDITMSG carries the space)', () => {
// A linked-worktree commit hands the commit-msg hook the ABSOLUTE $GIT_DIR/COMMIT_EDITMSG path; under
// a spaced repo root that path contains the space. Devkit forwards it as one intact arg (every ship
Expand Down
32 changes: 31 additions & 1 deletion cli/__tests__/self-host.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* If the parity test fails, the hook drifted from the generator: regenerate it (`devkit init` in the
* repo, or `devkit doctor --fix`) and re-commit.
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -75,14 +76,43 @@ describe('buildSelfHostHook', () => {

it('preserves the advisory fallow-audit gate INSIDE the block (never blocks, survives re-run)', () => {
const hook = buildSelfHostHook(HOOK_SEL, '', ROOT);
expect(hook).toContain('command -v fallow >/dev/null 2>&1 && fallow audit || true');
expect(hook).toContain(
'command -v fallow >/dev/null 2>&1 && fallow audit $FALLOW_BASE_ARGS || true',
);
// Inside the devkit-guards block: after the start marker, before the end marker — so
// replaceGuardBlock preserves it on a re-run and the parity/doctor check covers it.
expect(hook.indexOf('fallow audit')).toBeGreaterThan(hook.indexOf('>>> devkit-guards'));
expect(hook.indexOf('fallow audit')).toBeLessThan(hook.indexOf('<<< devkit-guards'));
expect(hook.trimEnd().endsWith('exit 0')).toBe(true);
});

// DK-5: a --base ship cuts the gate worktree from a possibly non-main base, so the advisory fallow
// audit must diff against THAT commit (DEVKIT_SHIP_BASE_SHA, exported by ship-branch.sh/reship.sh)
// rather than fallow's own main-autodetect — else a stacked branch's own pre-existing findings
// misreport as "new". No real fallow binary in this sandbox: stub it and assert on the args it sees.
it('scopes the fallow audit to DEVKIT_SHIP_BASE_SHA when a ship exported it', () => {
const hook = buildSelfHostHook(HOOK_SEL, '', ROOT);
expect(hook).toContain('[ -n "${DEVKIT_SHIP_BASE_SHA:-}" ]');
expect(hook).toContain('FALLOW_BASE_ARGS="--base $DEVKIT_SHIP_BASE_SHA"');
const fragment = extractGuardBlock(hook, '')?.match(
/# devkit:fallow-advisory[\s\S]*?# \/devkit:fallow-advisory/,
)?.[0];
expect(fragment).toBeDefined();
const script = `fallow() { echo "FALLOW_ARGS:$*"; }\n${fragment}`;

const unset = execFileSync('sh', ['-c', script], {
encoding: 'utf8',
env: { PATH: process.env.PATH },
});
expect(unset.trim()).toBe('FALLOW_ARGS:audit');

const based = execFileSync('sh', ['-c', script], {
encoding: 'utf8',
env: { PATH: process.env.PATH, DEVKIT_SHIP_BASE_SHA: 'deadbeef' },
});
expect(based.trim()).toBe('FALLOW_ARGS:audit --base deadbeef');
});

it('is idempotent through replaceGuardBlock — re-applying the block keeps the fallow fragment intact', () => {
const fresh = buildSelfHostHook(HOOK_SEL, '', ROOT);
const block = buildSelfHostBlock(HOOK_SEL, '', ROOT);
Expand Down
52 changes: 48 additions & 4 deletions cli/__tests__/ship-branch.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,13 @@ function dropWorktree(git, stderr) {
* bare path fails its shape check), while the absolute path stays reachable from BOTH ROOT (ls-remote)
* and the ephemeral $WT (push) with no network. Drives the real non-dry push + manifest path offline.
*/
function seedShipRepoLocalRemote() {
function seedShipRepoLocalRemote({ hookBody } = {}) {
const ghRoot = mkdtempSync(join(tmpdir(), 'shipgh-'));
dirs.push(ghRoot);
const bare = join(ghRoot, 'github.com', 'acme', 'app.git');
mkdirSync(join(ghRoot, 'github.com', 'acme'), { recursive: true });
execFileSync('git', ['init', '-q', '--bare', bare], { env: { ...process.env, ...GIT_ENV } });
return { ...seedShipRepo({ origin: bare }), bare };
return { ...seedShipRepo({ origin: bare, ...(hookBody ? { hookBody } : {}) }), bare };
}

/**
Expand Down Expand Up @@ -294,8 +294,8 @@ describe('ship-branch.sh — PR base = the branch we branched from', () => {
describe('ship-branch.sh — --base <branch>', () => {
/** A repo with an `origin` bare that has a `studio` branch, and a `finalized` branch (checked out)
* whose note.txt change is ALREADY COMMITTED — exactly the DK-1 repro state. */
function seedBaseRepo() {
const seeded = seedShipRepoLocalRemote();
function seedBaseRepo({ hookBody } = {}) {
const seeded = seedShipRepoLocalRemote({ hookBody });
const { dir, git, bare } = seeded;
writeFileSync(join(dir, 'note.txt'), 'studio\n');
git(['add', 'note.txt'], { stdio: 'ignore' });
Expand Down Expand Up @@ -412,6 +412,24 @@ describe('ship-branch.sh — --base <branch>', () => {

// The --base empty-commit hint ("already identical on origin/<base>", no checkout advice) is
// covered in the `empty-commit preflight` describe, beside its default-base twin.

// DK-5: the worktree is cut from origin's fetched studio tip — in-chain gates (fallow) need that
// SAME commit exported so they scope their own audit against it instead of their own
// main-autodetect, which would misreport studio's own pre-existing findings vs main as "new".
it("exports DEVKIT_SHIP_BASE_SHA = origin's fetched studio tip, not a stale local ref", () => {
const { dir, env, git, studioTip } = seedBaseRepo({
hookBody: 'echo "HOOK_BASE=$DEVKIT_SHIP_BASE_SHA"',
});
const r = spawnSync(
'/bin/bash',
[scriptPath, 'feat/base-sha-flag', 't', '--base', 'studio', '--', 'note.txt'],
{ cwd: dir, input: 'b\n', encoding: 'utf8', env: { ...env, SHIP_DRY_RUN: '1' } },
);
dropWorktree(git, r.stderr);
expect(r.status, r.stderr).toBe(0);
const log = readFileSync(join(dir, '.devkit/last-ship-gates-feat-base-sha-flag.log'), 'utf8');
expect(log).toContain(`HOOK_BASE=${studioTip}`);
});
});

describe('ship-branch.sh — isolation + arg guards', () => {
Expand Down Expand Up @@ -861,6 +879,32 @@ describe('ship-branch.sh — worktree integration', () => {
expect(r.status, r.stderr).toBe(0);
expect(git(['show', '-s', '--format=%b', 'feat/body'])).toMatch(/BODY_INLINE_XYZ/);
});

// DK-5: the worktree is cut from $BASE (default: this checkout's HEAD; --base: origin's fetched
// tip), and in-chain gates (fallow) need that SAME commit to scope their own audit correctly —
// else a --base ship off a stacked branch misreports that branch's pre-existing findings vs main
// as "new". Assert the exported var matches the commit the worktree was ACTUALLY cut from, not a
// stale local ref.
it("exports DEVKIT_SHIP_BASE_SHA = this checkout's HEAD for a default (no --base) ship", () => {
const { dir, env, git } = seedShipRepo({
hookBody: 'echo "HOOK_BASE=$DEVKIT_SHIP_BASE_SHA"',
});
const headSha = git(['rev-parse', 'HEAD']).trim();
writeFileSync(join(dir, 'note.txt'), 'hi\n');
const r = spawnSync('/bin/bash', [scriptPath, 'feat/base-sha-default', 't', 'note.txt'], {
cwd: dir,
input: 'b\n',
encoding: 'utf8',
env: { ...env, SHIP_DRY_RUN: '1' },
});
dropWorktree(git, r.stderr);
expect(r.status, r.stderr).toBe(0);
const log = readFileSync(
join(dir, '.devkit/last-ship-gates-feat-base-sha-default.log'),
'utf8',
);
expect(log).toContain(`HOOK_BASE=${headSha}`);
});
});

// Overlay mode keeps the entire gate chain in a git-ignored .devkit/hooks/pre-commit that never
Expand Down
6 changes: 5 additions & 1 deletion cli/lib/husky/husky-block.mts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,11 @@ fi`;
// chaining the gate inline here is the only way the audit runs. `fallow audit` exits non-zero on
// NEW issues (pre-existing debt is grandfathered by the saved fallow-baselines/).
const FALLOW_OVERLAY_GATE = `# devkit fallow gate (overlay) — fail-open; skipped if fallow isn't installed.
command -v fallow >/dev/null 2>&1 && { fallow audit || exit 1; }`;
# DEVKIT_SHIP_BASE_SHA (set by devkit ship) narrows the audit to the exact ship base rather than
# fallow's own main-autodetect — see self-host.mts's FALLOW_FRAGMENT for the full rationale (DK-5).
FALLOW_BASE_ARGS=""
[ -n "\${DEVKIT_SHIP_BASE_SHA:-}" ] && FALLOW_BASE_ARGS="--base $DEVKIT_SHIP_BASE_SHA"
command -v fallow >/dev/null 2>&1 && { fallow audit $FALLOW_BASE_ARGS || exit 1; }`;

/**
* Build the OVERLAY hook — a complete, self-contained file devkit fully owns (written to a
Expand Down
9 changes: 8 additions & 1 deletion cli/lib/husky/self-host.mts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,14 @@ export const SELF_HOST_EXTRAS: Array<{ label: string; cmd: string }> = [
// covers it too.
const FALLOW_FRAGMENT = `# devkit:fallow-advisory
# fallow audit — dead-code / duplication / complexity on the changed set; advisory, never blocks.
command -v fallow >/dev/null 2>&1 && fallow audit || true
# DEVKIT_SHIP_BASE_SHA (exported by devkit ship — see commit-with-gate-capture.sh) pins the audit's
# comparison ref to the exact commit the ship worktree was cut from, instead of fallow's own
# main-autodetect: a --base ship off a long-lived/stacked branch would otherwise misreport that
# branch's own pre-existing findings vs main as "new" (DK-5). Unset on a plain \`git commit\` — fallow
# falls back to its own default.
FALLOW_BASE_ARGS=""
[ -n "\${DEVKIT_SHIP_BASE_SHA:-}" ] && FALLOW_BASE_ARGS="--base $DEVKIT_SHIP_BASE_SHA"
command -v fallow >/dev/null 2>&1 && fallow audit $FALLOW_BASE_ARGS || true
# /devkit:fallow-advisory`;

// The hook-builder's view of the self-host selection (Selection + the two hook-only fields the
Expand Down
7 changes: 7 additions & 0 deletions cli/lib/ship/commit-with-gate-capture.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ commit_with_gate_capture() {
# outside ship keep their fail-open default.
# DEVKIT_REVIEW_PROGRESS where guard-review records {running,completed} reviewer names, so a
# timeout can name the ones left unfinished (structured, not stderr prose).
# DEVKIT_SHIP_BASE_SHA the commit the worktree was cut from — NOT exported here, but by this
# function's CALLERS (ship-branch.sh / reship.sh, right beside their own
# DEVKIT_SHIP_MODE export), since they're the ones who resolved $BASE.
# Listed here so this stays the one place to look for every ship-mode env
# var. Consumed by devkit's own fallow-advisory/overlay fragments to scope
# `fallow audit --base` at the real ship base instead of its own
# main-autodetect (DK-5).
export DEVKIT_SHIP=1 GUARD_AI_STRICT=1 DEVKIT_REVIEW_PROGRESS="$progress"

# Gate telemetry (best-effort, ship-scoped). A shared append-only JSONL sink + one ship_id per
Expand Down
3 changes: 3 additions & 0 deletions cli/lib/ship/reship.sh
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ link_untracked_gate_configs "$WT" "$ROOT"
# Commit (gates run HERE). Capture + surface the gate output for the shipping agent — git buries it on
# the commit's stderr. Shared with new-ship. See commit-with-gate-capture.sh.
. "$(dirname "${BASH_SOURCE[0]}")/commit-with-gate-capture.sh"
# The fetched PR-branch tip the worktree was cut from — lets in-chain gates (fallow) diff against IT,
# not their own main-autodetect (DK-5).
export DEVKIT_SHIP_BASE_SHA="$BASE"
export DEVKIT_SHIP_MODE=reship # tags the ship_attempt telemetry (retry onto an existing branch)
commit_with_gate_capture "$WT" "$ROOT" "$BR" "$TITLE" "$BODY"

Expand Down
4 changes: 4 additions & 0 deletions cli/lib/ship/ship-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,10 @@ link_untracked_gate_configs "$WT" "$ROOT"
# Commit inside the worktree (hook gates run HERE). Capture + surface the gate output so the shipping
# agent reliably sees the verdicts — git buries them on the commit's stderr. See commit-with-gate-capture.sh.
. "$(dirname "${BASH_SOURCE[0]}")/commit-with-gate-capture.sh"
# The commit the worktree was cut from — lets in-chain gates (fallow) diff against IT, not their own
# main-autodetect. Unconditional (not just under --base): even the default case is more precise than
# a gate auto-detecting main, for any branch that isn't a fresh cut off main (DK-5).
export DEVKIT_SHIP_BASE_SHA="$BASE"
export DEVKIT_SHIP_MODE=ship # tags the ship_attempt telemetry (new-ship vs reship retry)
commit_with_gate_capture "$WT" "$ROOT" "$BR" "$TITLE" "$BODY"

Expand Down
2 changes: 1 addition & 1 deletion eslint/baselines/size-lines.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
"gate-engine/review/eval/bench.mts": 878,
"gate-engine/review/eval/conventions/bench.mts": 988,
"gate-engine/review/eval/reviewers/bench.mts": 977,
"gate-engine/review/run-review.mts": 623
"gate-engine/review/run-review.mts": 642
}
}
4 changes: 3 additions & 1 deletion gate-engine/review/run-review.mts
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,9 @@ export async function runReviewGate(
if (res.status === 'pass')
// res.model = the model that actually judged (a Reviewer.model pin wins over the cascade
// default) — recording firstModel here mislabeled every pinned reviewer's cached PASS.
savePasses(cwd, { [t.key]: { at: new Date().toISOString(), model: res.model ?? firstModel } });
savePasses(cwd, {
[t.key]: { at: new Date().toISOString(), model: res.model ?? firstModel },
});
if (progressFile) {
completed.push(res.name);
writeProgress(progressFile, { running, completed });
Expand Down