From dd08f9858cb2c32c2f6a66ecb1f27a82b202a945 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 16 Sep 2026 08:34:55 -0400 Subject: [PATCH] fix(tests): the rule-33 contract follows the scope key into the shared helper (#4085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two interface-contract tests read each hook's own source for the query args it sends. The project-scope key now comes from scribe_scope_query, so `repo=` was no longer spelled in the hook and both read that as the hook having stopped sending it. They now pin the pair: the hook delegates to the helper, and the helper emits exactly `repo=` and `project_id=`. That is a stronger assertion than the one it replaces — it catches a hook rolling its own scope resolution again, and it pins the new key, which the route must read for a non-git session to reach its project at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- tests/test_rule_usage_wiring.py | 17 +++++++++++++++-- tests/test_write_path_trigger.py | 10 ++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/tests/test_rule_usage_wiring.py b/tests/test_rule_usage_wiring.py index 952b29b..d589587 100644 --- a/tests/test_rule_usage_wiring.py +++ b/tests/test_rule_usage_wiring.py @@ -735,14 +735,27 @@ def test_the_hook_and_the_route_agree_on_every_parameter_name(): import re hook = Path("plugin/hooks/scribe_tool_rules.sh").read_text() + defs = Path("plugin/hooks/scribe_defs.sh").read_text() route = Path("src/scribe/routes/plugin.py").read_text() handler = route.split("async def pre_tool_rules")[1].split("\n@plugin_bp")[0] sent = set(re.findall(r"[?&]([a-z_]+)=", hook)) - assert sent == {"tool", "command", "repo", "exclude_rule_ids"}, sent + assert sent == {"tool", "command", "exclude_rule_ids"}, sent - # `repo` is read by the shared _project_scope() helper, not inline. + # The project-scope key is the shared helper's to choose (#4085): the hook + # splices in `scribe_scope_query`'s output, which is `repo=` inside a git + # repo and `project_id=` where a `.scribe` marker names the project. Both + # halves are one contract with the route, so both are pinned. + assert "scribe_scope_query" in hook, "hook no longer asks for a project scope" + assert set(re.findall(r"printf '([a-z_]+)=", defs)) == {"repo", "project_id"} + + # Both scope keys are read by the shared _project_scope() helper, not inline. assert "_project_scope()" in handler + scope = route.split("async def _project_scope")[1].split("\n@plugin_bp")[0] + for arg in ("project_id", "repo"): + assert f'request.args.get("{arg}"' in scope, ( + f"the hooks can send {arg!r} and the route never reads it" + ) for arg in ("tool", "command", "exclude_rule_ids"): assert f'request.args.get("{arg}")' in handler, ( f"the hook sends {arg!r} and the route never reads it" diff --git a/tests/test_write_path_trigger.py b/tests/test_write_path_trigger.py index d6dd323..84af7cd 100644 --- a/tests/test_write_path_trigger.py +++ b/tests/test_write_path_trigger.py @@ -931,10 +931,16 @@ def test_route_reads_every_arg_the_hook_sends(): assert f'request.args.get("{arg}"' in src, f"route ignores {arg}" hook = HOOK.read_text() - for arg in ("path=", "code=", "repo=", "exclude_ids=", "exclude_sync_ids=", - "shapes="): + for arg in ("path=", "code=", "exclude_ids=", "exclude_sync_ids=", "shapes="): assert arg in hook, f"hook never sends {arg}" + # The project-scope key is no longer spelled in the hook: since #4085 it is + # whichever of `repo=` / `project_id=` scribe_scope_query picks, so the hook + # splices in its output and the helper is the other half of the contract. + assert "scribe_scope_query" in hook, "hook no longer asks for a project scope" + defs = (HOOK.parent / "scribe_defs.sh").read_text() + assert set(re.findall(r"printf '([a-z_]+)=", defs)) == {"repo", "project_id"} + def test_route_resolves_repo_to_a_project_not_to_a_location_filter(): """A snippet's `repo` is a label the operator typed; the hook sends a git