feat(rules): retrieval honours a rule's home — global everywhere, a project's rules only in that project (#4074)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 31s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 31s
semantic_search_rules searched every rule the user owned, and every hook arm called it without a project, so each project's rules were injected into every other project's sessions and a project rule meant nothing a session could feel. The search now takes a scope: global rules by default (an unbound session, or a caller that forgets to say), global plus project N when given project_id (N's rules only if the caller can read that project, through access.can_read_project), and every owned rule with everywhere=True. The four hook arms and the report preference lookup pass the session's project; an explicit search(content_type="rule") scopes to its project_id, or asks the whole rulebook without one. Milestone 414 step 1. Guarded by an AST walk that every hook call site passes project_id, and an integration test on real Postgres that a rule is reached only from its home. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -1625,3 +1625,72 @@ async def test_an_act_arm_reports_the_bar_it_actually_searched_at():
|
||||
if c.kwargs.get("source") == "pre_tool_rule"]
|
||||
assert len(rows) == 1
|
||||
assert rows[0].kwargs["threshold"] == search.await_args.kwargs["threshold"]
|
||||
|
||||
|
||||
# ── scope: a session gets global rules plus its own project's (milestone 414) ──
|
||||
|
||||
|
||||
def test_every_hook_rule_search_says_which_project_it_is_for():
|
||||
"""Every call site passes `project_id`, walked rather than grepped (rule 167).
|
||||
|
||||
semantic_search_rules defaults to GLOBAL rules only, so an arm that forgets
|
||||
the keyword does not leak another project's rules — it quietly stops
|
||||
surfacing its own project's. That is the failure this pins, and it is
|
||||
silent in a session: nothing errors, a project rule just never arrives.
|
||||
`everywhere` is not an acceptable answer in a hook, which speaks unasked.
|
||||
"""
|
||||
sources = {
|
||||
"src/scribe/services/plugin_context.py": 4,
|
||||
"src/scribe/services/reply_preferences.py": 1,
|
||||
}
|
||||
for path, expected in sources.items():
|
||||
calls = [
|
||||
n for n in ast.walk(ast.parse(Path(path).read_text()))
|
||||
if isinstance(n, ast.Call)
|
||||
and getattr(n.func, "id", None) == "semantic_search_rules"
|
||||
]
|
||||
# The count is what lets this fail: a new arm is a new call site, and
|
||||
# it must be looked at rather than slip past a guard that only checks
|
||||
# the calls it already knew about.
|
||||
assert len(calls) == expected, (
|
||||
f"{path} has {len(calls)} rule searches, expected {expected} — a new "
|
||||
f"arm must decide its scope; update this count once it passes project_id"
|
||||
)
|
||||
for call in calls:
|
||||
keywords = {k.arg for k in call.keywords}
|
||||
assert "project_id" in keywords, (
|
||||
f"{path}:{call.lineno} searches rules without project_id, so it "
|
||||
f"gets global rules only and never its own project's"
|
||||
)
|
||||
assert "everywhere" not in keywords, (
|
||||
f"{path}:{call.lineno} searches every project's rules from a hook"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("bound, scope", [(7, 7), (0, None)])
|
||||
async def test_the_act_arms_scope_their_search_to_the_bound_project(bound, scope):
|
||||
"""A bound session searches its own project; an unbound one (0) searches
|
||||
global rules only, which the search spells as `project_id=None`."""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
cfg = {"enabled": True, "threshold": 0.6, "top_k": 3,
|
||||
"rule_threshold": 0.6, "tool_rule_threshold": 0.6}
|
||||
tool_search = AsyncMock(return_value=[])
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch.object(
|
||||
pc, "get_writepath_config", AsyncMock(return_value=cfg)))
|
||||
stack.enter_context(patch.object(pc, "semantic_search_rules", tool_search))
|
||||
stack.enter_context(patch.object(pc, "record_retrieval", MagicMock()))
|
||||
stack.enter_context(patch.object(pc, "record_rule_surfaced", MagicMock()))
|
||||
await pc.build_tool_rule_hint(1, "Bash", "git push origin dev", project_id=bound)
|
||||
assert tool_search.await_args.kwargs["project_id"] == scope
|
||||
|
||||
prompt_search = AsyncMock(return_value=[])
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch.object(pc, "get_setting", AsyncMock(return_value="0.6")))
|
||||
stack.enter_context(patch.object(pc, "semantic_search_rules", prompt_search))
|
||||
stack.enter_context(patch.object(pc, "record_retrieval", MagicMock()))
|
||||
stack.enter_context(patch.object(pc, "record_rule_surfaced", MagicMock()))
|
||||
await pc.build_prompt_rule_hint(1, "please merge to main", project_id=bound)
|
||||
assert prompt_search.await_args.kwargs["project_id"] == scope
|
||||
|
||||
Reference in New Issue
Block a user