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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,16 @@ worktree_base = "/tmp/gate-worktrees"
# build.typecheck_cmd = "npx tsc --noEmit"
# build.lint_cmd = "npm run lint:check"
# build.test_cmd = "npm run test:run"
# build.typecheck_cmds = ["npm run type-check", "npm run api:type-check"]
# build.lint_cmds = ["npm run lint:check", "npm run api:lint"]
# build.test_cmds = ["npm run --workspace frontend test:run", "npm run api:test"]
# build.scoped_lint_cmd = "npm exec eslint --" # optional app-local scoped lint
# build.scoped_lint_cwd = "apps/frontend"
# build.source_root = "apps/frontend and apps/api" # monorepo source roots, if applicable
# build.test_dir = "apps/frontend for frontend tests; apps/api for API tests"

# Use singular *_cmd keys for one command, or plural *_cmds arrays
# when a monorepo needs several runtimes checked in sequence.

# Per-repo limit overrides:
# limits.max_fix_attempts_total = 0
Expand Down
4 changes: 4 additions & 0 deletions config/fix-blocklist.example.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
# tsconfig.json
# src/db/schema/**
# drizzle/**
# Monorepo apps should use root-relative paths, e.g.
# apps/web/package.json
# apps/web/src/db/schema/**
# apps/web/drizzle/**

# Python
# pyproject.toml
Expand Down
20 changes: 20 additions & 0 deletions config/gate.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,23 @@ postconditions_max_functions = 10
# [repos.build]
# verify_cmd = "dafny verify {FILE}" # Dafny example
# verify_cmd = "verus --crate-type=lib {FILE}" # Verus example

# --- Monorepo/application layout (per-repo) ---
# For npm workspaces or other monorepos, keep clone_path at the repo root
# and override commands/layout here. Gate still computes diffs from the
# root, while prompts tell agents where the app code and temporary Gate
# tests live.
#
# [repos.build]
# typecheck_cmds = ["npm run type-check", "npm run api:type-check"]
# lint_cmds = ["npm run lint:check", "npm run api:lint"]
# scoped_lint_cmd = "npm exec eslint --"
# scoped_lint_cwd = "apps/frontend"
# test_cmds = ["npm run --workspace frontend test:run", "npm run api:test"]
# dep_install_cmd = "npm ci"
# dep_file = "package-lock.json"
# source_root = "apps/frontend and apps/api"
# test_dir = "apps/frontend for frontend tests; apps/api for API tests"
#
# Use singular *_cmd keys for one command, or plural *_cmds arrays when a
# monorepo needs several runtimes checked in sequence.
121 changes: 61 additions & 60 deletions gate/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ def run_build(worktree: Path, config: dict | None = None) -> dict:
profile = profiles.resolve_profile(repo_cfg, worktree)
project_type = profile.get("project_type", "none")

typecheck_cmd = profile.get("typecheck_cmd", "")
lint_cmd = profile.get("lint_cmd", "")
test_cmd = profile.get("test_cmd", "")
typecheck_cmds = profiles.command_list(profile, "typecheck_cmd")
lint_cmds = profiles.command_list(profile, "lint_cmd")
test_cmds = profiles.command_list(profile, "test_cmd")

if not typecheck_cmd and not lint_cmd and not test_cmd:
if not typecheck_cmds and not lint_cmds and not test_cmds:
logger.info(f"No build commands for {worktree} (project_type={project_type}), skipping")
return {
"typecheck": {"pass": True, "errors": [], "error_count": 0, "tool": ""},
Expand All @@ -53,63 +53,13 @@ def run_build(worktree: Path, config: dict | None = None) -> dict:

build_timeout = 300

if typecheck_cmd:
tc_args = shlex.split(typecheck_cmd)
try:
tc_result = subprocess.run(
tc_args,
capture_output=True, text=True, cwd=cwd, timeout=build_timeout,
)
except subprocess.TimeoutExpired:
logger.warning(
f"typecheck timed out after {build_timeout}s in {cwd} "
f"(cmd: {typecheck_cmd})"
)
tc_result = subprocess.CompletedProcess(
tc_args, 1, stdout="", stderr=f"typecheck timed out after {build_timeout}s",
)
else:
tc_result = subprocess.CompletedProcess([], 0, stdout="", stderr="")

if lint_cmd:
lint_args = shlex.split(lint_cmd)
try:
lint_result = subprocess.run(
lint_args,
capture_output=True, text=True, cwd=cwd, timeout=build_timeout,
)
except subprocess.TimeoutExpired:
logger.warning(
f"lint timed out after {build_timeout}s in {cwd} "
f"(cmd: {lint_cmd})"
)
lint_result = subprocess.CompletedProcess(
lint_args, 1, stdout="", stderr=f"lint timed out after {build_timeout}s",
)
else:
lint_result = subprocess.CompletedProcess([], 0, stdout="", stderr="")

if test_cmd:
test_args = shlex.split(test_cmd)
try:
test_result = subprocess.run(
test_args,
capture_output=True, text=True, cwd=cwd, timeout=build_timeout,
)
except subprocess.TimeoutExpired:
logger.warning(
f"tests timed out after {build_timeout}s in {cwd} "
f"(cmd: {test_cmd})"
)
test_result = subprocess.CompletedProcess(
test_args, 1, stdout="", stderr=f"tests timed out after {build_timeout}s",
)
else:
test_result = subprocess.CompletedProcess([], 0, stdout="", stderr="")
tc_result = run_command_group(typecheck_cmds, cwd, build_timeout, "typecheck")
lint_result = run_command_group(lint_cmds, cwd, build_timeout, "lint")
test_result = run_command_group(test_cmds, cwd, build_timeout, "tests")

tc_tool = shlex.split(typecheck_cmd)[0] if typecheck_cmd else ""
lint_tool = shlex.split(lint_cmd)[0] if lint_cmd else ""
test_tool = shlex.split(test_cmd)[0] if test_cmd else ""
tc_tool = command_group_tool(typecheck_cmds)
lint_tool = command_group_tool(lint_cmds)
test_tool = command_group_tool(test_cmds)

return compile_build(
typecheck_log=tc_result.stdout + tc_result.stderr,
Expand All @@ -125,6 +75,57 @@ def run_build(worktree: Path, config: dict | None = None) -> dict:
)


def command_group_tool(cmds: list[str]) -> str:
"""Best-effort tool label for a command group."""
if not cmds:
return ""
try:
return shlex.split(cmds[0])[0]
except ValueError:
return ""


def run_command_group(
cmds: list[str],
cwd: str,
timeout: int,
label: str,
) -> subprocess.CompletedProcess:
"""Run one or more commands sequentially without invoking a shell."""
if not cmds:
return subprocess.CompletedProcess([], 0, stdout="", stderr="")

outputs: list[str] = []
first_nonzero = 0
for cmd in cmds:
args: list[str] = []
try:
args = shlex.split(cmd)
result = subprocess.run(
args,
capture_output=True, text=True, cwd=cwd, timeout=timeout,
)
except subprocess.TimeoutExpired:
logger.warning(
f"{label} timed out after {timeout}s in {cwd} "
f"(cmd: {cmd})"
)
result = subprocess.CompletedProcess(
args, 1, stdout="", stderr=f"{label} timed out after {timeout}s",
)
except (OSError, ValueError) as exc:
result = subprocess.CompletedProcess(
args, 1, stdout="", stderr=f"{label} failed to start: {exc}",
)
outputs.append(f"$ {cmd}\n{result.stdout}{result.stderr}")
if result.returncode != 0 and first_nonzero == 0:
first_nonzero = result.returncode

return subprocess.CompletedProcess(
cmds, first_nonzero, stdout="\n".join(outputs), stderr=""
)


def compile_build(
typecheck_log: str,
typecheck_exit: int,
Expand Down
29 changes: 24 additions & 5 deletions gate/checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,23 @@ def _lint_family(tool: str) -> str | None:
return None


def _lint_family_from_tokens(tokens: list[str]) -> str | None:
"""Return the linter family mentioned anywhere in a command argv."""
for token in tokens:
family = _lint_family(token)
if family is not None:
return family
return None


def _paths_for_command_cwd(files: list[str], cwd: str) -> list[str]:
"""Translate root-relative paths for a command run from a subdirectory."""
if not cwd or cwd == ".":
return files
prefix = cwd.rstrip("/") + "/"
return [f.removeprefix(prefix) for f in files if f == cwd or f.startswith(prefix)]


def _scoped_lint(workspace: Path, config: dict, files: list[str]) -> tuple[int, str]:
"""Run lint scoped to ``files`` where the linter supports it.

Expand All @@ -306,25 +323,27 @@ def _scoped_lint(workspace: Path, config: dict, files: list[str]) -> tuple[int,

repo_cfg = (config or {}).get("repo", {})
profile = profiles.resolve_profile(repo_cfg, workspace)
lint_cmd = profile.get("lint_cmd", "")
lint_cmd = profile.get("scoped_lint_cmd") or profile.get("lint_cmd", "")
if not lint_cmd or not files:
return 0, ""

tokens = shlex.split(lint_cmd)
tool = tokens[0] if tokens else ""
family = _lint_family(tool)
family = _lint_family_from_tokens(tokens)
if family is None:
# Unknown linter — just run the full command.
out, exit_code = _run_silent(lint_cmd, cwd=str(workspace))
return exit_code, out[-4000:]

command_cwd = profile.get("scoped_lint_cwd", "")
lint_files = _paths_for_command_cwd(files, command_cwd)
allowed = _LINT_EXTS[family]
filtered = [f for f in files if f.endswith(allowed)]
filtered = [f for f in lint_files if f.endswith(allowed)]
if not filtered:
return 0, ""

scoped = tokens + filtered
out, exit_code = _run_silent(scoped, cwd=str(workspace))
cwd = workspace / command_cwd if command_cwd else workspace
out, exit_code = _run_silent(scoped, cwd=str(cwd))
return exit_code, out[-4000:]


Expand Down
5 changes: 5 additions & 0 deletions gate/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ def run_code_stage(
profile = profiles.resolve_profile(repo_config, workspace_path)
vars_dict = {"request": request}
vars_dict.update({k: v for k, v in profile.items() if isinstance(v, str)})
vars_dict.update({
"typecheck_cmd": profiles.command_display(profile, "typecheck_cmd"),
"lint_cmd": profiles.command_display(profile, "lint_cmd"),
"test_cmd": profiles.command_display(profile, "test_cmd"),
})
prompt_text = safe_substitute(template, vars_dict, f"gate-code-{stage}")

version = _next_version(workspace, stage)
Expand Down
35 changes: 16 additions & 19 deletions gate/fixer.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,11 @@ def build_verify(
profile = profiles.resolve_profile(repo_cfg, workspace)
project_type = profile.get("project_type", "none")

typecheck_cmd = profile.get("typecheck_cmd", "")
lint_cmd = profile.get("lint_cmd", "")
test_cmd = profile.get("test_cmd", "")
typecheck_cmds = profiles.command_list(profile, "typecheck_cmd")
lint_cmds = profiles.command_list(profile, "lint_cmd")
test_cmds = profiles.command_list(profile, "test_cmd")

if not typecheck_cmd and not lint_cmd and not test_cmd:
if not typecheck_cmds and not lint_cmds and not test_cmds:
return {
"pass": True,
"typecheck_errors": 0,
Expand All @@ -258,26 +258,23 @@ def build_verify(
build_dir = workspace / "fix-build"
build_dir.mkdir(exist_ok=True)

typecheck_tool = shlex.split(typecheck_cmd)[0] if typecheck_cmd else ""
lint_tool = shlex.split(lint_cmd)[0] if lint_cmd else ""
test_tool = shlex.split(test_cmd)[0] if test_cmd else ""
typecheck_tool = builder.command_group_tool(typecheck_cmds)
lint_tool = builder.command_group_tool(lint_cmds)
test_tool = builder.command_group_tool(test_cmds)

if typecheck_cmd:
tc_out, tc_exit = _run_silent(typecheck_cmd, cwd=cwd)
else:
tc_out, tc_exit = "", 0
tc_result = builder.run_command_group(typecheck_cmds, cwd, 600, "typecheck")
tc_out = tc_result.stdout + tc_result.stderr
tc_exit = tc_result.returncode
(build_dir / "typecheck.log").write_text(tc_out)

if lint_cmd:
lint_out, lint_exit = _run_silent(lint_cmd, cwd=cwd)
else:
lint_out, lint_exit = "", 0
lint_result = builder.run_command_group(lint_cmds, cwd, 600, "lint")
lint_out = lint_result.stdout + lint_result.stderr
lint_exit = lint_result.returncode
(build_dir / "lint.log").write_text(lint_out)

if test_cmd:
test_out, test_exit = _run_silent(test_cmd, cwd=cwd)
else:
test_out, test_exit = "", 0
test_result = builder.run_command_group(test_cmds, cwd, 600, "tests")
test_out = test_result.stdout + test_result.stderr
test_exit = test_result.returncode
(build_dir / "test.log").write_text(test_out)

build_result = builder.compile_build(
Expand Down
Loading
Loading