feat(rules): both doors carry the trigger, the tier, the areas and the edges (#3029, milestone 307 step 3, surfaces)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 24s
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 24s
MCP and REST both gain when_to_apply / tier / system_ids / arose_from_id on create and update, plus relate_rules / unrelate_rules for the typed edges, and get_rule now returns a rule's areas and relations alongside it. rule_detail() is a SERVICE function, not one per door. It started as a copy in each — identical, and the prior-art hook flagged it immediately, which is the same lesson rules_payload (#2858) already recorded: a second copy drifts. Both doors call the one seam, so create, update and get cannot disagree about what a rule looks like coming back. The authoring guidance lands in create_rule's docstring rather than in a rule, per rule 119 as the operator described it: this is behaviour every instance should inherit, not one operator's preference. It states the test — ONE RULE = ONE THING YOU COULD VIOLATE. Rules that FAIL TOGETHER get linked with relate_rules(kind="co_surfaces"), never merged into one row. — and names why merging loses: a merged rule cannot be cited, surfaced or suppressed a clause at a time, and it grows without limit because adding to it is always cheaper than adding a rule. create_project_rule says the same about "overrides", which is what FabledCurator's 85/86 should have been instead of near-copies that drift from their parent. The tier arg carries the test itself: can you name the trigger WITHOUT naming a system, an artifact type or a moment? If the honest answer is "whenever you are working", it is always_on. Tests: the applicable-rules cases fabricated raw tuples matching the old column lists, so they move to the entity shape via fake_rule; new cases pin rule_brief (a DATE not a stamp, the depth left to get_rule, no null keys) and that an unknown tier falls back to BINDING. fake_rule gains when_to_apply / tier / arose_from_id for the note-2109 reason the helper exists: unnamed, they would be truthy MagicMocks. The tool tests stub the new rule_detail seam — they are about argument forwarding and have no database. The module header's "Sixteen tools" had been wrong for two milestones; the registration count test is what actually catches that, so the header now says so instead of carrying a number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
Mirrors the pattern in tests/test_events_service.py.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -206,8 +207,10 @@ async def test_get_applicable_rules_returns_shape():
|
||||
sub_result.all.return_value = [(1, "FabledSword family")]
|
||||
rules_result = MagicMock()
|
||||
rules_result.all.return_value = [
|
||||
# (rule_id, title, statement, topic_id, topic_title, rulebook_id, rulebook_title)
|
||||
(i, f"Rule {i}", f"Statement {i}", 2, "git-workflow", 1, "FabledSword family")
|
||||
# The query selects the ENTITY plus three labels, so rule_brief stays
|
||||
# the one place deciding what a surfaced rule carries (note 3026).
|
||||
(fake_rule(id=i, title=f"Rule {i}", statement=f"Statement {i}", topic_id=2),
|
||||
"git-workflow", 1, "FabledSword family")
|
||||
for i in range(50)
|
||||
]
|
||||
# Execute order: sub_q, suppressed_rules_q, suppressed_topics_q, rules_q, proj_rules_q
|
||||
@@ -244,7 +247,7 @@ async def test_get_applicable_rules_truncates_when_over_limit():
|
||||
sub_result.all.return_value = []
|
||||
rules_result = MagicMock()
|
||||
rules_result.all.return_value = [
|
||||
(i, f"r{i}", "stmt", 2, "topic", 1, "rb") for i in range(51)
|
||||
(fake_rule(id=i, title=f"r{i}"), "topic", 1, "rb") for i in range(51)
|
||||
]
|
||||
mock_session.execute = AsyncMock(side_effect=[
|
||||
sub_result, _empty(), _empty(), rules_result, _empty(),
|
||||
@@ -265,8 +268,10 @@ async def test_get_applicable_rules_includes_project_scoped_rules():
|
||||
mock_session = make_mock_session()
|
||||
proj_rules_result = MagicMock()
|
||||
proj_rules_result.all.return_value = [
|
||||
(100, "Use alembic", "Always run migrations via alembic, never raw SQL."),
|
||||
(101, "PR-bound", "Land schema changes in their own PR."),
|
||||
(fake_rule(id=100, topic_id=None, project_id=3, title="Use alembic",
|
||||
statement="Always run migrations via alembic, never raw SQL."),),
|
||||
(fake_rule(id=101, topic_id=None, project_id=3, title="PR-bound",
|
||||
statement="Land schema changes in their own PR."),),
|
||||
]
|
||||
mock_session.execute = AsyncMock(side_effect=[
|
||||
_empty(), _empty(), _empty(), _empty(), proj_rules_result,
|
||||
@@ -311,3 +316,52 @@ async def test_get_applicable_rules_surfaces_suppressed_with_context():
|
||||
assert result["suppressed_rules"][0]["rulebook_title"] == "FabledSword family"
|
||||
assert len(result["suppressed_topics"]) == 1
|
||||
assert result["suppressed_topics"][0]["title"] == "design-system"
|
||||
|
||||
|
||||
# ── rule_brief + tier (milestone 307) ───────────────────────────────────
|
||||
|
||||
def test_rule_brief_carries_age_but_not_the_deep_fields():
|
||||
"""The shape a SURFACED rule takes, and the reason it exists.
|
||||
|
||||
There were three hand-written copies of this dict and they had already
|
||||
diverged — none carried the timestamps the model has always held, which is
|
||||
why a rule written before the capability it duplicates was
|
||||
indistinguishable at read time from one still doing work (note 3026).
|
||||
"""
|
||||
from scribe.services.rulebooks import rule_brief
|
||||
|
||||
out = rule_brief(fake_rule(
|
||||
when_to_apply="before any git push",
|
||||
updated_at=datetime(2026, 6, 1, 14, 30, tzinfo=timezone.utc),
|
||||
))
|
||||
assert out["when_to_apply"] == "before any git push"
|
||||
assert out["tier"] == "always_on"
|
||||
# A DATE, not a stamp: the question is "how old is this", and a full ISO
|
||||
# string across the always-on set is ~2k characters of payload.
|
||||
assert out["updated_at"] == "2026-06-01"
|
||||
# The depth stays with get_rule — putting it in every listing is the bloat
|
||||
# this milestone is about.
|
||||
assert "why" not in out and "how_to_apply" not in out
|
||||
|
||||
|
||||
def test_rule_brief_omits_keys_a_rule_has_no_value_for():
|
||||
"""#2483: a null key reads as a capability the record has and isn't using,
|
||||
which is a different claim from not having one."""
|
||||
from scribe.services.rulebooks import rule_brief
|
||||
|
||||
out = rule_brief(fake_rule())
|
||||
assert "when_to_apply" not in out
|
||||
assert "arose_from_id" not in out
|
||||
|
||||
|
||||
def test_an_unknown_tier_falls_back_to_binding():
|
||||
"""The asymmetry that decides the direction: a rule that preloads when it
|
||||
needn't costs context; a rule that quietly stops preloading costs the
|
||||
behaviour it was written for. So a typo binds."""
|
||||
from scribe.services.rulebooks import _valid_tier
|
||||
|
||||
assert _valid_tier("conditional") == "conditional"
|
||||
assert _valid_tier("always_on") == "always_on"
|
||||
assert _valid_tier("Conditional") == "always_on"
|
||||
assert _valid_tier("") == "always_on"
|
||||
assert _valid_tier("occasionally") == "always_on"
|
||||
|
||||
Reference in New Issue
Block a user