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
70 changes: 70 additions & 0 deletions cli/__tests__/ship-branch.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,42 @@ describe('ship-branch.sh — --base <branch>', () => {
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(
Expand Down Expand Up @@ -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(
Expand Down
24 changes: 24 additions & 0 deletions cli/lib/ship/prepare-gate-worktree.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
5 changes: 4 additions & 1 deletion cli/lib/ship/reship.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<text>" wins (explicit, no temp file); else use the same bounded stdin contract as
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion cli/lib/ship/ship-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions dist/cli/lib/ship/prepare-gate-worktree.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
5 changes: 4 additions & 1 deletion dist/cli/lib/ship/reship.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<text>" wins (explicit, no temp file); else use the same bounded stdin contract as
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion dist/cli/lib/ship/ship-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions dist/gate-engine/ratchets/size-disable.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/;
Expand All @@ -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))) {
Expand Down Expand Up @@ -390,10 +390,12 @@ function runCli(cmd) {
runLinesGate(root, cfg, linesBaselineFile);
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]);
}
2 changes: 2 additions & 0 deletions dist/gate-engine/ratchets/size-policy.mjs
Original file line number Diff line number Diff line change
@@ -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']);
120 changes: 120 additions & 0 deletions dist/gate-engine/ratchets/size-preflight.mjs
Original file line number Diff line number Diff line change
@@ -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 <ref> [-- path...]');
process.exit(2);
}
process.exit(preflightLines(process.cwd(), args[1], args.slice(3)));
}
1 change: 1 addition & 0 deletions dist/skills/using-devkit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading