Skip to content
Closed
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,13 @@ Presets are optional defaults under `[tool.interlocks]`. Explicit values in the

```toml
[tool.interlocks]
preset = "baseline" # "baseline" | "strict" | "legacy"
preset = "baseline" # "baseline" | "strict" | "legacy" | "progressive"
```

- `baseline` lowers first-adoption friction: advisory CRAP, relaxed thresholds, mutation off in CI, acceptance off in `check`.
- `strict` is for mature repositories: stronger thresholds, blocking CRAP and mutation, mutation in CI, acceptance in `check`, and required Gherkin coverage.
- `legacy` is for ratcheting existing repositories: very permissive thresholds, advisory gates, mutation off in CI.
- `progressive` is an autopilot ratchet: blocking gates like `strict`, but floors are captured in `.interlocks/baseline.json` and advanced on green main merges. Start permissive; the baseline file drives thresholds upward automatically.

`agent-safe` is intentionally unsupported. If configured, `interlocks doctor` reports it as an unsupported preset instead of resolving agent-specific defaults.

Expand Down
12 changes: 12 additions & 0 deletions interlocks/behavior_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ def duplicates(self) -> tuple[DuplicateBehavior, ...]:
"evaluate prints actionable closure guidance",
"interlocks.tasks.evaluate:cmd_evaluate",
),
Behavior(
"cli-presets-parity",
"cli",
"presets command lists all four presets including progressive",
"interlocks.cli:main",
),
Behavior(
"doctor-readiness",
"doctor",
Expand Down Expand Up @@ -373,6 +379,12 @@ def duplicates(self) -> tuple[DuplicateBehavior, ...]:
"a subprocess gate failure exits via SystemExit without entering capture",
"interlocks.crash.boundary:CrashBoundary",
),
Behavior(
"crash-malformed-config-no-capture",
"crash",
"malformed pyproject.toml fails as a clean user error without crash capture",
"interlocks.crash.boundary:CrashBoundary",
),
)


Expand Down
9 changes: 6 additions & 3 deletions interlocks/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,8 +469,11 @@ def _load_pyproject(project_root: Path) -> dict[str, Any]:
path = project_root / "pyproject.toml"
if not path.is_file():
return {}
with path.open("rb") as f:
return tomllib.load(f)
try:
with path.open("rb") as f:
return tomllib.load(f)
except tomllib.TOMLDecodeError as exc:
raise InterlockConfigError(f"pyproject.toml is not valid TOML: {exc}") from exc


def _interlock_table(pyproject: dict[str, Any]) -> dict[str, Any]:
Expand Down Expand Up @@ -686,7 +689,7 @@ def load_optional_config(start: Path | None = None) -> InterlockConfig | None:
"""
try:
return load_config(start)
except (OSError, tomllib.TOMLDecodeError):
except (OSError, tomllib.TOMLDecodeError, InterlockConfigError):
return None


Expand Down
4 changes: 2 additions & 2 deletions interlocks/hook_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing import TypeVar

from interlocks.runner import ok
from interlocks.setup_state import is_post_edit_command
from interlocks.setup_state import _git_hooks_dir, is_post_edit_command

_Container = TypeVar("_Container", dict[str, object], list[object])

Expand Down Expand Up @@ -54,7 +54,7 @@ def install_hooks(project_root: Path | None = None) -> None:
root = project_root or Path.cwd()
python = shlex.quote(sys.executable)

hook = root / ".git" / "hooks" / "pre-commit"
hook = _git_hooks_dir(root) / "pre-commit"
hook.parent.mkdir(parents=True, exist_ok=True)
script = f"#!/bin/sh\nexec {python} -m interlocks.cli pre-commit\n"
hook.write_text(script, encoding="utf-8")
Expand Down
24 changes: 23 additions & 1 deletion interlocks/setup_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,31 @@ def is_post_edit_command(command: object) -> bool:
)


def _git_hooks_dir(project_root: Path) -> Path:
"""Return the git hooks directory for *project_root*.

In a linked worktree ``.git`` is a file with content
``gitdir: <path>/.git/worktrees/<name>``. The actual hooks directory
lives two levels up in the common git dir, not inside the per-worktree
pseudo-repo.
"""
git_path = project_root / ".git"
if not git_path.is_file():
return git_path / "hooks"
text = git_path.read_text(encoding="utf-8").strip()
if not text.startswith("gitdir:"):
return git_path / "hooks"
gitdir = Path(text.split(":", 1)[1].strip())
if not gitdir.is_absolute():
gitdir = (project_root / gitdir).resolve()
if gitdir.parent.name == "worktrees":
return gitdir.parent.parent / "hooks"
return git_path / "hooks"


def pre_commit_hook_installed(project_root: Path) -> bool:
"""True when ``.git/hooks/pre-commit`` exists and invokes ``interlocks pre-commit``."""
hook = project_root / ".git" / "hooks" / "pre-commit"
hook = _git_hooks_dir(project_root) / "pre-commit"
try:
body = hook.read_text(encoding="utf-8")
except OSError:
Expand Down
4 changes: 2 additions & 2 deletions interlocks/tasks/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from typing import TYPE_CHECKING

from interlocks import ui
from interlocks.config import find_project_root, kv_with_source, load_config
from interlocks.config import InterlockConfigError, find_project_root, kv_with_source, load_config
from interlocks.crash.storage import cache_dir as _crash_cache_dir
from interlocks.detect import expected_target_interpreter
from interlocks.setup_state import (
Expand Down Expand Up @@ -125,7 +125,7 @@ def _safe_load_config(pyproject_path: Path, failures: list[str]) -> InterlockCon
"""Load config, recording a failure when ``pyproject.toml`` is unreadable."""
try:
return load_config()
except (OSError, tomllib.TOMLDecodeError) as exc:
except (OSError, tomllib.TOMLDecodeError, InterlockConfigError) as exc:
failures.append(f"cannot read {pyproject_path}: {exc}")
return None

Expand Down
10 changes: 10 additions & 0 deletions tests/features/interlock_cli.feature
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ Feature: interlocks CLI surface area
And the output contains "── Examples"
And the output does not contain "user-global"

# req: cli-presets-parity
Scenario: presets command lists all four presets including progressive
Given I run "interlocks presets"
Then the output contains "── Available Presets"
And the output contains "baseline"
And the output contains "strict"
And the output contains "legacy"
And the output contains "progressive"
And the output contains "autopilot ratchet"

# req: cli-evaluate-guidance
Scenario: Evaluate gap guidance includes closure command
Given I run "interlocks evaluate" on a project with a traceability gap
Expand Down
10 changes: 10 additions & 0 deletions tests/features/interlock_crash.feature
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,13 @@ Feature: interlocks CLI crash boundary
Then the exit code is not 0
And stderr does not contain "github.com/0xjgv/interlocks/issues/new"
And no crash file exists in the cache directory

# req: crash-malformed-config-no-capture
Scenario: Malformed pyproject.toml fails as clean user error without crash report
Given a project with a malformed pyproject.toml
When I run "interlocks lint"
Then the exit code is 2
And stderr contains "interlocks:"
And stderr does not contain "Traceback"
And stderr does not contain "github.com/0xjgv/interlocks/issues/new"
And no crash file exists in the cache directory
19 changes: 19 additions & 0 deletions tests/features/interlock_stages.feature
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ Feature: interlocks stage commands on a minimal inline project
And the stage output contains "changed vs HEAD"
And the stage output contains "skipped under --changed"

# req: stage-check
Scenario: `interlocks check --changed` skips each graph-wide gate with a named reason
Given a minimal tmp project initialized as a git repo
And the tmp project has a changed Python file
When I run "interlocks check --changed=HEAD" in the tmp project
Then the stage exits 0
And the stage output contains "test: skipped under --changed"
And the stage output contains "deps: skipped under --changed"
And the stage output contains "attribution: skipped under --changed"

# req: stage-check
Scenario: `interlocks check --changed` runs file-level gates on changed Python files
Given a minimal tmp project initialized as a git repo
And the tmp project has a changed Python file
When I run "interlocks check --changed=HEAD" in the tmp project
Then the stage exits 0
And the stage output contains "[fix]"
And the stage output contains "[typecheck]"

# req: stage-check
Scenario: `interlocks check --changed` honors changed_ref from pyproject
Given a minimal tmp project initialized as a git repo
Expand Down
38 changes: 38 additions & 0 deletions tests/stages/test_setup_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,44 @@ def test_ensure_stop_hook_normalizes_duplicate_post_edit_hooks(self) -> None:
],
)

def test_keep_existing_hook_preserves_non_command_hook(self) -> None:
"""Matcher and other non-command hook objects are always kept."""
from interlocks.hook_setup import _keep_existing_hook

matcher = {"type": "matcher", "pattern": ".*error.*"}
assert _keep_existing_hook(matcher, "python -m interlocks.cli post-edit") is True

def test_keep_existing_hook_preserves_non_dict_hook(self) -> None:
"""Non-dict hook values are treated as unknown and kept unchanged."""
from interlocks.hook_setup import _keep_existing_hook

result = _keep_existing_hook("run-some-script.sh", "python -m interlocks.cli post-edit")
assert result is True

def test_ensure_stop_hook_three_calls_produces_single_entry(self) -> None:
"""Calling _ensure_stop_hook three times results in exactly one managed entry."""
command = "python -m interlocks.cli post-edit"
settings: dict[str, object] = {}
_ensure_stop_hook(settings, command)
_ensure_stop_hook(settings, command)
_ensure_stop_hook(settings, command)

hooks = settings["hooks"]["Stop"][0]["hooks"] # pyright: ignore[reportIndexIssue]
managed = [h for h in hooks if isinstance(h, dict) and h.get("command") == command]
assert len(managed) == 1

def test_ensure_stop_hook_preserves_unmanaged_command_alongside_managed(self) -> None:
"""An unrecognised command hook coexists with the managed post-edit entry."""
settings: dict[str, object] = {
"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "my-linter --fix"}]}]}
}
_ensure_stop_hook(settings, "python -m interlocks.cli post-edit")

hooks = settings["hooks"]["Stop"][0]["hooks"] # pyright: ignore[reportIndexIssue]
commands = [h["command"] for h in hooks if isinstance(h, dict)]
assert "my-linter --fix" in commands
assert "python -m interlocks.cli post-edit" in commands

def test_cmd_hooks_writes_hook_file_and_settings(self) -> None:
from interlocks.stages.setup_hooks import cmd_hooks

Expand Down
59 changes: 59 additions & 0 deletions tests/stages/test_setup_hooks_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,65 @@ def test_setup_hooks_installs_pre_commit_and_stop_hook(tmp_project: Path) -> Non
assert any(h["type"] == "command" and h["command"].endswith(suffix) for h in hooks)


def _init_repo_for_worktree(root: Path) -> None:
"""Initialise a git repo with an initial commit (required before adding worktrees)."""
_git(root, "init", "-q", "-b", "main")
_git(root, "config", "user.email", "t@e.co")
_git(root, "config", "user.name", "t")
_git(root, "config", "commit.gpgsign", "false")
(root / "placeholder.txt").write_text("init\n", encoding="utf-8")
subprocess.run(["git", "add", "-A"], cwd=root, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-q", "-m", "init"],
cwd=root,
check=True,
capture_output=True,
)


def _make_worktree_pair(tmp_path: Path, branch: str) -> tuple[Path, Path]:
"""Return (main, linked) after creating a repo and adding a linked worktree."""
main = tmp_path / "main"
main.mkdir()
_init_repo_for_worktree(main)
linked = tmp_path / "linked"
subprocess.run(
["git", "worktree", "add", "-b", branch, str(linked), "HEAD"],
cwd=main,
check=True,
capture_output=True,
)
return main, linked


def test_install_hooks_in_linked_worktree(tmp_path: Path) -> None:
"""install_hooks writes the pre-commit hook to the main repo from a linked worktree."""
from interlocks.hook_setup import install_hooks

main, linked = _make_worktree_pair(tmp_path, "feature")
assert (linked / ".git").is_file(), "linked worktree .git must be a file"

install_hooks(linked)

hook = main / ".git" / "hooks" / "pre-commit"
assert hook.exists()
assert os.access(hook, os.X_OK)
assert "-m interlocks.cli pre-commit" in hook.read_text(encoding="utf-8")


def test_pre_commit_hook_installed_detects_linked_worktree(tmp_path: Path) -> None:
"""pre_commit_hook_installed returns True for a hook in the common git dir."""
from interlocks.hook_setup import install_hooks
from interlocks.setup_state import pre_commit_hook_installed

main, linked = _make_worktree_pair(tmp_path, "detect-test")

install_hooks(linked)

assert pre_commit_hook_installed(linked)
assert pre_commit_hook_installed(main)


def test_setup_hooks_is_idempotent(tmp_project: Path) -> None:
assert _run_setup_hooks(tmp_project).returncode == 0
assert _run_setup_hooks(tmp_project).returncode == 0
Expand Down
7 changes: 7 additions & 0 deletions tests/step_defs/test_interlock_crash.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@ def _lint_failure_project(crash_session: CrashSession) -> None:
_scaffold_crash_project(crash_session.project_root, broken_module=True)


@given("a project with a malformed pyproject.toml")
def _malformed_pyproject(crash_session: CrashSession) -> None:
(crash_session.project_root / "pyproject.toml").write_text(
"[invalid\nnot valid toml\n", encoding="utf-8"
)


@given("the first run printed a GitHub issue URL")
def _first_run_printed_url(crash_run: CrashRun, crash_session: CrashSession) -> None:
assert "github.com/0xjgv/interlocks/issues/new" in crash_run.stderr, (
Expand Down
6 changes: 5 additions & 1 deletion tests/step_defs/test_interlock_stages.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,11 @@ def _stage_exits(stage_result: subprocess.CompletedProcess[str], code: int) -> N
)


def _stage_combined(result: subprocess.CompletedProcess[str]) -> str:
return result.stdout + result.stderr


@then(parsers.parse('the stage output contains "{fragment}"'))
def _stage_output_contains(stage_result: subprocess.CompletedProcess[str], fragment: str) -> None:
combined = stage_result.stdout + stage_result.stderr
combined = _stage_combined(stage_result)
assert fragment in combined, f"expected {fragment!r} in stage output; got:\n{combined}"
59 changes: 59 additions & 0 deletions tests/tasks/test_arch.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,27 @@ def test_task_arch_layered_template_synthesizes_with_layers(
assert layer in contents


def test_task_arch_uses_importlinter_sidecar_config(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
""".importlinter sidecar in project root suppresses bundled --config."""
from interlocks.tasks import arch as arch_mod

proj = tmp_path / "proj"
proj.mkdir()
(proj / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8")
(proj / ".importlinter").write_text(
"[importlinter]\nroot_package = x\n\n[contracts:test]\nname = test\ntype = forbidden\n"
"source_modules = x\nforbidden_modules = tests\n",
encoding="utf-8",
)
_stub_load_config(monkeypatch, proj, proj / "src", proj / "tests")
task = arch_mod.task_arch()
assert task is not None
assert task.description == "Architecture (import-linter)"
assert "--config" not in task.cmd


def test_task_arch_layered_skips_when_no_layers_defined(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
Expand Down Expand Up @@ -280,3 +301,41 @@ def test_task_arch_layered_skips_when_no_layers_defined(
out = capsys.readouterr().out
assert "layered template selected" in out
assert "arch_layers" in out


# ─────────────── tool pin propagation ──────────────────────────────


def test_task_arch_uses_import_linter_pin_override(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""[tool.interlocks.tools] import-linter override must appear in the uvx --from spec.

Uses a user [tool.importlinter] section to take the simpler user-contracts path
(no temp INI needed) and keep setup minimal — only the pin is under test.
"""
from interlocks.defaults.tools import default_pin
from interlocks.tasks import arch as arch_mod

custom_pin = "2.0.0"
proj = tmp_path / "proj"
proj.mkdir()
(proj / "pyproject.toml").write_text(
textwrap.dedent(f"""\
[project]
name = "archpin"
version = "0.0.0"

[tool.importlinter]
root_package = "archpin"

[tool.interlocks.tools]
import-linter = "{custom_pin}"
"""),
encoding="utf-8",
)
monkeypatch.chdir(proj)
task = arch_mod.task_arch()
assert task is not None
assert f"import-linter=={custom_pin}" in task.cmd
assert f"import-linter=={default_pin('import-linter')}" not in task.cmd
Loading
Loading