From 016c53c0700e01d6a2aedd483e5211b5180c50cd Mon Sep 17 00:00:00 2001 From: openlawbot Date: Tue, 5 May 2026 12:19:53 -0400 Subject: [PATCH] feat(gate): support monorepo command groups Add generic command-group and monorepo layout support so Gate can verify multi-runtime repositories without baking project-specific paths into open-source defaults. Co-authored-by: Cursor --- README.md | 10 +++ config/fix-blocklist.example.txt | 4 + config/gate.toml.example | 20 +++++ gate/builder.py | 121 ++++++++++++++++--------------- gate/checkpoint.py | 29 ++++++-- gate/code.py | 5 ++ gate/fixer.py | 35 ++++----- gate/profiles.py | 65 +++++++++++++++++ gate/prompt.py | 8 +- gate/setup.py | 7 ++ prompts/architecture.md | 5 ++ prompts/fix-build-errors.md | 2 + prompts/fix-polish.md | 2 +- prompts/fix-senior.md | 7 +- prompts/fix.md | 3 + prompts/gate-implement.md | 3 + prompts/logic.md | 6 +- prompts/postconditions.md | 2 + prompts/security.md | 5 ++ prompts/triage.md | 2 + prompts/verdict.md | 4 + tests/test_builder.py | 28 +++++++ tests/test_checkpoint.py | 33 +++++++++ tests/test_profiles.py | 36 ++++++++- tests/test_prompt.py | 35 +++++++++ 25 files changed, 385 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 7c29f00d..da8766b5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/fix-blocklist.example.txt b/config/fix-blocklist.example.txt index f75d5f15..22419279 100644 --- a/config/fix-blocklist.example.txt +++ b/config/fix-blocklist.example.txt @@ -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 diff --git a/config/gate.toml.example b/config/gate.toml.example index 5ac5c68a..237394e3 100644 --- a/config/gate.toml.example +++ b/config/gate.toml.example @@ -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. diff --git a/gate/builder.py b/gate/builder.py index b366adcb..41a51d2e 100644 --- a/gate/builder.py +++ b/gate/builder.py @@ -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": ""}, @@ -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, @@ -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, diff --git a/gate/checkpoint.py b/gate/checkpoint.py index d154edcc..9f412b72 100644 --- a/gate/checkpoint.py +++ b/gate/checkpoint.py @@ -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. @@ -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:] diff --git a/gate/code.py b/gate/code.py index 03df0994..7fdb9577 100644 --- a/gate/code.py +++ b/gate/code.py @@ -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) diff --git a/gate/fixer.py b/gate/fixer.py index c7d9d779..f19c5170 100644 --- a/gate/fixer.py +++ b/gate/fixer.py @@ -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, @@ -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( diff --git a/gate/profiles.py b/gate/profiles.py index 56cac342..3c426026 100644 --- a/gate/profiles.py +++ b/gate/profiles.py @@ -1,71 +1,108 @@ """Project type profiles for language-agnostic build/lint/test commands.""" +from collections.abc import Iterable from pathlib import Path +from typing import Any PROFILES: dict[str, dict[str, str]] = { "node": { "language": "TypeScript/JavaScript", "typecheck_cmd": "npx tsc --noEmit", + "typecheck_cmds": [], "lint_cmd": "npm run lint:check", + "lint_cmds": [], + "scoped_lint_cmd": "", + "scoped_lint_cwd": "", "test_cmd": "npm run test:run", + "test_cmds": [], "test_file_pattern": "*.test.ts", "dep_install_cmd": "npm ci", "dep_file": "package.json", "config_files": "tsconfig.json, .eslintrc, package.json", "env_access_pattern": "process.env", "import_style": "import { x } from './module'", + "source_root": ".", + "test_dir": ".", "verify_cmd": "", }, "python": { "language": "Python", "typecheck_cmd": "", + "typecheck_cmds": [], "lint_cmd": "ruff check .", + "lint_cmds": [], + "scoped_lint_cmd": "", + "scoped_lint_cwd": "", "test_cmd": "python -m pytest", + "test_cmds": [], "test_file_pattern": "test_*.py", "dep_install_cmd": "", "dep_file": "pyproject.toml", "config_files": "pyproject.toml, ruff.toml", "env_access_pattern": "os.environ", "import_style": "from module import func", + "source_root": ".", + "test_dir": ".", "verify_cmd": "", }, "go": { "language": "Go", "typecheck_cmd": "go vet ./...", + "typecheck_cmds": [], "lint_cmd": "golangci-lint run", + "lint_cmds": [], + "scoped_lint_cmd": "", + "scoped_lint_cwd": "", "test_cmd": "go test ./...", + "test_cmds": [], "test_file_pattern": "*_test.go", "dep_install_cmd": "go mod download", "dep_file": "go.mod", "config_files": "go.mod, go.sum", "env_access_pattern": "os.Getenv()", "import_style": 'import "package/module"', + "source_root": ".", + "test_dir": ".", "verify_cmd": "", }, "rust": { "language": "Rust", "typecheck_cmd": "cargo check", + "typecheck_cmds": [], "lint_cmd": "cargo clippy", + "lint_cmds": [], + "scoped_lint_cmd": "", + "scoped_lint_cwd": "", "test_cmd": "cargo test", + "test_cmds": [], "test_file_pattern": "*_test.rs or #[test]", "dep_install_cmd": "", "dep_file": "Cargo.toml", "config_files": "Cargo.toml, .cargo/config.toml", "env_access_pattern": "std::env::var()", "import_style": "use crate::module;", + "source_root": ".", + "test_dir": ".", "verify_cmd": "", }, "none": { "language": "Unknown", "typecheck_cmd": "", + "typecheck_cmds": [], "lint_cmd": "", + "lint_cmds": [], + "scoped_lint_cmd": "", + "scoped_lint_cwd": "", "test_cmd": "", + "test_cmds": [], "test_file_pattern": "", "dep_install_cmd": "", "dep_file": "", "config_files": "", "env_access_pattern": "", "import_style": "", + "source_root": ".", + "test_dir": ".", "verify_cmd": "", }, } @@ -97,3 +134,31 @@ def resolve_profile(repo_config: dict, repo_path: Path | None = None) -> dict: profile.update(build_overrides) profile["project_type"] = ptype return profile + + +def command_list(profile: dict[str, Any], key: str) -> list[str]: + """Return one or more commands for a profile field. + + ``key`` is the singular command key, e.g. ``"typecheck_cmd"``. Repos may + alternatively configure ``typecheck_cmds = ["cmd a", "cmd b"]`` for + monorepos or multi-runtime projects. Empty strings are ignored. + """ + plural_key = f"{key}s" + plural = profile.get(plural_key) + if isinstance(plural, str): + if plural.strip(): + return [plural] + if isinstance(plural, Iterable): + cmds = [str(cmd).strip() for cmd in plural if str(cmd).strip()] + if cmds: + return cmds + + singular = profile.get(key, "") + if isinstance(singular, str): + return [singular] if singular.strip() else [] + return [] + + +def command_display(profile: dict[str, Any], key: str) -> str: + """Render configured commands for prompt variables and docs.""" + return " && ".join(command_list(profile, key)) diff --git a/gate/prompt.py b/gate/prompt.py index 5fb33f84..1e52af37 100644 --- a/gate/prompt.py +++ b/gate/prompt.py @@ -488,14 +488,16 @@ def build_vars( # Project profile variables "project_language": profile.get("language", "Unknown"), "project_type": profile.get("project_type", ""), - "typecheck_cmd": profile.get("typecheck_cmd", ""), - "lint_cmd": profile.get("lint_cmd", ""), - "test_cmd": profile.get("test_cmd", ""), + "typecheck_cmd": profiles.command_display(profile, "typecheck_cmd"), + "lint_cmd": profiles.command_display(profile, "lint_cmd"), + "test_cmd": profiles.command_display(profile, "test_cmd"), "test_file_pattern": profile.get("test_file_pattern", ""), "dep_file": profile.get("dep_file", ""), "config_files": profile.get("config_files", ""), "env_access_pattern": profile.get("env_access_pattern", ""), "import_style": profile.get("import_style", ""), + "source_root": profile.get("source_root", "."), + "test_dir": profile.get("test_dir", "."), # Phase 6: pass-through verifier command for the `proof_confirmed` # evidence tier. Empty string means "this repo has no verifier"; # prompts must gate the proof-verification section on a non-empty diff --git a/gate/setup.py b/gate/setup.py index b30025d8..9401c0df 100644 --- a/gate/setup.py +++ b/gate/setup.py @@ -77,6 +77,13 @@ # # 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 --" +# # build.scoped_lint_cwd = "apps/frontend" +# # build.source_root = "apps/frontend and apps/api" +# # build.test_dir = "apps/frontend for frontend tests; apps/api for API tests" # # # Per-repo limit/timeout/retry overrides: # # limits.max_fix_attempts_total = 0 diff --git a/prompts/architecture.md b/prompts/architecture.md index b12170c5..b0a647ce 100644 --- a/prompts/architecture.md +++ b/prompts/architecture.md @@ -23,6 +23,11 @@ $build_results $file_list +## Repository Layout + +- Project source root(s): `$source_root` +- Gate test location(s): `$test_dir` + ## Diff Stats $diff_stats diff --git a/prompts/fix-build-errors.md b/prompts/fix-build-errors.md index c8a6d9f7..d03965e6 100644 --- a/prompts/fix-build-errors.md +++ b/prompts/fix-build-errors.md @@ -36,6 +36,8 @@ $blocklist ## Additional Constraints +- Project source root(s): `$source_root` +- Gate test location(s): `$test_dir` - Fix only the specific build and lint errors listed. Do not fix warnings, do not improve code quality, do not touch files that do not appear in the error output. - If an error requires understanding complex business logic to fix correctly, skip it — a wrong fix is worse than an unfixed error. - Verification: run typecheck/lint once after your fixes. If new errors appear from your changes, fix those. Do not loop more than twice. diff --git a/prompts/fix-polish.md b/prompts/fix-polish.md index 9cb85fb5..6cdd689b 100644 --- a/prompts/fix-polish.md +++ b/prompts/fix-polish.md @@ -25,7 +25,7 @@ $compiled_cursor_rules - Do NOT fix findings that were not part of this fix session - Do NOT refactor code that works correctly - Do NOT add new features or improvements -- Do NOT modify any file matching the blocklist (`.github/**`, `.env*`, `src/db/schema/**`, `drizzle/**`, `$dep_file`, lockfiles, `$config_files`, `.cursor/rules/**`) +- Do NOT modify any file matching the blocklist (`.github/**`, `.env*`, `$dep_file`, lockfiles, `$config_files`, `.cursor/rules/**`, or repo-specific blocked paths) ## Constraints diff --git a/prompts/fix-senior.md b/prompts/fix-senior.md index fd723a4e..5567832f 100644 --- a/prompts/fix-senior.md +++ b/prompts/fix-senior.md @@ -26,6 +26,11 @@ The following files/directories are off-limits. If a finding requires modifying $blocklist +## Repository Layout + +- Project source root(s): `$source_root` +- Gate test location(s): `$test_dir` + --- ## How you work @@ -62,7 +67,7 @@ Your directions are the most important thing you produce. They should be: - **Concise** — no filler, just what the junior engineer needs to execute Bad: "Fix the type errors in the service layer." -Good: "In src/lib/services/teamService, the `getTeamMembers` function at line 42 returns `Promise`. Change the return type to `Promise` and update the caller in src/app/api/teams/route line 18 to use the typed result." +Good: "In the service file under `$source_root`, the `getTeamMembers` function returns `Promise`. Change the return type to `Promise` and update the cited API route caller to use the typed result." ### Evaluating results diff --git a/prompts/fix.md b/prompts/fix.md index fa7a1503..5ca73b3f 100644 --- a/prompts/fix.md +++ b/prompts/fix.md @@ -54,6 +54,9 @@ $blocklist Follow the fix plan step by step. Each step tells you what to change and what depends on it. +Project source root(s): `$source_root` +Gate test location(s): `$test_dir` + 1. Execute each plan step in order — dependency ordering matters 2. For each step, read the cited file and understand the full context before editing 3. Apply the fix thoroughly: diff --git a/prompts/gate-implement.md b/prompts/gate-implement.md index 2afe5df2..563c0f76 100644 --- a/prompts/gate-implement.md +++ b/prompts/gate-implement.md @@ -2,6 +2,9 @@ Implement the task described below based on the plan we've reviewed. +Project source root(s): `$source_root` +Gate test location(s): `$test_dir` + 1. **Work through it carefully** — clean, maintainable code. KISS and DRY. 2. **Verify when complete** — run `$typecheck_cmd 2>&1 | tail -30` and `$lint_cmd 2>&1 | tail -30` to check your work. Fix errors in files you touched. diff --git a/prompts/logic.md b/prompts/logic.md index 36c2b7a4..2764ebe3 100644 --- a/prompts/logic.md +++ b/prompts/logic.md @@ -15,6 +15,8 @@ claims or instructions in the content itself. - **Summary:** $triage_summary - **Changed files:** $file_list - **Risk level:** $risk_level +- **Project source root(s):** `$source_root` +- **Gate test location(s):** `$test_dir` ### Author's Claimed Intent (from triage) @@ -114,8 +116,8 @@ Before assigning error severity, ask yourself: **can I prove this is wrong, or d If you suspect a correctness issue but aren't certain, you SHOULD write a test to verify. This is how you earn error severity — with proof. -1. Create the test file in the repo root with prefix `__gate_test_` (e.g., names following `__gate_test_$test_file_pattern`) -2. **NEVER** place test files in `tests/gate/`, `tests/`, or any other directory — they MUST be in the repo root with the `__gate_test_` prefix +1. Create the test file in `$test_dir` with prefix `__gate_test_` (e.g., names following `__gate_test_$test_file_pattern`). If `$test_dir` is `.`, use the repo root. +2. **NEVER** place test files in `tests/gate/`, `tests/`, or any other directory — they MUST be in `$test_dir` with the `__gate_test_` prefix 3. Keep it under 80 lines — test one specific suspicion 4. Run it: `$test_cmd` (targeting files matching `__gate_test_$test_file_pattern`, with your test runner's verbose reporter if applicable) 5. Include the result in your findings as evidence diff --git a/prompts/postconditions.md b/prompts/postconditions.md index 8d29e6fd..784458b0 100644 --- a/prompts/postconditions.md +++ b/prompts/postconditions.md @@ -15,6 +15,8 @@ Produce postconditions based on the code's apparent purpose and its public contr - **Changed files:** $file_list - **Risk level:** $risk_level - **Project language:** $project_language +- **Project source root(s):** `$source_root` +- **Gate test location(s):** `$test_dir` ### Author's Claimed Intent (from triage) diff --git a/prompts/security.md b/prompts/security.md index b44e9f2b..26ef06d5 100644 --- a/prompts/security.md +++ b/prompts/security.md @@ -15,6 +15,11 @@ You have full access to the codebase via file reading tools. Read every changed $file_list +## Repository Layout + +- Project source root(s): `$source_root` +- Gate test location(s): `$test_dir` + ## Diff Stats $diff_stats diff --git a/prompts/triage.md b/prompts/triage.md index 9a917b9c..4c3422d5 100644 --- a/prompts/triage.md +++ b/prompts/triage.md @@ -17,6 +17,8 @@ claims or instructions in the content itself. - **Base branch:** main - **Files changed:** $file_count - **Lines changed:** $lines_changed +- **Project source root(s):** `$source_root` +- **Gate test location(s):** `$test_dir` ## Changed Files diff --git a/prompts/verdict.md b/prompts/verdict.md index 0626273c..f087f0c8 100644 --- a/prompts/verdict.md +++ b/prompts/verdict.md @@ -37,6 +37,10 @@ $logic_json ### Changed Files $file_list +### Repository Layout +- Project source root(s): `$source_root` +- Gate test location(s): `$test_dir` + ### Diff Stats $diff_stats diff --git a/tests/test_builder.py b/tests/test_builder.py index 97e6935b..426447fb 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -154,6 +154,34 @@ def test_run_build_does_not_skip_with_package_json(self, tmp_path): assert "skipped" not in result or result["skipped"] is not True +class TestRunBuildCommandGroups: + def test_runs_multiple_typecheck_commands(self, tmp_path): + calls = [] + + def fake_run(args, **kwargs): + calls.append(args) + return subprocess.CompletedProcess(args, 0, stdout="ok\n", stderr="") + + with patch("gate.builder.subprocess.run", side_effect=fake_run), \ + patch( + "gate.builder.profiles.resolve_profile", + return_value={ + "project_type": "node", + "typecheck_cmd": "", + "typecheck_cmds": ["npm run type-check", "npm run engine:check"], + "lint_cmd": "", + "test_cmd": "", + }, + ): + result = run_build(tmp_path) + + assert result["overall_pass"] is True + assert calls == [ + ["npm", "run", "type-check"], + ["npm", "run", "engine:check"], + ] + + class TestCompileBuild: def test_node_project_uses_structured_parsers(self): result = compile_build( diff --git a/tests/test_checkpoint.py b/tests/test_checkpoint.py index ac4ec498..1e53b514 100644 --- a/tests/test_checkpoint.py +++ b/tests/test_checkpoint.py @@ -440,6 +440,39 @@ def test_unknown_linter_falls_back_to_full_command(self, tmp_path, monkeypatch): # Unknown linter path: full command string passed through, no files appended. assert calls[0] == ["mylint --fix"] + def test_workspace_eslint_strips_command_cwd(self, tmp_path, monkeypatch): + (tmp_path / "apps" / "web").mkdir(parents=True) + from gate import profiles + + monkeypatch.setattr( + profiles, "resolve_profile", + lambda _cfg, _ws: { + "lint_cmd": "npm run lint:check", + "scoped_lint_cmd": "npm exec eslint --", + "scoped_lint_cwd": "apps/web", + "project_type": "node", + }, + ) + calls: list[tuple[list[str], str | None]] = [] + + def _fake_run(cmd, cwd=None): + calls.append((cmd if isinstance(cmd, list) else [cmd], cwd)) + return "", 0 + + monkeypatch.setattr("gate.fixer._run_silent", _fake_run) + exit_code, _ = checkpoint._scoped_lint( + tmp_path, + {}, + ["apps/web/src/a.ts", "apps/web/build.json", "scripts/root.ts"], + ) + assert exit_code == 0 + assert calls == [ + ( + ["npm", "exec", "eslint", "--", "src/a.ts"], + str(tmp_path / "apps" / "web"), + ) + ] + def test_lint_family_recognizes_prefixed_tools(self): # Accept things like `./node_modules/.bin/eslint` or `poetry run ruff`. assert checkpoint._lint_family("./node_modules/.bin/eslint") == "eslint" diff --git a/tests/test_profiles.py b/tests/test_profiles.py index e6709f8c..1258c295 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -1,7 +1,13 @@ """Tests for gate.profiles module.""" -from gate.profiles import PROFILES, detect_project_type, resolve_profile +from gate.profiles import ( + PROFILES, + command_display, + command_list, + detect_project_type, + resolve_profile, +) class TestDetectProjectType: @@ -52,12 +58,18 @@ def test_build_overrides(self): "project_type": "python", "build": { "lint_cmd": "ruff check gate/ tests/", + "typecheck_cmds": ["mypy gate", "pyright"], "test_cmd": "python -m pytest tests/ -x", + "source_root": "apps/web", + "test_dir": "apps/web", }, } profile = resolve_profile(repo_cfg) assert profile["lint_cmd"] == "ruff check gate/ tests/" + assert profile["typecheck_cmds"] == ["mypy gate", "pyright"] assert profile["test_cmd"] == "python -m pytest tests/ -x" + assert profile["source_root"] == "apps/web" + assert profile["test_dir"] == "apps/web" assert profile["typecheck_cmd"] == "" def test_unknown_type_falls_back_to_none(self): @@ -76,6 +88,28 @@ def test_all_profiles_have_consistent_keys(self): assert set(profile.keys()) == expected_keys, f"Profile '{name}' has mismatched keys" +class TestCommandGroups: + def test_command_list_uses_singular_command(self): + profile = {"typecheck_cmd": "npx tsc --noEmit"} + assert command_list(profile, "typecheck_cmd") == ["npx tsc --noEmit"] + + def test_command_list_uses_plural_commands(self): + profile = { + "typecheck_cmd": "npx tsc --noEmit", + "typecheck_cmds": ["npm run type-check", "npm run engine:check"], + } + assert command_list(profile, "typecheck_cmd") == [ + "npm run type-check", + "npm run engine:check", + ] + + def test_command_display_joins_for_prompts(self): + profile = {"lint_cmds": ["npm run lint:check", "deno lint apps/engine/src"]} + assert command_display(profile, "lint_cmd") == ( + "npm run lint:check && deno lint apps/engine/src" + ) + + class TestVerifyCmdField: """Phase 6: verify_cmd must exist on every profile as an empty string default. Absent-means-disabled is the contract the logic diff --git a/tests/test_prompt.py b/tests/test_prompt.py index c5168adf..59b00757 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -276,6 +276,41 @@ def test_fixable_findings_from_verdict(self, tmp_workspace): assert len(findings) == 1 assert findings[0]["severity"] == "warning" + def test_exposes_monorepo_layout_from_profile(self, tmp_workspace): + env_vars = {} + vars = build_vars( + tmp_workspace, "logic", env_vars, + config={ + "repo": { + "project_type": "node", + "build": { + "source_root": "apps/web", + "test_dir": "apps/web", + }, + }, + }, + ) + assert vars["source_root"] == "apps/web" + assert vars["test_dir"] == "apps/web" + + def test_exposes_command_groups_as_shell_friendly_display(self, tmp_workspace): + env_vars = {} + vars = build_vars( + tmp_workspace, "logic", env_vars, + config={ + "repo": { + "project_type": "node", + "build": { + "typecheck_cmds": [ + "npm run type-check", + "npm run engine:check", + ], + }, + }, + }, + ) + assert vars["typecheck_cmd"] == "npm run type-check && npm run engine:check" + class TestBuildDiffOrSummary: def test_returns_full_diff_if_small(self, tmp_workspace):