From 94e0573b51e38554c4807be028deef2ad6fd9a22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:58:16 +0000 Subject: [PATCH 1/2] Keep the gate runtime tracked under a global bin/ ignore, and say when a hook target is not The `bin/` rule in the Visual Studio, .NET and Java gitignore templates, common in a developer's global excludes file, matched `.chock/bin/`: `git add -A` never tracked the runtime every agent hook runs, and a clone, a CI checkout or `git clean -x` left the agents' hook configs naming a file that was not there. Found on Windows by chock-catalog's agent test kit: Claude Code reported `SessionStart:startup hook error ... can't open file '.chock/bin/claude_code.py'`, and no gate judged anything from then on. - chock.scaffold.gitrules writes chock's .gitignore rules in one place: `.chock/log/` ignored as before, and `!.chock/bin/`, `!.chock/compiled/` (and `/**` under each) re-included -- a repository's own .gitignore outranks a global excludes file. `chock init` writes them, and `chock sync` adds them for adopters who initialised earlier. This repository's own .gitignore carries them now. - check_dangling_hook_targets reports a hook target git would ignore, naming the rule and where it lives, as well as a missing one, and covers the compiled gate a hook hands the runtime. It asks `check-ignore -q` whether a path is ignored: `-v` also names a matching `!` negation, and exits 0 for it. - docs: getting-started and adopting say what the rules are and why. tests/test_runtime_tracked_under_global_ignore.py runs under a global excludes file with `[Bb]in/`: the runtime and compiled gates are tracked after init and sync; without the rules the check names the global rule; sync restores the rules; a missing compiled gate is reported; a negated path is not; the rules are written once and an adopter's own are kept. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .gitignore | 6 + CHANGELOG.md | 19 +++ docs/adopting.md | 6 +- docs/getting-started.md | 5 +- src/chock/scaffold/gitrules.py | 52 +++++++ src/chock/scaffold/init.py | 22 +-- src/chock/scaffold/recompile.py | 4 + src/chock/validation/checks_repo.py | 55 ++++++-- tests/test_gate_log_is_not_committed.py | 7 +- ...est_runtime_tracked_under_global_ignore.py | 127 ++++++++++++++++++ 10 files changed, 265 insertions(+), 38 deletions(-) create mode 100644 src/chock/scaffold/gitrules.py create mode 100644 tests/test_runtime_tracked_under_global_ignore.py diff --git a/.gitignore b/.gitignore index 5663d954..02f89382 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,9 @@ docs/assets/promo/frames_test/ docs/assets/promo/audio_work/ docs/assets/promo/audio.wav dist-test/ +# chock: the gate runtime and compiled gates are what every hook runs -- tracked even where +# a global rule (a `bin/` from a Visual Studio or Java template) would ignore them +!.chock/bin/ +!.chock/bin/** +!.chock/compiled/ +!.chock/compiled/** diff --git a/CHANGELOG.md b/CHANGELOG.md index cecd98bf..4ac227a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Chock changelog +## Unreleased + +- **A global `bin/` ignore no longer takes the gate out of an adopter's repository.** The `bin/` + rule in the Visual Studio, .NET and Java gitignore templates, common in a developer's global + excludes file, matched `.chock/bin/`: `git add -A` never tracked the runtime every agent hook + runs, and a clone, a CI checkout or a `git clean -x` left `.claude/settings.json`, + `.cursor/hooks.json`, `.codex/hooks.json` and `.devin/hooks.json` naming a file that was not + there. Claude Code reported `SessionStart:startup hook error ... can't open file + '.chock/bin/claude_code.py'`, and no gate judged anything from then on: a hook whose script + cannot start refuses every call with an unrelated error on a client that reads its exit code 2 + as a block, and lets every call through on one that treats a failed hook as non-blocking. + `chock init` and `chock sync` now add `!.chock/bin/` and `!.chock/compiled/` (and `/**` under + each) to the repository's .gitignore, which outranks a global excludes file. + Existing adopters get the rules at their next `chock sync`; commit them with the files. +- **`chock check` names a hook target git would not keep.** The dangling-hook check caught only a + runtime that was missing here. It now reports a runtime that git ignores, naming the rule and + where it lives, and covers the compiled gate a hook hands the runtime as well as the runtime + itself. + ## 0.11.2 — The plugins page states each client's own crash answer - **The plugins page states each client's own answer to a crashed guard.** Every tree's diff --git a/docs/adopting.md b/docs/adopting.md index 42117b55..eebf9604 100644 --- a/docs/adopting.md +++ b/docs/adopting.md @@ -130,7 +130,11 @@ reinstalled: 3. `ls .chock/compiled/scan-secrets/git-hook/gate.json` should exist. 4. Commit a file containing a credential. `scan-secrets` should block it and print the policy message. -If your consumer `.gitignore` contains a broad `.chock/` rule, add un-ignore lines so the committed artifacts stay tracked: +`chock init` and `chock sync` write these un-ignore lines to your `.gitignore`, so a global ignore +rule cannot keep the gate out of your commits. The `bin/` in the Visual Studio, .NET and Java +templates is the usual culprit: it matches `.chock/bin/`, and every agent hook then names a file a +clone does not have. `chock check` reports a hook target that is missing or git-ignored. If your +own `.gitignore` has a broad `.chock/` rule, keep the lines below after it: ```gitignore !.chock/bin/ diff --git a/docs/getting-started.md b/docs/getting-started.md index 2283826d..2f7eac45 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -50,8 +50,9 @@ chock init . - creates `.chock/` (config, `coverage.json`, `dependency-allowlist.txt`, the vendored runtime under `bin/` and compiled output under `compiled/`) plus `chock.lock` at the repo root and an empty `.agents/policies/`, -- writes `AGENTS.md`, a `.gitattributes` pinning generated scripts to LF, a `.gitignore` rule for - the per-machine gate log (`.chock/log/`), a wrapper file for each agent that does **not** read +- writes `AGENTS.md`, a `.gitattributes` pinning generated scripts to LF, `.gitignore` rules that + ignore the per-machine gate log (`.chock/log/`) and keep the gate runtime and compiled gates + (`.chock/bin/`, `.chock/compiled/`) tracked even where a global `bin/` rule would ignore them, a wrapper file for each agent that does **not** read `AGENTS.md` natively (root `CLAUDE.md` for Claude Code, and by default nothing else — Cursor, Copilot, Codex, Gemini, VS Code and Windsurf all read `AGENTS.md` directly), and the guardrail pairs `.agents/policies/{AGENTS.md,CLAUDE.md}` and `.agents/skills/{AGENTS.md,CLAUDE.md}`, diff --git a/src/chock/scaffold/gitrules.py b/src/chock/scaffold/gitrules.py new file mode 100644 index 00000000..a2a5e4e7 --- /dev/null +++ b/src/chock/scaffold/gitrules.py @@ -0,0 +1,52 @@ +"""The two things chock says about an adopter's .gitignore: what must stay out, and what must stay in. + +The gate outcome log is per machine and grows on every commit, so it is ignored. The gate runtime +(`.chock/bin/`) and the compiled gates (`.chock/compiled/`) are what every agent hook and git hook +runs, so they are tracked -- explicitly, because a *global* ignore rule reaches them otherwise: the +`bin/` in the Visual Studio, .NET and Java gitignore templates matches `.chock/bin/`, `git add -A` +skips it, and every clone, CI checkout and `git clean -x` then has hooks naming a file that is not +there. A repository's own .gitignore outranks the global excludes file, so the negation wins. + +Written once, appended, never rewritten: a rule the adopter already has, in a form git honours the +same way, is left alone. +""" + +from __future__ import annotations + +from pathlib import Path + +from chock.emit import write_generated + +GATE_LOG_IGNORE = ".chock/log/" +#: Re-included whatever a global excludes file says: the directories (against a `bin/` rule) and +#: everything under them (against a rule that names the files, a `*.json`, say). +TRACKED_RUNTIME = ("!.chock/bin/", "!.chock/bin/**", "!.chock/compiled/", "!.chock/compiled/**") + +_BLOCKS = ( + ((GATE_LOG_IGNORE,), "# chock: the gate outcome log is per machine and grows on every commit"), + ( + TRACKED_RUNTIME, + "# chock: the gate runtime and compiled gates are what every hook runs -- tracked even where\n" + "# a global rule (a `bin/` from a Visual Studio or Java template) would ignore them", + ), +) + + +def _normal(rule: str) -> str: + return rule.strip().rstrip("/") + + +def ensure_git_rules(repo_root: Path) -> None: + """Append whichever of chock's rules the repository's .gitignore does not already carry.""" + gitignore = Path(repo_root) / ".gitignore" + existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else "" + present = {_normal(line) for line in existing.splitlines()} + added = "" + for rules, comment in _BLOCKS: + missing = [rule for rule in rules if _normal(rule) not in present] + if missing: + added += comment + "\n" + "".join(f"{rule}\n" for rule in missing) + if not added: + return + joiner = "" if not existing or existing.endswith("\n") else "\n" + write_generated(gitignore, existing + joiner + added) diff --git a/src/chock/scaffold/init.py b/src/chock/scaffold/init.py index bfa1c630..6ad4c389 100644 --- a/src/chock/scaffold/init.py +++ b/src/chock/scaffold/init.py @@ -25,6 +25,7 @@ write_instructions, ) from chock.scaffold.agents_md import update_agents_md +from chock.scaffold.gitrules import ensure_git_rules from chock.scaffold.recompile import BookkeepingError, discover_policy_dirs, recompile from chock.scaffold.skills import install_skills from chock.scaffold.templates import ( @@ -96,29 +97,10 @@ def _write_config(repo_root: Path, agents: list[str], *, agent_agnostic: bool) - allowlist = path.parent / "dependency-allowlist.txt" if not allowlist.exists(): allowlist.write_text(_dependency_allowlist_template(), encoding="utf-8") - _ignore_gate_log(repo_root) + ensure_git_rules(repo_root) return path -GATE_LOG_IGNORE = ".chock/log/" - - -def _ignore_gate_log(repo_root: Path) -> None: - """Keep the per-machine gate outcome log out of the adopter's commits. - - Every git hook appends to it, so an adopter who ran `git add -A` after `chock init` - committed a file that then changed on every commit they made. Appended once, never - rewritten: an existing rule, in any form git already honours, is left alone. - """ - gitignore = repo_root / ".gitignore" - existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else "" - if any(line.strip().rstrip("/") == GATE_LOG_IGNORE.rstrip("/") for line in existing.splitlines()): - return - block = f"# chock: the gate outcome log is per machine and grows on every commit\n{GATE_LOG_IGNORE}\n" - joiner = "" if not existing or existing.endswith("\n") else "\n" - write_generated(gitignore, existing + joiner + block) - - def _normalize_agents(args_agents: list[str] | None, *, agent_agnostic: bool, repo_root: Path) -> list[str]: if agent_agnostic: return sorted(CHOCK_AGENT) diff --git a/src/chock/scaffold/recompile.py b/src/chock/scaffold/recompile.py index 95f0bca1..91202b3a 100644 --- a/src/chock/scaffold/recompile.py +++ b/src/chock/scaffold/recompile.py @@ -28,6 +28,7 @@ from chock.output import warn from chock.policies import discover_policy_dirs from chock.registry.core import save_registry, scan +from chock.scaffold.gitrules import ensure_git_rules from chock.vendored import vendored_differences from chock.vendors import CHOCK_AGENT @@ -210,6 +211,9 @@ def recompile(repo_root: Path | str, agents: list[str], *, skip_hooks: bool = Fa (chock_dir / "bin").mkdir(parents=True, exist_ok=True) shutil.copy2(str(staged_runner), str(chock_dir / "bin" / "gate.py")) + # An adopter who ran `init` before the runtime was tracked explicitly gets the rule here. + ensure_git_rules(repo_root) + write_generated_json(coverage_path, coverage) if not skip_hooks: diff --git a/src/chock/validation/checks_repo.py b/src/chock/validation/checks_repo.py index d3679990..d18e5fbf 100644 --- a/src/chock/validation/checks_repo.py +++ b/src/chock/validation/checks_repo.py @@ -22,7 +22,8 @@ #: writer of that directory, so naming a path under it identifies an entry as chock's own, #: independent of the vendor-specific shapes `in_agent_merged.py`/`in_agent_generic.py` merge #: it through. -_BIN_TARGET_RE = re.compile(r"\.chock/bin/[\w.-]+\.py") +#: A file an agent hook command runs or hands the gate: the runtime, and the compiled gate it reads. +_BIN_TARGET_RE = re.compile(r"\.chock/(?:bin/[\w.-]+\.py|compiled/[\w./-]+\.json)") _POINTER_RE = re.compile( re.escape(POINTER_START) + r"(.*?)" + re.escape(POINTER_END), @@ -232,13 +233,47 @@ def _tracked_under(root: Path, rel: str) -> list[str]: return result.stdout.split() if result.returncode == 0 else [] +def _ignore_rule(root: Path, rel: str) -> str | None: + """The rule git would ignore `rel` by (`source:line:pattern`), or None; None outside a repo. + + A tracked file is never ignored, so this names only a file a clone would not have. Whether + it is ignored is asked plainly: `check-ignore -v` also reports a path a `!` negation + re-includes, and exits 0 for it, so it only names the rule once the answer is yes. + """ + git = shutil.which("git") + if git is None: + return None + base = [git, "-C", str(root), "check-ignore"] + ignored = subprocess.run([*base, "-q", "--", rel], capture_output=True, check=False) # noqa: S603 -- asking git + if ignored.returncode != 0: + return None + named = subprocess.run([*base, "-v", "--", rel], capture_output=True, text=True, check=False) # noqa: S603 + return named.stdout.strip().split("\t", 1)[0] or "a gitignore rule" + + +def _dangling_reason(root: Path, target: str) -> str | None: + """Why a hook naming `target` fails -- here, or in every clone -- or None when it does not.""" + rule = _ignore_rule(root, target) + if rule is not None: + return ( + f"is ignored by git ({rule}), so a clone, a teammate's checkout or CI has no such file " + "and the hook fails there. A global `bin/` rule is the usual cause: `chock sync` adds " + "`!.chock/bin/` and `!.chock/compiled/` to .gitignore; commit them and the files." + ) + if not (root / target).exists(): + return "does not exist. Run `chock sync` to reinstall or uninstall this vendor's hooks." + return None + + def check_dangling_hook_targets(root: Path, report: Report) -> None: - """A chock-written hook entry naming a `.chock/bin/` runtime `sync` already deleted. + """A chock-written hook entry naming a file that is missing, or that git would not keep. `sync` wires in-agent hooks only for the vendors `supported_agents` names and prunes a vendored runtime once its vendor falls out of that list (`recompile.py`); a hook config still naming the deleted runtime is a client-side failure on every tool call, silent to - both `chock sync --check` and `chock check` unless something reads the config back. + both `chock sync --check` and `chock check` unless something reads the config back. A + runtime that exists here but is git-ignored fails the same way in every other checkout: + there, no hook can start the gate at all. """ paths = [root / vendors.config_path(vendor) for vendor in WIRED_VENDORS] paths.append(root / agent_hooks_rel()) @@ -253,18 +288,12 @@ def check_dangling_hook_targets(root: Path, report: Report) -> None: continue for target in _BIN_TARGET_RE.findall(text): key = (str(path), target) - if key in seen or (root / target).exists(): + if key in seen: continue seen.add(key) - report.add( - Finding( - str(path), - "dangling_hook_target", - "error", - f"{path} runs {target}, which does not exist. Run `chock sync` to reinstall or " - "uninstall this vendor's hooks.", - ) - ) + reason = _dangling_reason(root, target) + if reason is not None: + report.add(Finding(str(path), "dangling_hook_target", "error", f"{path} runs {target}, which {reason}")) def check_ambient_token_budget(root: Path, report: Report) -> None: diff --git a/tests/test_gate_log_is_not_committed.py b/tests/test_gate_log_is_not_committed.py index 332427e2..73057e79 100644 --- a/tests/test_gate_log_is_not_committed.py +++ b/tests/test_gate_log_is_not_committed.py @@ -11,7 +11,8 @@ from conftest import init_repo -from chock.scaffold.init import GATE_LOG_IGNORE, cmd_init +from chock.scaffold.gitrules import GATE_LOG_IGNORE +from chock.scaffold.init import cmd_init from chock.validation.checks_repo import check_gate_log_untracked from chock.validation.report import Report @@ -55,7 +56,9 @@ def test_a_rule_the_adopter_already_wrote_is_respected(tmp_path: Path) -> None: repo = init_repo(tmp_path) (repo / ".gitignore").write_text(".chock/log\n", encoding="utf-8") assert _init(repo) == 0 - assert (repo / ".gitignore").read_text(encoding="utf-8") == ".chock/log\n" + lines = (repo / ".gitignore").read_text(encoding="utf-8").splitlines() + assert lines[0] == ".chock/log" + assert GATE_LOG_IGNORE not in lines # --- and validate says so when the log was committed anyway -------------------------------------- diff --git a/tests/test_runtime_tracked_under_global_ignore.py b/tests/test_runtime_tracked_under_global_ignore.py new file mode 100644 index 00000000..eefed513 --- /dev/null +++ b/tests/test_runtime_tracked_under_global_ignore.py @@ -0,0 +1,127 @@ +"""A global `bin/` ignore must not take `.chock/bin/` out of an adopter's repository. + +Found by chock-catalog's agent test kit on Windows: the `bin/` in the Visual Studio, .NET and Java +gitignore templates matched `.chock/bin/`, `git add -A` never tracked it, the first `git clean -x` +deleted it, and Claude Code reported `SessionStart:startup hook error ... can't open file +'.chock/bin/claude_code.py'` -- after which no hook could start the gate. chock now tracks its +runtime explicitly, and `chock check` says so when a hook target is missing or ignored. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest +from conftest import baseline_policy, init_repo + +from chock.scaffold.gitrules import GATE_LOG_IGNORE, TRACKED_RUNTIME, ensure_git_rules +from chock.scaffold.recompile import recompile +from chock.validation.checks_repo import check_dangling_hook_targets +from chock.validation.report import Report + +RUNTIME = ".chock/bin/claude_code.py" + + +@pytest.fixture +def global_bin_ignore(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> Path: + """A machine whose global excludes file ignores `bin/`, as the common templates do.""" + home = tmp_path_factory.mktemp("machine") + excludes = home / "global-gitignore" + excludes.write_text("[Bb]in/\nobj/\n", encoding="utf-8") + config = home / "global-gitconfig" + config.write_text(f"[core]\n\texcludesFile = {excludes.as_posix()}\n", encoding="utf-8") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) + return excludes + + +def _wired(tmp_path: Path) -> Path: + repo = init_repo(tmp_path) + # A command guard (the runtime alone) and a write gate (the runtime and a compiled gate). + for policy in ("block-destructive-commands", "pin-github-actions"): + shutil.copytree(baseline_policy(policy), repo / ".agents" / "policies" / policy) + recompile(repo, ["claude"], skip_hooks=False) + assert (repo / RUNTIME).is_file() + return repo + + +def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, check=False) + + +def _without_runtime_rules(repo: Path) -> None: + """An adopter's .gitignore from before chock tracked its runtime explicitly.""" + gitignore = repo / ".gitignore" + kept = [line for line in gitignore.read_text(encoding="utf-8").splitlines() if line not in TRACKED_RUNTIME] + gitignore.write_text("\n".join(kept) + "\n", encoding="utf-8") + + +@pytest.mark.usefixtures("global_bin_ignore") +def test_the_runtime_is_tracked_despite_a_global_bin_ignore(tmp_path: Path) -> None: + repo = _wired(tmp_path) + assert _git(repo, "check-ignore", "-q", RUNTIME).returncode == 1, "the runtime would be ignored" + _git(repo, "add", "-A") + tracked = _git(repo, "ls-files", ".chock/bin", ".chock/compiled").stdout.splitlines() + assert RUNTIME in tracked + assert any(path.endswith("gate.json") for path in tracked) + assert _git(repo, "check-ignore", "-q", ".chock/log/gate-events.jsonl").returncode == 0 + + +@pytest.mark.usefixtures("global_bin_ignore") +def test_an_ignored_runtime_is_an_error_that_names_the_rule(tmp_path: Path) -> None: + repo = _wired(tmp_path) + _without_runtime_rules(repo) + report = Report() + check_dangling_hook_targets(repo, report) + messages = [f.message for f in report.errors if f.check == "dangling_hook_target"] + assert messages, "an ignored runtime passed the check" + assert all("is ignored by git" in m and "global-gitignore" in m for m in messages) + assert any(RUNTIME in m for m in messages) + + +@pytest.mark.usefixtures("global_bin_ignore") +def test_sync_restores_the_rules_for_an_adopter_who_initialised_earlier(tmp_path: Path) -> None: + repo = _wired(tmp_path) + _without_runtime_rules(repo) + recompile(repo, ["claude"], skip_hooks=False) + report = Report() + check_dangling_hook_targets(repo, report) + assert report.errors == [] + + +def test_a_missing_compiled_gate_is_a_dangling_target(tmp_path: Path) -> None: + repo = _wired(tmp_path) + shutil.rmtree(repo / ".chock" / "compiled") + report = Report() + check_dangling_hook_targets(repo, report) + messages = [f.message for f in report.errors] + assert messages + assert all(".chock/compiled/" in m and "does not exist" in m for m in messages) + + +def test_the_rules_are_written_once_and_an_adopters_own_are_kept(tmp_path: Path) -> None: + gitignore = tmp_path / ".gitignore" + gitignore.write_text("target/\n.chock/log\n!.chock/bin\n", encoding="utf-8") + ensure_git_rules(tmp_path) + ensure_git_rules(tmp_path) + lines = gitignore.read_text(encoding="utf-8").splitlines() + assert lines[:3] == ["target/", ".chock/log", "!.chock/bin"] + assert GATE_LOG_IGNORE not in lines + assert lines.count("!.chock/compiled/") == 1 + assert "!.chock/bin/" not in lines + + +def test_a_repository_with_no_gitignore_gets_both_rules(tmp_path: Path) -> None: + ensure_git_rules(tmp_path) + lines = (tmp_path / ".gitignore").read_text(encoding="utf-8").splitlines() + assert {GATE_LOG_IGNORE, *TRACKED_RUNTIME} <= set(lines) + + +def test_a_path_a_negation_re_includes_is_not_reported_as_ignored(tmp_path: Path) -> None: + """`git check-ignore -v` names a matching `!` rule and exits 0 even though the path is kept.""" + repo = _wired(tmp_path) + assert "!.chock/bin/**" in (repo / ".gitignore").read_text(encoding="utf-8").splitlines() + report = Report() + check_dangling_hook_targets(repo, report) + assert report.errors == [] From 863259628e54376f191f0918337215e9cf358787 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:11:08 +0000 Subject: [PATCH 2/2] Move the hook-target check into its own module checks_repo.py went past the 300-line review budget with the ignore-aware reasons; the hook-target check is one activity and now reads as one file. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- src/chock/validation/checks_hook_targets.py | 81 +++++++++++++++++++ src/chock/validation/checks_repo.py | 72 ----------------- src/chock/validation/engine.py | 2 +- tests/test_dangling_hook_target_check.py | 2 +- ...est_runtime_tracked_under_global_ignore.py | 2 +- 5 files changed, 84 insertions(+), 75 deletions(-) create mode 100644 src/chock/validation/checks_hook_targets.py diff --git a/src/chock/validation/checks_hook_targets.py b/src/chock/validation/checks_hook_targets.py new file mode 100644 index 00000000..2840ab4e --- /dev/null +++ b/src/chock/validation/checks_hook_targets.py @@ -0,0 +1,81 @@ +"""Hook targets: every file a chock-written agent hook runs must exist here and in every clone.""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + +from chock import vendors +from chock.hooks.in_agent_install import WIRED_VENDORS, agent_hooks_rel +from chock.validation.report import Finding, Report + +#: A file an agent hook command runs or hands the gate: the runtime (`.chock/bin/.py`) +#: and the compiled gate it reads. chock is the only writer of both directories, so naming a +#: path under them identifies an entry as chock's own, independent of the vendor-specific shapes +#: `in_agent_merged.py`/`in_agent_generic.py` merge it through. +_BIN_TARGET_RE = re.compile(r"\.chock/(?:bin/[\w.-]+\.py|compiled/[\w./-]+\.json)") + + +def _ignore_rule(root: Path, rel: str) -> str | None: + """The rule git would ignore `rel` by (`source:line:pattern`), or None; None outside a repo. + + A tracked file is never ignored, so this names only a file a clone would not have. Whether + it is ignored is asked plainly: `check-ignore -v` also reports a path a `!` negation + re-includes, and exits 0 for it, so it only names the rule once the answer is yes. + """ + git = shutil.which("git") + if git is None: + return None + base = [git, "-C", str(root), "check-ignore"] + ignored = subprocess.run([*base, "-q", "--", rel], capture_output=True, check=False) # noqa: S603 -- asking git + if ignored.returncode != 0: + return None + named = subprocess.run([*base, "-v", "--", rel], capture_output=True, text=True, check=False) # noqa: S603 + return named.stdout.strip().split("\t", 1)[0] or "a gitignore rule" + + +def _dangling_reason(root: Path, target: str) -> str | None: + """Why a hook naming `target` fails -- here, or in every clone -- or None when it does not.""" + rule = _ignore_rule(root, target) + if rule is not None: + return ( + f"is ignored by git ({rule}), so a clone, a teammate's checkout or CI has no such file " + "and the hook fails there. A global `bin/` rule is the usual cause: `chock sync` adds " + "`!.chock/bin/` and `!.chock/compiled/` to .gitignore; commit them and the files." + ) + if not (root / target).exists(): + return "does not exist. Run `chock sync` to reinstall or uninstall this vendor's hooks." + return None + + +def check_dangling_hook_targets(root: Path, report: Report) -> None: + """A chock-written hook entry naming a file that is missing, or that git would not keep. + + `sync` wires in-agent hooks only for the vendors `supported_agents` names and prunes a + vendored runtime once its vendor falls out of that list (`recompile.py`); a hook config + still naming the deleted runtime is a client-side failure on every tool call, silent to + both `chock sync --check` and `chock check` unless something reads the config back. A + runtime that exists here but is git-ignored fails the same way in every other checkout: + there, no hook can start the gate at all. + """ + paths = [root / vendors.config_path(vendor) for vendor in WIRED_VENDORS] + paths.append(root / agent_hooks_rel()) + + seen: set[tuple[str, str]] = set() + for path in paths: + if not path.exists(): + continue + try: + text = path.read_text(encoding="utf-8") + except OSError: + continue + for target in _BIN_TARGET_RE.findall(text): + key = (str(path), target) + if key in seen: + continue + seen.add(key) + reason = _dangling_reason(root, target) + if reason is not None: + report.add(Finding(str(path), "dangling_hook_target", "error", f"{path} runs {target}, which {reason}")) diff --git a/src/chock/validation/checks_repo.py b/src/chock/validation/checks_repo.py index d18e5fbf..e6972f5e 100644 --- a/src/chock/validation/checks_repo.py +++ b/src/chock/validation/checks_repo.py @@ -10,21 +10,12 @@ import yaml -from chock import vendors -from chock.hooks.in_agent_install import WIRED_VENDORS, agent_hooks_rel from chock.index.builder import max_tokens_for from chock.index.cli import is_stale from chock.scaffold.agents_md import POINTER_BLOCK, POINTER_END, POINTER_START from chock.validation.loading import discover_artifacts from chock.validation.report import Finding, Report -#: `.chock/bin/.py`, wherever it turns up inside a hook command: chock is the only -#: writer of that directory, so naming a path under it identifies an entry as chock's own, -#: independent of the vendor-specific shapes `in_agent_merged.py`/`in_agent_generic.py` merge -#: it through. -#: A file an agent hook command runs or hands the gate: the runtime, and the compiled gate it reads. -_BIN_TARGET_RE = re.compile(r"\.chock/(?:bin/[\w.-]+\.py|compiled/[\w./-]+\.json)") - _POINTER_RE = re.compile( re.escape(POINTER_START) + r"(.*?)" + re.escape(POINTER_END), re.DOTALL, @@ -233,69 +224,6 @@ def _tracked_under(root: Path, rel: str) -> list[str]: return result.stdout.split() if result.returncode == 0 else [] -def _ignore_rule(root: Path, rel: str) -> str | None: - """The rule git would ignore `rel` by (`source:line:pattern`), or None; None outside a repo. - - A tracked file is never ignored, so this names only a file a clone would not have. Whether - it is ignored is asked plainly: `check-ignore -v` also reports a path a `!` negation - re-includes, and exits 0 for it, so it only names the rule once the answer is yes. - """ - git = shutil.which("git") - if git is None: - return None - base = [git, "-C", str(root), "check-ignore"] - ignored = subprocess.run([*base, "-q", "--", rel], capture_output=True, check=False) # noqa: S603 -- asking git - if ignored.returncode != 0: - return None - named = subprocess.run([*base, "-v", "--", rel], capture_output=True, text=True, check=False) # noqa: S603 - return named.stdout.strip().split("\t", 1)[0] or "a gitignore rule" - - -def _dangling_reason(root: Path, target: str) -> str | None: - """Why a hook naming `target` fails -- here, or in every clone -- or None when it does not.""" - rule = _ignore_rule(root, target) - if rule is not None: - return ( - f"is ignored by git ({rule}), so a clone, a teammate's checkout or CI has no such file " - "and the hook fails there. A global `bin/` rule is the usual cause: `chock sync` adds " - "`!.chock/bin/` and `!.chock/compiled/` to .gitignore; commit them and the files." - ) - if not (root / target).exists(): - return "does not exist. Run `chock sync` to reinstall or uninstall this vendor's hooks." - return None - - -def check_dangling_hook_targets(root: Path, report: Report) -> None: - """A chock-written hook entry naming a file that is missing, or that git would not keep. - - `sync` wires in-agent hooks only for the vendors `supported_agents` names and prunes a - vendored runtime once its vendor falls out of that list (`recompile.py`); a hook config - still naming the deleted runtime is a client-side failure on every tool call, silent to - both `chock sync --check` and `chock check` unless something reads the config back. A - runtime that exists here but is git-ignored fails the same way in every other checkout: - there, no hook can start the gate at all. - """ - paths = [root / vendors.config_path(vendor) for vendor in WIRED_VENDORS] - paths.append(root / agent_hooks_rel()) - - seen: set[tuple[str, str]] = set() - for path in paths: - if not path.exists(): - continue - try: - text = path.read_text(encoding="utf-8") - except OSError: - continue - for target in _BIN_TARGET_RE.findall(text): - key = (str(path), target) - if key in seen: - continue - seen.add(key) - reason = _dangling_reason(root, target) - if reason is not None: - report.add(Finding(str(path), "dangling_hook_target", "error", f"{path} runs {target}, which {reason}")) - - def check_ambient_token_budget(root: Path, report: Report) -> None: """Spec F2: the attention surface in INDEX.md must fit index.max_tokens (default 2000).""" index_path = root / ".agents" / "policies" / "INDEX.md" diff --git a/src/chock/validation/engine.py b/src/chock/validation/engine.py index 13cf98e2..522e3318 100644 --- a/src/chock/validation/engine.py +++ b/src/chock/validation/engine.py @@ -30,6 +30,7 @@ check_registry_freshness, ) from chock.validation.checks_evals import check_eval_first +from chock.validation.checks_hook_targets import check_dangling_hook_targets from chock.validation.checks_manifest_advice import check_manifest_advice from chock.validation.checks_manifest_schema import check_manifest_schema from chock.validation.checks_orchestration import ( @@ -44,7 +45,6 @@ check_adapter_integrity, check_ambient_rule_blocks, check_ambient_token_budget, - check_dangling_hook_targets, check_gate_log_untracked, check_release_consistency, ) diff --git a/tests/test_dangling_hook_target_check.py b/tests/test_dangling_hook_target_check.py index 7eca089d..713ca2e4 100644 --- a/tests/test_dangling_hook_target_check.py +++ b/tests/test_dangling_hook_target_check.py @@ -11,7 +11,7 @@ from conftest import baseline_policy, init_repo from chock.scaffold.recompile import recompile -from chock.validation.checks_repo import check_dangling_hook_targets +from chock.validation.checks_hook_targets import check_dangling_hook_targets from chock.validation.report import Report from chock.vendors import CHOCK_AGENT diff --git a/tests/test_runtime_tracked_under_global_ignore.py b/tests/test_runtime_tracked_under_global_ignore.py index eefed513..c866b018 100644 --- a/tests/test_runtime_tracked_under_global_ignore.py +++ b/tests/test_runtime_tracked_under_global_ignore.py @@ -18,7 +18,7 @@ from chock.scaffold.gitrules import GATE_LOG_IGNORE, TRACKED_RUNTIME, ensure_git_rules from chock.scaffold.recompile import recompile -from chock.validation.checks_repo import check_dangling_hook_targets +from chock.validation.checks_hook_targets import check_dangling_hook_targets from chock.validation.report import Report RUNTIME = ".chock/bin/claude_code.py"