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
40 changes: 20 additions & 20 deletions dist/gate-engine/deterministic/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -262,28 +262,28 @@ export function runDeterministic(cwd = process.cwd(), opts = {}) {
const cacheScope = prefixCacheScope(opts.scope, effectiveIds);
// Deterministic-prefix cache (ship only — a no-op otherwise): a cached all-green staged tree skips
// every gate. checkPrefix returns true = skip, false = run.
const cachedPrefix = prefixEntry(cwd, { hookPath: opts.hookPath, scope: cacheScope });
const skip = Boolean(cachedPrefix);
const bypassStructure = Boolean(opts.structure) && structureBypassed();
const fails = [];
const cachedPrefix = prefixEntry(cwd, { hookPath: opts.hookPath, scope: cacheScope });
const skip = Boolean(cachedPrefix);
const bypassStructure = Boolean(opts.structure) && structureBypassed();
const fails = [];
// Gates that opted out (exit 2 where that IS an opt-out) and so proved nothing. Reported even on a
// green run — the whole defect this exists for is a skipped gate reading like a passed one.
const skipped = [];
if (!skip) {
if (bypassStructure) {
console.log('⚠️ Structure lint BYPASSED for this run (GUARD_STRUCTURE_OK=1).');
console.log(' Repository structure was NOT verified for this commit.');
emitGateEvent({
type: 'gate_result',
gate: 'structure-lint',
// The collector's gate_result schema accepts fail | could_not_run. Keep the deliberate
// bypass measurable as a non-run, and distinguish it from an infrastructure opt-out in
// detail instead of inventing a status that downstream readers would treat as clean.
status: 'could_not_run',
detail: 'structure-lint(bypassed:GUARD_STRUCTURE_OK)',
});
}
const ids = new Set(effectiveIds);
const skipped = [];
if (!skip) {
if (bypassStructure) {
console.log('⚠️ Structure lint BYPASSED for this run (GUARD_STRUCTURE_OK=1).');
console.log(' Repository structure was NOT verified for this commit.');
emitGateEvent({
type: 'gate_result',
gate: 'structure-lint',
// The collector's gate_result schema accepts fail | could_not_run. Keep the deliberate
// bypass measurable as a non-run, and distinguish it from an infrastructure opt-out in
// detail instead of inventing a status that downstream readers would treat as clean.
status: 'could_not_run',
detail: 'structure-lint(bypassed:GUARD_STRUCTURE_OK)',
});
}
const ids = new Set(effectiveIds);
const gates = DETERMINISTIC.filter((g) => ids.has(g.id)).map((g) => ({
label: `guard-${g.id}`,
argv: ['node', path.resolve(HERE, g.module.replace(MJS_EXT_RE, SELF_EXT)), ...g.args],
Expand Down
39 changes: 20 additions & 19 deletions dist/gate-engine/ratchets/size-disable.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync,
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { CONFIG_FILENAME, resolveGuardConfig, sourceMatchers } from "../config.mjs";
import { hasStagedFiles, stageBaseline, stagedSet } from "./git-index.mjs";
import { hasStagedFiles, pullRequestScope, stageBaseline, stagedSet } from "./git-index.mjs";
import { LINES_BASELINE, SIZE_SKIP_DIRS } from "./size-policy.mjs";
import { runPreflightCli } from "./size-preflight.mjs";
const BASELINE = 'eslint/baselines/size.json';
Expand Down Expand Up @@ -194,7 +194,7 @@ export function previewGrandfather(cwd) {
// no commit to carry a baseline change. Exits 1 on a file over its ceiling.
// Reason: sequential grow-check then per-file auto-lower, each a trivial guard at low nesting; splitting scatters one gate decision
// fallow-ignore-next-line complexity
function runLinesGate(root, cfg, linesBaselineFile) {
function runLinesGate(root, cfg, linesBaselineFile, ciScope) {
const over = countOversized(root);
const grandfathered = existsSync(linesBaselineFile)
? JSON.parse(readFileSync(linesBaselineFile, 'utf8')).files
Expand All @@ -203,8 +203,9 @@ function runLinesGate(root, cfg, linesBaselineFile) {
const inCommit = staged !== null && hasStagedFiles(root);
const match = sourceMatchers(cfg.sourceExtensions);
const cap = (f) => (match.isTest(f) ? cfg.maxTestLines : cfg.maxLines);
// Scope to the committing files; with nothing staged, fall back to the whole tree (CI).
const scoped = inCommit ? over.filter((o) => staged?.has(o.file)) : over;
// A PR supplies an exact base scope; local commits use the index; audits use the whole tree.
const selected = ciScope ?? (inCommit ? staged : null);
const scoped = selected ? over.filter((o) => selected.has(o.file)) : over;
// A file fails when it exceeds its own recorded ceiling (grandfathered) or the cap (new file).
const grew = scoped.filter((o) => o.lines > Math.max(cap(o.file), grandfathered[o.file] ?? 0));
if (grew.length) {
Expand All @@ -214,8 +215,8 @@ function runLinesGate(root, cfg, linesBaselineFile) {
}
process.exit(1);
}
if (!inCommit || !staged)
return; // no commit in progress → never tighten/stage
if (ciScope || !inCommit || !staged)
return; // CI never tightens/stages
// Tighten only the committing files' ceilings; every other recorded count is preserved as-is,
// so a concurrent agent's uncommitted shrink is never locked in.
const next = { ...grandfathered };
Expand Down Expand Up @@ -266,21 +267,21 @@ function readDisableBaseline(baselineFile) {
// gate blocks (its counts aren't recognised); a stale {0,0} self-deletes in the commit.
// Reason: sequential grow-check then per-file auto-lower, each a trivial guard at low nesting; one gate decision, mirrors runLinesGate
// fallow-ignore-next-line complexity
function runDisableGate(root, baselineFile, current) {
function runDisableGate(root, baselineFile, current, ciScope) {
const { grandfathered, legacy } = readDisableBaseline(baselineFile);
const cur = current.perFile;
const staged = stagedSet(root);
const inCommit = staged !== null && hasStagedFiles(root);
const ceil = (f) => grandfathered[f] ?? { file: 0, fn: 0 };
// A file fails when its disables exceed its recorded ceiling (0 for an unlisted/new file). Scope to
// the committing files; with nothing staged, the whole tree (CI). A LEGACY baseline is always
// whole-tree: it has no per-file grandfathering, so any disable ANYWHERE is unrecognised and must
// block the migrate — else an unstaged disable slips past and the commit path below deletes
// A file fails when its disables exceed its recorded ceiling (0 for an unlisted/new file). A PR
// scopes to its diff. Otherwise a LEGACY baseline stays whole-tree: it has no per-file
// grandfathering, so an unstaged disable must block rather than let the commit path below delete
// size.json wholesale (changed=legacy, empty map), silently un-grandfathering it.
const scoped = legacy
? Object.keys(cur)
: inCommit
? [...staged]
const selected = legacy ? null : (ciScope ?? (inCommit ? staged : null));
const scoped = selected
? [...selected]
: legacy
? Object.keys(cur)
: Object.keys({ ...cur, ...grandfathered });
const grew = scoped.filter((f) => cur[f] && (cur[f].file > ceil(f).file || cur[f].fn > ceil(f).fn));
if (grew.length) {
Expand All @@ -295,7 +296,7 @@ function runDisableGate(root, baselineFile, current) {
console.error(' Split the file below the cap instead of disabling.');
process.exit(1);
}
if (!inCommit || !staged) {
if (ciScope || !inCommit || !staged) {
// No commit in progress → never mutate. Nudge a re-freeze if anything shrank or a legacy file lingers.
if (legacy) {
console.log(`✓ ${BASELINE} is a pre-per-file baseline — run \`guard-size freeze\` to migrate.`);
Expand Down Expand Up @@ -374,6 +375,7 @@ function runCli(cmd) {
// Reason: the two ratchets (folder-fanout / size-disable) are parallel-by-design independent guard bins (+ tests); each self-contained with the same freeze/gate CLI shell
// fallow-ignore-next-line code-duplication
if (cmd === 'gate') {
const ciScope = pullRequestScope(root);
const hasBaseline = existsSync(baselineFile);
// A missing baseline means "no grandfathered debt". Enforce from config (empty baseline = 0/0)
// whenever the repo is governed (guard.config.json present — true in devkit's own repo, CI, and
Expand All @@ -384,10 +386,9 @@ function runCli(cmd) {
process.exit(2); // ungoverned + un-frozen → fail open
}
// Disable ratchet: per-file, per-commit shrink-only (auto-lowers as disables are removed).
runDisableGate(root, baselineFile, current);
// Raw-line caps: a per-file, per-COMMIT shrink-only ratchet.
runDisableGate(root, baselineFile, current, ciScope);
if (cfg.maxLines || cfg.maxTestLines)
runLinesGate(root, cfg, linesBaselineFile);
runLinesGate(root, cfg, linesBaselineFile, ciScope);
process.exit(0);
}
console.error('usage: guard-size <freeze|gate|preflight --base <ref> [-- path...]>');
Expand Down
7 changes: 2 additions & 5 deletions dist/gate-engine/ratchets/size-preflight.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { resolveGuardConfig, sourceMatchers } from "../config.mjs";
import { stagedSet } from "./git-index.mjs";
import { gitPrefix, stagedSet } from "./git-index.mjs";
import { LINES_BASELINE, SIZE_SKIP_DIRS } from "./size-policy.mjs";
function readLinesBaseline(file) {
if (!existsSync(file))
Expand All @@ -15,10 +15,7 @@ function readLinesBaselineAtRef(root, ref) {
cwd: root,
stdio: ['ignore', 'pipe', 'ignore'],
});
const prefix = execFileSync('git', ['rev-parse', '--show-prefix'], {
cwd: root,
encoding: 'utf8',
}).trim();
const prefix = gitPrefix(root);
let text;
try {
text = execFileSync('git', ['show', `${ref}:${prefix}${LINES_BASELINE}`], {
Expand Down
22 changes: 22 additions & 0 deletions dist/gate-engine/review/cascade/consumer-assets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { checklistAssetPath, hasChecklist } from "../reviewers.mjs";
const CONSUMER_SKILL_ROOTS = ['.claude', '.agents', '.cursor'];
/** Resolve the provider-projected checklist root actually present in a consumer checkout. */
export function consumerChecklistAssetRoot(cwd, reviewer) {
if (!hasChecklist(reviewer))
return '.claude';
const relativePath = checklistAssetPath(reviewer);
return (CONSUMER_SKILL_ROOTS.find((root) => existsSync(path.resolve(cwd, root, relativePath))) ??
'.claude');
}
/** Read one package-relative asset from its consumer-projected brief or skill root. */
export function readConsumerReviewAsset(cwd, cfg, skillRoot, relativePath) {
const agentsPrefix = 'agents/';
if (relativePath.startsWith(agentsPrefix)) {
const dir = cfg.review.agentsDir;
const base = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
return readFileSync(path.join(base, relativePath.slice(agentsPrefix.length)));
}
return readFileSync(path.resolve(cwd, skillRoot, relativePath));
}
171 changes: 171 additions & 0 deletions dist/gate-engine/review/cascade/reviewer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { JUDGE_ISOLATION } from "../../judge/judge-isolation.mjs";
import { DEEP_JUDGE_TIMEOUT_MS, execJudgeAsync } from "../../judge/run-judge.mjs";
import { renderGoverningClaudeMd } from "../claude-md.mjs";
import { buildCappedDiffEvidence } from "../diff-evidence.mjs";
import { attachItems } from "../evidence/items.mjs";
import { gitCached } from "../evidence/staged-git.mjs";
import { applyOverrideValve } from "../overrides.mjs";
import { allowedToolsFor, escalatePrompt, hasChecklist, parseReviewVerdict, wrapConventionsPrompt, wrapPrompt, } from "../reviewers.mjs";
import { agentBody, cleanupChecklistState, enforceChecklistContract, initializeCommitGuardChecklist, readChecklistState, withStagedFiles, } from "../runtime.mjs";
import { consumerChecklistAssetRoot } from "./consumer-assets.mjs";
/** Run one reviewer with checklist verification, override handling, and cleanup. */
export async function runCascade(sel, opts) {
const { cwd } = opts;
const checklistRoot = opts.assetRoot ?? consumerChecklistAssetRoot(cwd, sel.reviewer);
cleanupChecklistState(cwd, sel.reviewer);
try {
initializeCommitGuardChecklist(cwd, sel.reviewer, checklistRoot, opts.judgeEnv);
let res = await cascadeVerdict(sel, opts, checklistRoot);
res = await enforceChecklistContract(sel, res, cwd, opts.assetRoot, async (reason) => {
if (opts.recovery === 'defer')
return { ...res, status: 'inconclusive', reason, retryable: reason };
if (opts.recovery === 'final')
return {
...res,
status: 'error',
reason: `reviewer checklist contract failed after one retry — ${reason}`,
};
throw new Error(`checklist recovery has no scheduling mode — ${reason}`);
});
const disposition = applyOverrideValve(sel, res, cwd, {
readState: () => readChecklistState(cwd, sel.reviewer),
stagedDiff: () => gitCached(cwd, [], sel.files),
});
attachItems(res, readChecklistState(cwd, sel.reviewer), disposition);
return res;
}
finally {
cleanupChecklistState(cwd, sel.reviewer);
}
}
async function cascadeVerdict({ reviewer, files }, { cwd, cfg, exec = execJudgeAsync, firstModel = 'haiku', retryFirst = false, assetRoot, judgeEnv, checklistRecoveryReason, promptExtras, }, checklistRoot) {
const env = withStagedFiles(judgeEnv ?? process.env, reviewer, files);
const body = agentBody(cwd, cfg, reviewer.name, assetRoot);
if (body === null)
return {
name: reviewer.name,
status: 'inconclusive',
reason: `agent brief ${reviewer.name}.md missing under ${cfg.review.agentsDir} — run devkit sync-agents && devkit sync-skills`,
escalated: false,
};
const stat = gitCached(cwd, ['--stat'], files);
const prompt = hasChecklist(reviewer)
? wrapPrompt(body, reviewer, files, assetRoot, checklistRecoveryReason, promptExtras, checklistRoot)
: wrapConventionsPrompt(body, files, renderGoverningClaudeMd(cwd, files), promptExtras);
const input = buildCappedDiffEvidence(gitCached(cwd, [], files), stat);
const args = (promptBody, model) => [
'-p',
promptBody,
'--model',
model,
...JUDGE_ISOLATION,
'--allowedTools',
allowedToolsFor(reviewer, cfg, checklistRoot),
];
const passModel = reviewer.model ?? firstModel;
let firstOutage;
const firstOpts = {
label: `review:${reviewer.name}`,
args: args(prompt, passModel),
input,
timeout: DEEP_JUDGE_TIMEOUT_MS,
cwd,
transcript: false,
env,
onOutage: (kind) => {
firstOutage = kind;
},
};
let first = await exec(firstOpts);
if (first === null && retryFirst && firstOutage !== 'timeout') {
console.error(`guard-review: ${reviewer.name}: judge run failed (${firstOutage ?? 'transient'}), retrying once…`);
cleanupChecklistState(cwd, reviewer);
initializeCommitGuardChecklist(cwd, reviewer, checklistRoot, judgeEnv);
first = await exec(firstOpts);
}
if (first === null)
return {
name: reviewer.name,
status: 'inconclusive',
reason: firstOutage === 'timeout' ? 'judge timed out' : 'judge outage',
escalated: false,
model: passModel,
};
const firstVerdict = parseReviewVerdict(first);
if (firstVerdict.verdict === 'PASS')
return {
name: reviewer.name,
status: 'pass',
reason: firstVerdict.reason,
escalated: false,
model: passModel,
transcript: first,
};
if (firstVerdict.verdict === null)
return {
name: reviewer.name,
status: 'inconclusive',
reason: 'no VERDICT line',
escalated: false,
model: passModel,
transcript: first,
};
if (reviewer.model)
return {
name: reviewer.name,
status: 'fail',
reason: firstVerdict.reason,
escalated: false,
model: passModel,
transcript: first,
};
let secondOutage;
const second = await exec({
label: `review:${reviewer.name}:escalate`,
args: args(escalatePrompt(prompt, first), 'opus'),
input,
timeout: DEEP_JUDGE_TIMEOUT_MS,
cwd,
transcript: false,
env,
onOutage: (kind) => {
secondOutage = kind;
},
});
if (second === null)
return {
name: reviewer.name,
status: 'inconclusive',
reason: secondOutage === 'timeout' ? 'escalation timed out' : 'escalation outage',
escalated: true,
model: passModel,
transcript: first,
};
const finalVerdict = parseReviewVerdict(second);
if (finalVerdict.verdict === 'FAIL')
return {
name: reviewer.name,
status: 'fail',
reason: finalVerdict.reason,
escalated: true,
model: passModel,
transcript: second,
};
if (finalVerdict.verdict === 'PASS')
return {
name: reviewer.name,
status: 'pass',
reason: finalVerdict.reason,
escalated: true,
model: passModel,
transcript: second,
};
return {
name: reviewer.name,
status: 'inconclusive',
reason: 'no VERDICT line',
escalated: true,
model: passModel,
transcript: second,
};
}
7 changes: 4 additions & 3 deletions dist/gate-engine/review/reviewers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,10 @@ export function stripFrontmatter(md) {
* preamble re-scopes it (staged-only, checklist-driven, no marker/approve machinery) and the
* postamble pins the machine-parseable verdict line.
*/
export function wrapPrompt(agentBody, reviewer, files, assetRoot, checklistRecoveryReason, { targetsBlock = '', commitMsgBlock = '' } = {}) {
const effectiveAssetRoot = assetRoot ?? '.claude';
const brief = stripFrontmatter(agentBody).replaceAll('.claude/skills/', `${effectiveAssetRoot.replace(TRAILING_SLASH_RE, '')}/skills/`);
export function wrapPrompt(agentBody, reviewer, files, assetRoot, checklistRecoveryReason, { targetsBlock = '', commitMsgBlock = '' } = {}, checklistRoot = assetRoot ?? '.claude') {
const effectiveAssetRoot = checklistRoot;
const skillPrefix = `${effectiveAssetRoot.replace(TRAILING_SLASH_RE, '')}/skills/`;
const brief = ['.agents/skills/', '.claude/skills/', '.cursor/skills/'].reduce((body, providerPrefix) => body.replaceAll(providerPrefix, skillPrefix), stripFrontmatter(agentBody));
const script = checklistScriptAt(reviewer, effectiveAssetRoot);
const checklistContract = checklistContractFor(reviewer, script, assetRoot);
return ('You are running as an automated HEADLESS COMMIT GATE, not an interactive assistant.\n' +
Expand Down
Loading
Loading