diff --git a/cli/__tests__/ship-branch.test.mts b/cli/__tests__/ship-branch.test.mts index 15fb36b2..75fb81de 100644 --- a/cli/__tests__/ship-branch.test.mts +++ b/cli/__tests__/ship-branch.test.mts @@ -398,6 +398,42 @@ describe('ship-branch.sh — --base ', () => { expect(git(['rev-parse', 'feat/fresh^']).trim()).toBe(advancedTip); // fetched, not the stale local }); + it('fails before creating a worktree when the base has a tighter size ceiling than the checkout', () => { + const { dir, env, git, bare } = seedShipRepoLocalRemote(); + mkdirSync(join(dir, 'src'), { recursive: true }); + mkdirSync(join(dir, 'eslint/baselines'), { recursive: true }); + writeFileSync( + join(dir, 'guard.config.json'), + JSON.stringify({ scanRoots: ['src'], sourceExtensions: ['ts'], maxLines: 50 }), + ); + writeFileSync(join(dir, 'src/hot.ts'), Array(60).fill('const x = 1;').join('\n')); + writeFileSync( + join(dir, 'eslint/baselines/size-lines.json'), + JSON.stringify({ maxLines: 50, files: { 'src/hot.ts': 60 } }), + ); + git(['add', 'guard.config.json', 'src/hot.ts', 'eslint/baselines/size-lines.json']); + git(['commit', '-q', '-m', 'size baseline']); + git(['push', '-q', 'origin', 'work:studio']); + git(['checkout', '-q', '-b', 'finalized']); + + writeFileSync(join(dir, 'src/hot.ts'), Array(70).fill('const x = 1;').join('\n')); + writeFileSync( + join(dir, 'eslint/baselines/size-lines.json'), + JSON.stringify({ maxLines: 50, files: { 'src/hot.ts': 80 } }), + ); + const r = spawnSync( + '/bin/bash', + [scriptPath, 'feat/size-preflight', 't', '--base', 'studio', '--', 'src/hot.ts'], + { cwd: join(dir, 'src'), input: '', encoding: 'utf8', env: { ...env, SHIP_DRY_RUN: '1' } }, + ); + + expect(r.status).toBe(1); + expect(r.stderr).toContain('working-tree baseline would allow 80'); + expect(localBranchExists(git, 'feat/size-preflight')).toBe(false); + expect(remoteBranchExists(bare, 'feat/size-preflight')).toBe(false); + expect(r.stderr).not.toMatch(EPHEMERAL_WT_RE); + }); + it('rejects a --base branch that does not exist on origin', () => { const { dir, env } = seedBaseRepo(); const r = spawnSync( @@ -1200,6 +1236,40 @@ describe('ship-branch.sh — overlay-mode gate chain', () => { }); describe('reship.sh (ship --pr) — overlay-mode gate chain', () => { + it('runs the base-aware size preflight before creating a reship worktree', () => { + const { dir, env, git } = seedReshipRepo(); + mkdirSync(join(dir, 'src'), { recursive: true }); + mkdirSync(join(dir, 'eslint/baselines'), { recursive: true }); + writeFileSync( + join(dir, 'guard.config.json'), + JSON.stringify({ scanRoots: ['src'], sourceExtensions: ['ts'], maxLines: 50 }), + ); + writeFileSync(join(dir, 'src/hot.ts'), Array(60).fill('const x = 1;').join('\n')); + writeFileSync( + join(dir, 'eslint/baselines/size-lines.json'), + JSON.stringify({ maxLines: 50, files: { 'src/hot.ts': 60 } }), + ); + git(['add', 'guard.config.json', 'src/hot.ts', 'eslint/baselines/size-lines.json']); + git(['commit', '-q', '-m', 'size baseline']); + git(['push', '-q', 'origin', 'work:pr-open']); + writeFileSync(join(dir, 'src/hot.ts'), Array(70).fill('const x = 1;').join('\n')); + writeFileSync( + join(dir, 'eslint/baselines/size-lines.json'), + JSON.stringify({ maxLines: 50, files: { 'src/hot.ts': 80 } }), + ); + + const r = spawnSync('/bin/bash', [reshipScript, 'pr-open', 't', 'src/hot.ts'], { + cwd: dir, + input: '', + encoding: 'utf8', + env: { ...env, SHIP_DRY_RUN: '1' }, + }); + + expect(r.status).toBe(1); + expect(r.stderr).toContain('working-tree baseline would allow 80'); + expect(r.stderr).not.toMatch(EPHEMERAL_WT_RE); + }); + it('forces ship mode when the caller inherits review mode', () => { const { dir, env, git } = seedReshipRepo(); writeFileSync( diff --git a/cli/lib/ship/prepare-gate-worktree.sh b/cli/lib/ship/prepare-gate-worktree.sh index f7e34ab5..12195395 100644 --- a/cli/lib/ship/prepare-gate-worktree.sh +++ b/cli/lib/ship/prepare-gate-worktree.sh @@ -334,3 +334,27 @@ prepare_gate_worktree() { refresh_ship_reviewer_assets "$wt" "$root" "$purpose" fi } + +# Preview the raw-line ratchet before creating a gate worktree. Exit 2 means the optional preview is +# unavailable; the authoritative worktree gate still runs. Exit 1 is a proven size violation. +ship_size_preflight() { + local root=${1:?root} base=${2:?base} size_guard rc + shift 2 + local script_dir + script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + size_guard="$script_dir/../../../gate-engine/ratchets/size-disable.mts" + [ -f "$size_guard" ] || size_guard="$script_dir/../../../gate-engine/ratchets/size-disable.mjs" + if [ ! -f "$size_guard" ]; then + rc=2 + elif (cd "$root" && node "$size_guard" preflight --base "$base" -- "$@"); then + rc=0 + else + rc=$? + fi + case "$rc" in + 0) return 0 ;; + 1) return 1 ;; + 2) echo "⚠️ ship: guard-size base-aware preflight unavailable — continuing to the authoritative worktree gate" >&2; return 0 ;; + *) echo "ship: guard-size preflight failed unexpectedly (exit $rc)" >&2; return 1 ;; + esac +} diff --git a/cli/lib/ship/reship.sh b/cli/lib/ship/reship.sh index 9301f38f..041d637e 100644 --- a/cli/lib/ship/reship.sh +++ b/cli/lib/ship/reship.sh @@ -85,6 +85,10 @@ DIST_INTEGRITY="$SCRIPT_DIR/dist-integrity.mts" [ -f "$DIST_INTEGRITY" ] || DIST_INTEGRITY="$SCRIPT_DIR/dist-integrity.mjs" node "$DIST_INTEGRITY" --root "$ROOT" --base "$BASE" -- "${PATHS[@]}" +# Re-pushes pay the same gate cost and can inherit the same stale checkout baseline as new ships. +. "$SCRIPT_DIR/prepare-gate-worktree.sh" +ship_size_preflight "$ROOT" "$BASE" "${PATHS[@]}" + WT="${TMPDIR:-/tmp}/devkit-reship-${BR//\//-}-$$" STAGED_STATE=$(mktemp "${TMPDIR:-/tmp}/reship-staged.XXXXXX") # Body: --body "" wins (explicit, no temp file); else use the same bounded stdin contract as @@ -148,7 +152,6 @@ ship_record_staged_state "$WT" "$STAGED_STATE" ship_assert_staged_objects_readable "$WT" "after staging" || { KEEP_WT=1; exit 1; } # Only after caller content is staged: runtime symlinks must never enter the shipped diff. -. "$(dirname "${BASH_SOURCE[0]}")/prepare-gate-worktree.sh" prepare_gate_worktree "$WT" "$ROOT" shipping ${LINK_DIRS[@]+"${LINK_DIRS[@]}"} # Link gate configs present in the repo but absent from this fresh checkout (an untracked config, a diff --git a/cli/lib/ship/ship-branch.sh b/cli/lib/ship/ship-branch.sh index 93729116..a6721c86 100644 --- a/cli/lib/ship/ship-branch.sh +++ b/cli/lib/ship/ship-branch.sh @@ -144,6 +144,10 @@ DIST_INTEGRITY="$SCRIPT_DIR/dist-integrity.mts" [ -f "$DIST_INTEGRITY" ] || DIST_INTEGRITY="$SCRIPT_DIR/dist-integrity.mjs" node "$DIST_INTEGRITY" --root "$ROOT" --base "$BASE" -- "${PATHS[@]}" +# Preview the raw-line ratchet against the exact base baseline BEFORE creating the worktree. +. "$SCRIPT_DIR/prepare-gate-worktree.sh" +ship_size_preflight "$ROOT" "$BASE" "${PATHS[@]}" + # Nothing to commit → say so NOW. Staging (below) has exactly three inputs: the tracked diff vs # BASE, the untracked files in scope, and the untracked-but-IGNORED files in scope (a briefed path # under a gitignored, force-tracked tree such as devkit's own dist/). All empty ⇒ an empty index — which git only reports AFTER the @@ -267,7 +271,6 @@ ship_record_staged_state "$WT" "$STAGED_STATE" ship_assert_staged_objects_readable "$WT" "after staging" || { KEEP_WT=1; exit 1; } # Only after caller content is staged: runtime symlinks must never enter the shipped diff. -. "$(dirname "${BASH_SOURCE[0]}")/prepare-gate-worktree.sh" prepare_gate_worktree "$WT" "$ROOT" shipping ${LINK_DIRS[@]+"${LINK_DIRS[@]}"} # Link gate configs that live in the repo but aren't in this fresh checkout (an untracked config, a diff --git a/dist/cli/lib/ship/prepare-gate-worktree.sh b/dist/cli/lib/ship/prepare-gate-worktree.sh index f7e34ab5..12195395 100644 --- a/dist/cli/lib/ship/prepare-gate-worktree.sh +++ b/dist/cli/lib/ship/prepare-gate-worktree.sh @@ -334,3 +334,27 @@ prepare_gate_worktree() { refresh_ship_reviewer_assets "$wt" "$root" "$purpose" fi } + +# Preview the raw-line ratchet before creating a gate worktree. Exit 2 means the optional preview is +# unavailable; the authoritative worktree gate still runs. Exit 1 is a proven size violation. +ship_size_preflight() { + local root=${1:?root} base=${2:?base} size_guard rc + shift 2 + local script_dir + script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + size_guard="$script_dir/../../../gate-engine/ratchets/size-disable.mts" + [ -f "$size_guard" ] || size_guard="$script_dir/../../../gate-engine/ratchets/size-disable.mjs" + if [ ! -f "$size_guard" ]; then + rc=2 + elif (cd "$root" && node "$size_guard" preflight --base "$base" -- "$@"); then + rc=0 + else + rc=$? + fi + case "$rc" in + 0) return 0 ;; + 1) return 1 ;; + 2) echo "⚠️ ship: guard-size base-aware preflight unavailable — continuing to the authoritative worktree gate" >&2; return 0 ;; + *) echo "ship: guard-size preflight failed unexpectedly (exit $rc)" >&2; return 1 ;; + esac +} diff --git a/dist/cli/lib/ship/reship.sh b/dist/cli/lib/ship/reship.sh index 9301f38f..041d637e 100644 --- a/dist/cli/lib/ship/reship.sh +++ b/dist/cli/lib/ship/reship.sh @@ -85,6 +85,10 @@ DIST_INTEGRITY="$SCRIPT_DIR/dist-integrity.mts" [ -f "$DIST_INTEGRITY" ] || DIST_INTEGRITY="$SCRIPT_DIR/dist-integrity.mjs" node "$DIST_INTEGRITY" --root "$ROOT" --base "$BASE" -- "${PATHS[@]}" +# Re-pushes pay the same gate cost and can inherit the same stale checkout baseline as new ships. +. "$SCRIPT_DIR/prepare-gate-worktree.sh" +ship_size_preflight "$ROOT" "$BASE" "${PATHS[@]}" + WT="${TMPDIR:-/tmp}/devkit-reship-${BR//\//-}-$$" STAGED_STATE=$(mktemp "${TMPDIR:-/tmp}/reship-staged.XXXXXX") # Body: --body "" wins (explicit, no temp file); else use the same bounded stdin contract as @@ -148,7 +152,6 @@ ship_record_staged_state "$WT" "$STAGED_STATE" ship_assert_staged_objects_readable "$WT" "after staging" || { KEEP_WT=1; exit 1; } # Only after caller content is staged: runtime symlinks must never enter the shipped diff. -. "$(dirname "${BASH_SOURCE[0]}")/prepare-gate-worktree.sh" prepare_gate_worktree "$WT" "$ROOT" shipping ${LINK_DIRS[@]+"${LINK_DIRS[@]}"} # Link gate configs present in the repo but absent from this fresh checkout (an untracked config, a diff --git a/dist/cli/lib/ship/ship-branch.sh b/dist/cli/lib/ship/ship-branch.sh index 93729116..a6721c86 100644 --- a/dist/cli/lib/ship/ship-branch.sh +++ b/dist/cli/lib/ship/ship-branch.sh @@ -144,6 +144,10 @@ DIST_INTEGRITY="$SCRIPT_DIR/dist-integrity.mts" [ -f "$DIST_INTEGRITY" ] || DIST_INTEGRITY="$SCRIPT_DIR/dist-integrity.mjs" node "$DIST_INTEGRITY" --root "$ROOT" --base "$BASE" -- "${PATHS[@]}" +# Preview the raw-line ratchet against the exact base baseline BEFORE creating the worktree. +. "$SCRIPT_DIR/prepare-gate-worktree.sh" +ship_size_preflight "$ROOT" "$BASE" "${PATHS[@]}" + # Nothing to commit → say so NOW. Staging (below) has exactly three inputs: the tracked diff vs # BASE, the untracked files in scope, and the untracked-but-IGNORED files in scope (a briefed path # under a gitignored, force-tracked tree such as devkit's own dist/). All empty ⇒ an empty index — which git only reports AFTER the @@ -267,7 +271,6 @@ ship_record_staged_state "$WT" "$STAGED_STATE" ship_assert_staged_objects_readable "$WT" "after staging" || { KEEP_WT=1; exit 1; } # Only after caller content is staged: runtime symlinks must never enter the shipped diff. -. "$(dirname "${BASH_SOURCE[0]}")/prepare-gate-worktree.sh" prepare_gate_worktree "$WT" "$ROOT" shipping ${LINK_DIRS[@]+"${LINK_DIRS[@]}"} # Link gate configs that live in the repo but aren't in this fresh checkout (an untracked config, a diff --git a/dist/gate-engine/ratchets/size-disable.mjs b/dist/gate-engine/ratchets/size-disable.mjs index 7c423113..3ebb2650 100644 --- a/dist/gate-engine/ratchets/size-disable.mjs +++ b/dist/gate-engine/ratchets/size-disable.mjs @@ -14,9 +14,9 @@ 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 { LINES_BASELINE, SIZE_SKIP_DIRS } from "./size-policy.mjs"; +import { runPreflightCli } from "./size-preflight.mjs"; const BASELINE = 'eslint/baselines/size.json'; -const LINES_BASELINE = 'eslint/baselines/size-lines.json'; -const SKIP_DIRS = new Set(['node_modules', 'dist', 'out', '__snapshots__', '_shared']); // Only an actual directive comment counts — a line that merely MENTIONS the phrase // (string literal, prose comment) must not inflate the ratchet and falsely block. const DIRECTIVE_START = /^\s*(?:\/\/|\/\*)\s*eslint-disable/; @@ -34,7 +34,7 @@ function walk(root, dir, files, match, includeTests = false) { for (const e of entries) { const rel = `${dir}/${e.name}`; if (e.isDirectory()) { - if (!SKIP_DIRS.has(e.name)) + if (!SIZE_SKIP_DIRS.has(e.name)) walk(root, rel, files, match, includeTests); } else if (match.isSource(e.name) && (includeTests || !match.isTest(e.name))) { @@ -390,10 +390,12 @@ function runCli(cmd) { runLinesGate(root, cfg, linesBaselineFile); process.exit(0); } - console.error('usage: guard-size '); + console.error('usage: guard-size [-- path...]>'); process.exit(2); } // Run as a CLI only when invoked directly; importing this module (tests) has no side effects. if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { + if (process.argv[2] === 'preflight') + runPreflightCli(process.argv.slice(3)); runCli(process.argv[2]); } diff --git a/dist/gate-engine/ratchets/size-policy.mjs b/dist/gate-engine/ratchets/size-policy.mjs new file mode 100644 index 00000000..8f71600f --- /dev/null +++ b/dist/gate-engine/ratchets/size-policy.mjs @@ -0,0 +1,2 @@ +export const LINES_BASELINE = 'eslint/baselines/size-lines.json'; +export const SIZE_SKIP_DIRS = new Set(['node_modules', 'dist', 'out', '__snapshots__', '_shared']); diff --git a/dist/gate-engine/ratchets/size-preflight.mjs b/dist/gate-engine/ratchets/size-preflight.mjs new file mode 100644 index 00000000..298931f4 --- /dev/null +++ b/dist/gate-engine/ratchets/size-preflight.mjs @@ -0,0 +1,120 @@ +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 { LINES_BASELINE, SIZE_SKIP_DIRS } from "./size-policy.mjs"; +function readLinesBaseline(file) { + if (!existsSync(file)) + return { files: {} }; + const parsed = JSON.parse(readFileSync(file, 'utf8')); + return { files: parsed.files ?? {} }; +} +function readLinesBaselineAtRef(root, ref) { + execFileSync('git', ['rev-parse', '--verify', `${ref}^{commit}`], { + cwd: root, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const prefix = execFileSync('git', ['rev-parse', '--show-prefix'], { + cwd: root, + encoding: 'utf8', + }).trim(); + let text; + try { + text = execFileSync('git', ['show', `${ref}:${prefix}${LINES_BASELINE}`], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } + catch { + return null; + } + const parsed = JSON.parse(text); + return { files: parsed.files ?? {} }; +} +function sourcePaths(root, cfg, selected) { + const match = sourceMatchers(cfg.sourceExtensions); + return selected.filter((file) => existsSync(join(root, file)) && + match.isSource(file) && + !file.split('/').some((part) => SIZE_SKIP_DIRS.has(part)) && + cfg.scanRoots.some((scanRoot) => file === scanRoot || file.startsWith(`${scanRoot}/`))); +} +// Compare the caller's current bytes with the raw-line baseline from the exact ref a ship will use. +// A missing ref baseline is an overlay/untracked baseline, which ship links from the working copy. +export function preflightLines(root, ref, requested = []) { + let cfg; + let local; + try { + cfg = resolveGuardConfig(root); + if (!cfg.maxLines && !cfg.maxTestLines) + return 0; + local = readLinesBaseline(join(root, LINES_BASELINE)); + } + catch (error) { + console.error(`guard-size preflight unavailable: ${String(error)}`); + return 2; + } + let committed; + try { + committed = readLinesBaselineAtRef(root, ref); + } + catch (error) { + console.error(`guard-size preflight unavailable at ${ref}: ${String(error)}`); + return 2; + } + const match = sourceMatchers(cfg.sourceExtensions); + const cap = (file) => (match.isTest(file) ? cfg.maxTestLines : cfg.maxLines); + const selected = requested.length > 0 ? requested : [...(stagedSet(root) ?? [])]; + const baselineIncluded = selected.includes(LINES_BASELINE); + const files = sourcePaths(root, cfg, selected).filter((file) => cap(file) > 0); + if (files.length === 0) { + if (requested.length === 0) { + console.error('guard-size preflight: no staged source files (pass paths after `--`).'); + return 2; + } + console.log(`guard-size preflight: no source files in scope: ${requested.join(', ')}`); + return 0; + } + const usesWorkingBaseline = baselineIncluded || !committed; + const baseline = usesWorkingBaseline ? local : (committed ?? local); + const baselineLabel = usesWorkingBaseline ? 'working tree' : ref; + let rows; + try { + rows = files.map((file) => { + const lines = readFileSync(join(root, file), 'utf8').split('\n').length; + const ceiling = Math.max(cap(file), baseline.files[file] ?? 0); + const localCeiling = Math.max(cap(file), local.files[file] ?? 0); + return { file, lines, ceiling, headroom: ceiling - lines, localCeiling }; + }); + } + catch (error) { + console.error(`guard-size preflight unavailable while reading source files: ${String(error)}`); + return 2; + } + console.log(`guard-size preflight — effective ceilings from ${baselineLabel}`); + for (const row of rows) { + const drift = committed && row.localCeiling !== row.ceiling + ? `; working-tree max ${row.localCeiling} differs by ${row.localCeiling - row.ceiling}` + : ''; + console.log(` ${row.file}: ${row.lines} lines; max ${row.ceiling}; headroom ${row.headroom}${drift}`); + } + const grew = rows.filter((row) => row.headroom < 0); + if (grew.length === 0) + return 0; + console.error(`🚫 ${grew.length} file(s) exceed the line limit from ${baselineLabel}:`); + for (const row of grew) { + const drift = committed && row.localCeiling !== row.ceiling + ? `; working-tree baseline would allow ${row.localCeiling}` + : ''; + console.error(` ${row.file}: ${row.lines} lines (max ${row.ceiling}${drift})`); + } + return 1; +} +export function runPreflightCli(args) { + if (args[0] !== '--base' || !args[1] || (args.length > 2 && args[2] !== '--')) { + console.error('usage: guard-size preflight --base [-- path...]'); + process.exit(2); + } + process.exit(preflightLines(process.cwd(), args[1], args.slice(3))); +} diff --git a/dist/skills/using-devkit/SKILL.md b/dist/skills/using-devkit/SKILL.md index 304e3da4..7e428617 100644 --- a/dist/skills/using-devkit/SKILL.md +++ b/dist/skills/using-devkit/SKILL.md @@ -37,6 +37,7 @@ devkit command. | You observe (trigger) | Run | Why not the raw-git move | |---|---|---| | A commit was **denied on a protected branch**, or you're on `main`/`master` and need to land a change | `devkit ship "" -- <paths>` | `git switch -c` + commit + push **moves the shared checkout's HEAD**, disturbing parallel agents; `ship` commits in an ephemeral worktree and opens a PR without moving HEAD | +| You need to preview a hot file's real line ceiling before shipping | `guard-size preflight --base origin/<branch> -- <paths>` | It reads `size-lines.json` from the requested base, prints current lines / effective cap / headroom, and names any stale working-tree baseline. `devkit ship` runs the same preflight automatically before creating its gate worktree. | | The PR must target a branch **other than the one you're on** — e.g. your work is already committed on a source branch and the base is a different one | `devkit ship <branch> "<title>" --base <base-branch> -- <paths>` (branch + title FIRST — see Rules) | plain `ship` bases on this checkout's HEAD, where those paths are already identical, so it stages nothing and aborts `nothing to commit`; `--base` diffs your **working tree** against `origin/<base-branch>` and targets the PR there — no checkout, no worktree juggling | | You're in a **linked worktree, already on a branch**, and need a PR | `devkit ship <new-branch> "<title>" --base <base> -- <paths>` | you don't need — and must not create — another branch: `ship` makes the PR branch itself, and a branch that already exists is the one state it cannot recover from | | Ship reports the branch **already exists on origin** (an open PR uses it) | `devkit ship <branch> "<title>" --pr -- <paths>` | picking a new name orphans the existing PR; `--pr` fast-forwards a new commit onto that branch instead | diff --git a/gate-engine/judge/__tests__/diff-focus.test.mts b/gate-engine/judge/__tests__/diff-focus.test.mts index b601173a..38cada6c 100644 --- a/gate-engine/judge/__tests__/diff-focus.test.mts +++ b/gate-engine/judge/__tests__/diff-focus.test.mts @@ -233,7 +233,7 @@ describe('diffCacheIdentity — conservative matching + function anchors', () => "import { captureException } from '../utils/presentry-shim';", ], ['a nested call smuggled as the argument', 'Sentry.captureException(mutateGlobalState(err));'], - ['a template-literal argument', 'Sentry.captureMessage(`${sideEffect()}`);'], + ['a template-literal argument', ['Sentry.captureMessage(`', '$', '{sideEffect()}`);'].join('')], ])('does NOT strip %s — the restage re-reviews', (_label, line) => { const fixed = edit(10, ['handle();']); const d1 = gitDiffOf(BASE, fixed); diff --git a/gate-engine/ratchets/__tests__/size-preflight.test.mts b/gate-engine/ratchets/__tests__/size-preflight.test.mts new file mode 100644 index 00000000..4732784f --- /dev/null +++ b/gate-engine/ratchets/__tests__/size-preflight.test.mts @@ -0,0 +1,167 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), '..', 'size-disable.mts'); +const roots: string[] = []; + +function makeRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'size-preflight-')); + roots.push(root); + execFileSync('git', ['init', '-q'], { cwd: root }); + execFileSync('git', ['config', 'user.email', 't@t.t'], { cwd: root }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: root }); + return root; +} + +function write(root: string, rel: string, content: string): void { + mkdirSync(join(root, dirname(rel)), { recursive: true }); + writeFileSync(join(root, rel), content); +} + +function big(lines: number): string { + return Array(lines).fill('const x = 1;').join('\n'); +} + +function seedBaseline(root: string): void { + write( + root, + 'guard.config.json', + JSON.stringify({ scanRoots: ['src'], sourceExtensions: ['ts'], maxLines: 50 }), + ); + write(root, 'src/legacy.ts', big(60)); + write( + root, + 'eslint/baselines/size-lines.json', + JSON.stringify({ maxLines: 50, files: { 'src/legacy.ts': 60 } }), + ); + execFileSync( + 'git', + ['add', 'guard.config.json', 'src/legacy.ts', 'eslint/baselines/size-lines.json'], + { cwd: root }, + ); + execFileSync('git', ['commit', '-qm', 'base'], { cwd: root }); +} + +function run(root: string, ...args: string[]) { + return spawnSync(process.execPath, [SCRIPT, ...args], { cwd: root, encoding: 'utf8' }); +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('guard-size base-aware preflight', () => { + it('rejects growth hidden by a stale working-tree baseline', () => { + const root = makeRoot(); + seedBaseline(root); + write(root, 'src/legacy.ts', big(70)); + write( + root, + 'eslint/baselines/size-lines.json', + JSON.stringify({ maxLines: 50, files: { 'src/legacy.ts': 80 } }), + ); + + const result = run(root, 'preflight', '--base', 'HEAD', '--', 'src/legacy.ts'); + + expect(result.status).toBe(1); + expect(result.stdout).toContain('max 60; headroom -10; working-tree max 80 differs by 20'); + expect(result.stderr).toContain('working-tree baseline would allow 80'); + }); + + it('defaults to staged source files and prints remaining headroom', () => { + const root = makeRoot(); + seedBaseline(root); + write(root, 'src/legacy.ts', big(55)); + execFileSync('git', ['add', 'src/legacy.ts'], { cwd: root }); + + const result = run(root, 'preflight', '--base', 'HEAD'); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain('src/legacy.ts: 55 lines; max 60; headroom 5'); + }); + + it('ignores files governed by a disabled zero cap', () => { + const root = makeRoot(); + write( + root, + 'guard.config.json', + JSON.stringify({ + scanRoots: ['src'], + sourceExtensions: ['ts'], + maxLines: 50, + maxTestLines: 0, + }), + ); + write(root, 'src/example.test.ts', big(5)); + execFileSync('git', ['add', '.'], { cwd: root }); + execFileSync('git', ['commit', '-qm', 'base'], { cwd: root }); + + const result = run(root, 'preflight', '--base', 'HEAD', '--', 'src/example.test.ts'); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain('no source files in scope: src/example.test.ts'); + }); + + it('uses a working baseline that is explicitly included in the ship', () => { + const root = makeRoot(); + seedBaseline(root); + write(root, 'src/new.ts', big(70)); + write( + root, + 'eslint/baselines/size-lines.json', + JSON.stringify({ maxLines: 50, files: { 'src/legacy.ts': 60, 'src/new.ts': 70 } }), + ); + + const result = run( + root, + 'preflight', + '--base', + 'HEAD', + '--', + 'src/new.ts', + 'eslint/baselines/size-lines.json', + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain('effective ceilings from working tree'); + expect(result.stdout).toContain('src/new.ts: 70 lines; max 70; headroom 0'); + }); + + it('matches the gate by excluding skipped directories and names unmatched paths', () => { + const root = makeRoot(); + write( + root, + 'guard.config.json', + JSON.stringify({ scanRoots: ['src'], sourceExtensions: ['ts'], maxLines: 50 }), + ); + write(root, 'src/_shared/big.ts', big(70)); + execFileSync('git', ['add', '.'], { cwd: root }); + execFileSync('git', ['commit', '-qm', 'base'], { cwd: root }); + + const result = run(root, 'preflight', '--base', 'HEAD', '--', 'src/_shared/big.ts'); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain('no source files in scope: src/_shared/big.ts'); + }); + + it('classifies source read failures as unavailable instead of a size violation', () => { + const root = makeRoot(); + write( + root, + 'guard.config.json', + JSON.stringify({ scanRoots: ['src'], sourceExtensions: ['ts'], maxLines: 50 }), + ); + execFileSync('git', ['add', 'guard.config.json'], { cwd: root }); + execFileSync('git', ['commit', '-qm', 'base'], { cwd: root }); + mkdirSync(join(root, 'src/unreadable.ts'), { recursive: true }); + + const result = run(root, 'preflight', '--base', 'HEAD', '--', 'src/unreadable.ts'); + + expect(result.status).toBe(2); + expect(result.stderr).toContain('preflight unavailable while reading source files'); + }); +}); diff --git a/gate-engine/ratchets/size-disable.mts b/gate-engine/ratchets/size-disable.mts index b1c976d8..400d33f6 100755 --- a/gate-engine/ratchets/size-disable.mts +++ b/gate-engine/ratchets/size-disable.mts @@ -24,6 +24,8 @@ import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { CONFIG_FILENAME, resolveGuardConfig, sourceMatchers } from '../config.mts'; import { hasStagedFiles, stageBaseline, stagedSet } from './git-index.mts'; +import { LINES_BASELINE, SIZE_SKIP_DIRS } from './size-policy.mts'; +import { runPreflightCli } from './size-preflight.mts'; // The impl-file predicate bundle sourceMatchers() returns (isSource/isTest/isBarrel). type SourceMatchers = ReturnType<typeof sourceMatchers>; @@ -40,8 +42,7 @@ interface DisableCount { file: number; // `eslint-disable max-lines` directives in the file fn: number; // `eslint-disable max-lines-per-function` directives } -// Per-file disable baseline so only staged-file shrink auto-lowers during a commit. -// A pre-per-file `{ fileDisables, fnDisables }` baseline (no `files` key) is treated as NO +// Per-file disable baseline; a pre-per-file `{ fileDisables, fnDisables }` baseline (no `files` key) is NO // grandfathered debt and self-deleted on the next commit; the owner re-freezes once // (`guard-size freeze`, or `devkit upgrade`) to re-grandfather any real disables. Baselines are // regenerable state, so no dual-format gate is carried. @@ -57,8 +58,6 @@ interface LinesBaseline { } const BASELINE = 'eslint/baselines/size.json'; -const LINES_BASELINE = 'eslint/baselines/size-lines.json'; -const SKIP_DIRS = new Set(['node_modules', 'dist', 'out', '__snapshots__', '_shared']); // Only an actual directive comment counts — a line that merely MENTIONS the phrase // (string literal, prose comment) must not inflate the ratchet and falsely block. const DIRECTIVE_START = /^\s*(?:\/\/|\/\*)\s*eslint-disable/; @@ -82,7 +81,7 @@ function walk( for (const e of entries) { const rel = `${dir}/${e.name}`; if (e.isDirectory()) { - if (!SKIP_DIRS.has(e.name)) walk(root, rel, files, match, includeTests); + if (!SIZE_SKIP_DIRS.has(e.name)) walk(root, rel, files, match, includeTests); } else if (match.isSource(e.name) && (includeTests || !match.isTest(e.name))) { files.push(rel); } @@ -488,11 +487,12 @@ function runCli(cmd: string): void { process.exit(0); } - console.error('usage: guard-size <freeze|gate>'); + console.error('usage: guard-size <freeze|gate|preflight --base <ref> [-- path...]>'); process.exit(2); } // Run as a CLI only when invoked directly; importing this module (tests) has no side effects. if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { + if (process.argv[2] === 'preflight') runPreflightCli(process.argv.slice(3)); runCli(process.argv[2]); } diff --git a/gate-engine/ratchets/size-policy.mts b/gate-engine/ratchets/size-policy.mts new file mode 100644 index 00000000..8f71600f --- /dev/null +++ b/gate-engine/ratchets/size-policy.mts @@ -0,0 +1,2 @@ +export const LINES_BASELINE = 'eslint/baselines/size-lines.json'; +export const SIZE_SKIP_DIRS = new Set(['node_modules', 'dist', 'out', '__snapshots__', '_shared']); diff --git a/gate-engine/ratchets/size-preflight.mts b/gate-engine/ratchets/size-preflight.mts new file mode 100644 index 00000000..a1b4dbac --- /dev/null +++ b/gate-engine/ratchets/size-preflight.mts @@ -0,0 +1,146 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { resolveGuardConfig, sourceMatchers } from '../config.mts'; +import { stagedSet } from './git-index.mts'; +import { LINES_BASELINE, SIZE_SKIP_DIRS } from './size-policy.mts'; + +interface LinesBaseline { + files: Record<string, number>; +} + +interface LinesPreflightRow { + file: string; + lines: number; + ceiling: number; + headroom: number; + localCeiling: number; +} + +function readLinesBaseline(file: string): LinesBaseline { + if (!existsSync(file)) return { files: {} }; + const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<LinesBaseline>; + return { files: parsed.files ?? {} }; +} + +function readLinesBaselineAtRef(root: string, ref: string): LinesBaseline | null { + execFileSync('git', ['rev-parse', '--verify', `${ref}^{commit}`], { + cwd: root, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const prefix = execFileSync('git', ['rev-parse', '--show-prefix'], { + cwd: root, + encoding: 'utf8', + }).trim(); + let text: string; + try { + text = execFileSync('git', ['show', `${ref}:${prefix}${LINES_BASELINE}`], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return null; + } + const parsed = JSON.parse(text) as Partial<LinesBaseline>; + return { files: parsed.files ?? {} }; +} + +function sourcePaths( + root: string, + cfg: ReturnType<typeof resolveGuardConfig>, + selected: string[], +): string[] { + const match = sourceMatchers(cfg.sourceExtensions); + return selected.filter( + (file) => + existsSync(join(root, file)) && + match.isSource(file) && + !file.split('/').some((part) => SIZE_SKIP_DIRS.has(part)) && + cfg.scanRoots.some( + (scanRoot: string) => file === scanRoot || file.startsWith(`${scanRoot}/`), + ), + ); +} + +// Compare the caller's current bytes with the raw-line baseline from the exact ref a ship will use. +// A missing ref baseline is an overlay/untracked baseline, which ship links from the working copy. +export function preflightLines(root: string, ref: string, requested: string[] = []): number { + let cfg: ReturnType<typeof resolveGuardConfig>; + let local: LinesBaseline; + try { + cfg = resolveGuardConfig(root); + if (!cfg.maxLines && !cfg.maxTestLines) return 0; + local = readLinesBaseline(join(root, LINES_BASELINE)); + } catch (error) { + console.error(`guard-size preflight unavailable: ${String(error)}`); + return 2; + } + let committed: LinesBaseline | null; + try { + committed = readLinesBaselineAtRef(root, ref); + } catch (error) { + console.error(`guard-size preflight unavailable at ${ref}: ${String(error)}`); + return 2; + } + const match = sourceMatchers(cfg.sourceExtensions); + const cap = (file: string) => (match.isTest(file) ? cfg.maxTestLines : cfg.maxLines); + const selected = requested.length > 0 ? requested : [...(stagedSet(root) ?? [])]; + const baselineIncluded = selected.includes(LINES_BASELINE); + const files = sourcePaths(root, cfg, selected).filter((file) => cap(file) > 0); + if (files.length === 0) { + if (requested.length === 0) { + console.error('guard-size preflight: no staged source files (pass paths after `--`).'); + return 2; + } + console.log(`guard-size preflight: no source files in scope: ${requested.join(', ')}`); + return 0; + } + + const usesWorkingBaseline = baselineIncluded || !committed; + const baseline: LinesBaseline = usesWorkingBaseline ? local : (committed ?? local); + const baselineLabel = usesWorkingBaseline ? 'working tree' : ref; + let rows: LinesPreflightRow[]; + try { + rows = files.map((file) => { + const lines = readFileSync(join(root, file), 'utf8').split('\n').length; + const ceiling = Math.max(cap(file), baseline.files[file] ?? 0); + const localCeiling = Math.max(cap(file), local.files[file] ?? 0); + return { file, lines, ceiling, headroom: ceiling - lines, localCeiling }; + }); + } catch (error) { + console.error(`guard-size preflight unavailable while reading source files: ${String(error)}`); + return 2; + } + + console.log(`guard-size preflight — effective ceilings from ${baselineLabel}`); + for (const row of rows) { + const drift = + committed && row.localCeiling !== row.ceiling + ? `; working-tree max ${row.localCeiling} differs by ${row.localCeiling - row.ceiling}` + : ''; + console.log( + ` ${row.file}: ${row.lines} lines; max ${row.ceiling}; headroom ${row.headroom}${drift}`, + ); + } + + const grew = rows.filter((row) => row.headroom < 0); + if (grew.length === 0) return 0; + console.error(`🚫 ${grew.length} file(s) exceed the line limit from ${baselineLabel}:`); + for (const row of grew) { + const drift = + committed && row.localCeiling !== row.ceiling + ? `; working-tree baseline would allow ${row.localCeiling}` + : ''; + console.error(` ${row.file}: ${row.lines} lines (max ${row.ceiling}${drift})`); + } + return 1; +} + +export function runPreflightCli(args: string[]): never { + if (args[0] !== '--base' || !args[1] || (args.length > 2 && args[2] !== '--')) { + console.error('usage: guard-size preflight --base <ref> [-- path...]'); + process.exit(2); + } + process.exit(preflightLines(process.cwd(), args[1], args.slice(3))); +} diff --git a/skills/using-devkit/SKILL.md b/skills/using-devkit/SKILL.md index 304e3da4..7e428617 100644 --- a/skills/using-devkit/SKILL.md +++ b/skills/using-devkit/SKILL.md @@ -37,6 +37,7 @@ devkit command. | You observe (trigger) | Run | Why not the raw-git move | |---|---|---| | A commit was **denied on a protected branch**, or you're on `main`/`master` and need to land a change | `devkit ship <branch> "<title>" -- <paths>` | `git switch -c` + commit + push **moves the shared checkout's HEAD**, disturbing parallel agents; `ship` commits in an ephemeral worktree and opens a PR without moving HEAD | +| You need to preview a hot file's real line ceiling before shipping | `guard-size preflight --base origin/<branch> -- <paths>` | It reads `size-lines.json` from the requested base, prints current lines / effective cap / headroom, and names any stale working-tree baseline. `devkit ship` runs the same preflight automatically before creating its gate worktree. | | The PR must target a branch **other than the one you're on** — e.g. your work is already committed on a source branch and the base is a different one | `devkit ship <branch> "<title>" --base <base-branch> -- <paths>` (branch + title FIRST — see Rules) | plain `ship` bases on this checkout's HEAD, where those paths are already identical, so it stages nothing and aborts `nothing to commit`; `--base` diffs your **working tree** against `origin/<base-branch>` and targets the PR there — no checkout, no worktree juggling | | You're in a **linked worktree, already on a branch**, and need a PR | `devkit ship <new-branch> "<title>" --base <base> -- <paths>` | you don't need — and must not create — another branch: `ship` makes the PR branch itself, and a branch that already exists is the one state it cannot recover from | | Ship reports the branch **already exists on origin** (an open PR uses it) | `devkit ship <branch> "<title>" --pr -- <paths>` | picking a new name orphans the existing PR; `--pr` fast-forwards a new commit onto that branch instead |