feat(rules)!: retire rulebook subscriptions and per-project suppressions (#4052)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m3s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m3s
CI & Build / Build & push image (push) Skipped
A rule's home is its scope now: a rule in a rulebook topic is global, a rule on a project applies to that project, and retrieval reads that directly (#4074). A subscription had stopped changing anything a session received; a suppression muted rules from a subscription. Operator, 2026-09-15: "we have global and project scoped rules, we don't need the subscriptions now." What goes, whole (rule 22): - Migration 0101 drops project_rulebook_subscriptions, project_rule_suppressions and project_topic_suppressions, and strips subscribe_rulebooks (and 394's leftover exclude_always_on_rulebooks) from stored inception choices. - Service, MCP and REST: subscribe/unsubscribe and the four suppress/unsuppress operations. The Subscribers checklist, the subscribe chips, the skip buttons and the Suppressed section in the rules UI. - Inception asks two questions (design system, seed Systems). create_project and decide_project_inception lose subscribe_rulebooks. - Backup v15 stops exporting the three sections; older archives still restore, the keys simply unread. Trash no longer hard-deletes suppression rows. What changes meaning: - get_applicable_rules is a project's LISTING: its own rules, plus the global rules tagged to an area it works in. Untagged global rules apply everywhere and arrive by retrieval, so they are not listed. A co_surfaces partner on a different project is not dragged in. - list_rules(project_id) lists that project's own rules. - rules_payload drops subscribed_rulebooks and suppressed_*; the handshake's brief form is project_rules alone. - using-scribe's "Where a new rule goes" and inception sections, tool docstrings and docs say global vs project. Plugin 2026.09.15.1620. Milestone 414 step 2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
+10
-14
@@ -3,10 +3,9 @@
|
||||
The WHY a project inherits what it does lives on projects.inception. Pure
|
||||
validation is pinned here; the effects are step 3's integration tests.
|
||||
|
||||
The opt-out of an always-on rulebook had its own association table until
|
||||
milestone 394. With no always-on tier there is nothing to opt out OF — a
|
||||
rulebook binds a project only by subscription — so the table and the test
|
||||
that pinned its shape both went with it.
|
||||
Rules left inception in two steps: the always-on opt-out with the tier
|
||||
(milestone 394), and the rulebook subscription with subscriptions (milestone
|
||||
414). A rule in a rulebook is global, so there is nothing to choose.
|
||||
"""
|
||||
from scribe.models.project import Project
|
||||
from scribe.services.inception import (
|
||||
@@ -28,10 +27,9 @@ def test_project_carries_an_inception_record_and_to_dict_shows_it():
|
||||
|
||||
|
||||
def test_validate_inception_pins_the_choice_vocabulary():
|
||||
assert CHOICE_KEYS == ("subscribe_rulebooks", "design_system_id", "seed_systems")
|
||||
assert CHOICE_KEYS == ("design_system_id", "seed_systems")
|
||||
assert validate_inception({}) is None
|
||||
assert validate_inception({"subscribe_rulebooks": [2],
|
||||
"design_system_id": 3, "seed_systems": True}) is None
|
||||
assert validate_inception({"design_system_id": 3, "seed_systems": True}) is None
|
||||
assert validate_inception({"design_system_id": None}) is None
|
||||
assert "must be an object" in validate_inception([])
|
||||
assert "unknown inception choice" in validate_inception({"repo": "x"})
|
||||
@@ -41,19 +39,17 @@ def test_validate_inception_pins_the_choice_vocabulary():
|
||||
# believe a rulebook had been declined.
|
||||
assert "unknown inception choice" in validate_inception(
|
||||
{"exclude_always_on_rulebooks": [1]})
|
||||
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]})
|
||||
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]})
|
||||
# Same for the subscription choice since milestone 414.
|
||||
assert "unknown inception choice" in validate_inception({"subscribe_rulebooks": [2]})
|
||||
assert "positive id or null" in validate_inception({"design_system_id": 0})
|
||||
assert "positive id or null" in validate_inception({"design_system_id": True})
|
||||
assert "true or false" in validate_inception({"seed_systems": "yes"})
|
||||
|
||||
|
||||
def test_normalize_choices_is_canonical_and_complete():
|
||||
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3]})
|
||||
assert out == {"subscribe_rulebooks": [1, 3],
|
||||
"design_system_id": None, "seed_systems": False}
|
||||
assert normalize_choices(None) == {"subscribe_rulebooks": [],
|
||||
"design_system_id": None, "seed_systems": False}
|
||||
out = normalize_choices({"design_system_id": 4})
|
||||
assert out == {"design_system_id": 4, "seed_systems": False}
|
||||
assert normalize_choices(None) == {"design_system_id": None, "seed_systems": False}
|
||||
|
||||
|
||||
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Real-Postgres integration tests for project inception (milestone 297).
|
||||
|
||||
What mocks can't prove: a decision's effects land through the real services
|
||||
(exclusions filter the always-on set, subscriptions bind, the design system
|
||||
points, the standard Systems seed once), the record is written last, a bad
|
||||
target applies nothing.
|
||||
(the design system points, the standard Systems seed once), the record is
|
||||
written last, a bad target applies nothing.
|
||||
|
||||
Rules are not part of inception since milestone 414: a rule in a rulebook is
|
||||
global and applies to every project, so there is nothing to subscribe to.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -11,7 +13,6 @@ import pytest_asyncio
|
||||
from scribe.models import async_session
|
||||
from scribe.models.project import Project
|
||||
from scribe.services import inception as inception_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import canonical_systems as canonical_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from tests.helpers import ensure_user
|
||||
@@ -21,8 +22,7 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seeded():
|
||||
"""Owner, a fresh project, one always-on rulebook (with a rule) and one
|
||||
ordinary rulebook (with a rule)."""
|
||||
"""Owner and a fresh, undecided project."""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "inception_owner")
|
||||
project = Project(user_id=owner.id, title="Inception target")
|
||||
@@ -30,56 +30,33 @@ async def seeded():
|
||||
await s.flush()
|
||||
ids = {"owner": owner.id, "pid": project.id}
|
||||
await s.commit()
|
||||
# Two ordinary rulebooks. One was flagged always-on until milestone 394
|
||||
# removed the tier; a rulebook now reaches a project only by subscription,
|
||||
# so what used to be "binds automatically" and "binds if you opt in" are
|
||||
# the same kind of thing.
|
||||
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
||||
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
|
||||
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
||||
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
|
||||
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
|
||||
await rulebooks_svc.create_rule(t2.id, ids["owner"], "Write the why", "Record reasons.")
|
||||
ids.update({"always": always.id, "other": other.id})
|
||||
return ids
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
# Undecided: nothing binds, because nothing has been subscribed. Before
|
||||
# milestone 394 the always-on rulebook bound here without being asked for,
|
||||
# and this assertion read the other way round.
|
||||
defaults = await inception_svc.current_defaults(owner, pid)
|
||||
assert sorted(r["id"] for r in defaults["rulebooks"]) == sorted(
|
||||
[seeded["always"], seeded["other"]])
|
||||
assert set(defaults) == {"design_system_id", "design_systems", "systems"}
|
||||
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
|
||||
assert (await rulebooks_svc.get_applicable_rules(pid, owner))["rules"] == []
|
||||
|
||||
out = await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||
"subscribe_rulebooks": [seeded["other"]],
|
||||
"design_system_id": None,
|
||||
"seed_systems": True,
|
||||
})
|
||||
assert out["effects"]["subscribed"] == [seeded["other"]]
|
||||
assert set(out["effects"]) == {"design_system_id", "systems_seeded"}
|
||||
catalog = await canonical_svc.list_canonical_systems()
|
||||
assert len(out["effects"]["systems_seeded"]) == len(catalog)
|
||||
# Seeded Systems come out mapped, not needing a later reconciliation.
|
||||
seeded_systems = await systems_svc.list_systems(owner, pid)
|
||||
assert all(s.canonical_id is not None for s in seeded_systems)
|
||||
|
||||
# The subscription is what binds, and it is the ONLY thing that does —
|
||||
# the unsubscribed rulebook contributes nothing even though it used to
|
||||
# bind every project by default.
|
||||
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
|
||||
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
|
||||
assert "dev is home" not in [r["title"] for r in applicable["rules"]]
|
||||
# The record, written last, says why.
|
||||
async with async_session() as s:
|
||||
project = await s.get(Project, pid)
|
||||
assert inception_svc.is_decided(project)
|
||||
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
|
||||
assert project.inception["choices"]["subscribe_rulebooks"] == [seeded["other"]]
|
||||
assert project.inception["choices"] == {"design_system_id": None, "seed_systems": True}
|
||||
# Re-deciding with seed again mints nothing twice.
|
||||
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
||||
assert again["effects"]["systems_seeded"] == []
|
||||
@@ -89,15 +66,20 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||
@pytest.mark.integration
|
||||
async def test_a_bad_decision_applies_nothing(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
# A subscription to a rulebook that is not yours is refused BEFORE any
|
||||
# effect lands — the seed must not happen on a decision that fails.
|
||||
# A design system that is not readable is refused BEFORE any effect
|
||||
# lands — the seed must not happen on a decision that fails.
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||
"subscribe_rulebooks": [999999], "seed_systems": True,
|
||||
"design_system_id": 999999, "seed_systems": True,
|
||||
})
|
||||
assert await systems_svc.list_systems(owner, pid) == []
|
||||
# The retired rulebook choice is an unknown key, refused whole: a caller
|
||||
# still passing it must not believe a rulebook was subscribed.
|
||||
with pytest.raises(ValueError, match="unknown inception choice"):
|
||||
await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||
"subscribe_rulebooks": [1], "seed_systems": True,
|
||||
})
|
||||
assert await systems_svc.list_systems(owner, pid) == []
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await inception_svc.decide(owner, pid, via="mcp", choices={"subscribe_rulebooks": [999999]})
|
||||
with pytest.raises(ValueError, match="legacy"):
|
||||
await inception_svc.decide(owner, pid, via="legacy", choices={})
|
||||
async with async_session() as s:
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5,
|
||||
narrowed by 394).
|
||||
"""Real-Postgres tests for WHICH rules a project's listing names (milestone
|
||||
307 step 5, narrowed by 394 and 414).
|
||||
|
||||
What mocks can't prove, and what this design must not get wrong:
|
||||
A session receives rules by retrieval, scoped by a rule's home (see
|
||||
test_integration_rule_scope). This is the other surface — the listing a
|
||||
planning read carries — and what mocks can't prove about it:
|
||||
|
||||
1. A rule is invisible to a project that doesn't work in its area, and
|
||||
arrives — binding, not suggested — to one that does. Area matching is
|
||||
DETERMINISTIC: a tag comparison, never a similarity score.
|
||||
2. A `co_surfaces` partner arrives with its other half, which is the failure
|
||||
that made merging rule 144 into rule 46 look like the only fix.
|
||||
3. An explicit suppression outranks an edge.
|
||||
1. A global rule tagged to an area arrives in the listing of a project that
|
||||
works in that area, and not before. Area matching is DETERMINISTIC: a tag
|
||||
comparison, never a similarity score.
|
||||
2. An UNTAGGED global rule is not listed: it applies everywhere and arrives by
|
||||
retrieval, and listing every one under every project would say nothing.
|
||||
3. A `co_surfaces` partner arrives with its other half — the failure that made
|
||||
merging rule 144 into rule 46 look like the only fix — unless the partner
|
||||
lives on a different project, because an edge is not a way in.
|
||||
|
||||
TWO CLAIMS WERE DROPPED HERE BY MILESTONE 394, and it is worth saying which
|
||||
rather than leaving a shorter list. "A rule with no tier binds exactly as
|
||||
before" and "a conditional rule is reachable, not resident" were both about
|
||||
the always-on tier. There is no tier and no resident payload, so neither
|
||||
states anything that can now be true or false — they were not failing, they
|
||||
had stopped being claims.
|
||||
Milestone 414 dropped the suppression claim ("an explicit suppression outranks
|
||||
an edge") with suppressions themselves.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -32,40 +32,27 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def world():
|
||||
"""A project with two rulebooks — one subscribed, one not.
|
||||
|
||||
Both are ordinary rulebooks since milestone 394; the fixture used to flag
|
||||
one always-on because that was a second, separate way to reach a project.
|
||||
Keeping two is still worth it: a rulebook nobody subscribed to must
|
||||
contribute nothing, and a fixture with only the subscribed one could not
|
||||
tell "correctly scoped" from "returns everything".
|
||||
"""
|
||||
"""A project with one rule of its own, and a rulebook of global rules."""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "surfacing_owner")
|
||||
project = Project(user_id=owner.id, title="Surfacing target")
|
||||
s.add(project)
|
||||
elsewhere = Project(user_id=owner.id, title="Another project")
|
||||
s.add_all([project, elsewhere])
|
||||
await s.flush()
|
||||
ids = {"owner": owner.id, "pid": project.id}
|
||||
ids = {"owner": owner.id, "pid": project.id, "elsewhere": elsewhere.id}
|
||||
await s.commit()
|
||||
|
||||
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
||||
always_topic = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
||||
await rulebooks_svc.create_rule(
|
||||
always_topic.id, ids["owner"], "dev is home", "Work on dev.",
|
||||
)
|
||||
|
||||
book = await rulebooks_svc.create_rulebook(ids["owner"], "Subscribed practices")
|
||||
book = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
||||
topic = await rulebooks_svc.create_topic(book.id, ids["owner"], "release")
|
||||
plain = await rulebooks_svc.create_rule(
|
||||
topic.id, ids["owner"], "Between batches, keep stacking", "Keep going.",
|
||||
await rulebooks_svc.create_rule(
|
||||
topic.id, ids["owner"], "dev is home", "Work on dev.",
|
||||
when_to_apply="before pushing a branch",
|
||||
)
|
||||
await rulebooks_svc.subscribe_project(
|
||||
project_id=ids["pid"], rulebook_id=book.id, user_id=ids["owner"],
|
||||
own = await rulebooks_svc.create_project_rule(
|
||||
ids["pid"], ids["owner"], "Between batches, keep stacking", "Keep going.",
|
||||
when_to_apply="when a batch goes green",
|
||||
)
|
||||
ids.update({
|
||||
"always": always.id, "always_topic": always_topic.id,
|
||||
"book": book.id, "topic": topic.id, "plain": plain.id,
|
||||
})
|
||||
ids.update({"book": book.id, "topic": topic.id, "own": own.id})
|
||||
return ids
|
||||
|
||||
|
||||
@@ -74,12 +61,18 @@ async def _titles(ids) -> set[str]:
|
||||
return {r["title"] for r in applicable["rules"]}
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_an_untagged_global_rule_is_not_listed(world):
|
||||
"""It applies here — and everywhere — so naming it under this project says
|
||||
nothing a reader can act on. The project's own rule is always listed."""
|
||||
applicable = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
|
||||
assert "dev is home" not in await _titles(world)
|
||||
assert [r["title"] for r in applicable["project_rules"]] == [
|
||||
"Between batches, keep stacking"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world):
|
||||
async def test_a_tagged_global_rule_is_listed_for_a_project_that_works_in_its_area(world):
|
||||
"""The payoff: the tag match carries it in deterministically. The project
|
||||
reaches the area through its own System's canonical_id — its local NAME is
|
||||
irrelevant, which is the whole reason the catalog exists."""
|
||||
@@ -103,7 +96,7 @@ async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world):
|
||||
|
||||
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
|
||||
hit = [r for r in surfaced["rules"] if r["title"] == "Release tagging"]
|
||||
assert hit, "a tagged conditional rule must bind a project working in that area"
|
||||
assert hit, "a tagged global rule must be listed for a project working in that area"
|
||||
assert [s["name"] for s in hit[0]["systems"]] == ["CI & Release"]
|
||||
|
||||
|
||||
@@ -118,12 +111,8 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
|
||||
"A name decides nothing.",
|
||||
when_to_apply="when naming a build",
|
||||
)
|
||||
# TAGGED TO AN AREA THIS PROJECT DOES NOT WORK IN, which is what makes the
|
||||
# test able to fail at all. Since milestone 394 an UNTAGGED rule in a
|
||||
# subscribed rulebook applies on its own, so an untagged partner arrives
|
||||
# through the ordinary query and the edge is never exercised — the
|
||||
# assertion below passed while proving nothing, which is how this was
|
||||
# noticed. Tagging it puts it out of reach of everything except the edge.
|
||||
# Tagged to an area this project does NOT work in, so nothing but the edge
|
||||
# can bring it in — otherwise this test could pass without the edge.
|
||||
area = await canonical_svc.find_by_name("CI & Release")
|
||||
assert area is not None, "migration 0087 seeds the standard vocabulary"
|
||||
await rulebooks_svc.set_rule_systems(partner.id, world["owner"], [area.id])
|
||||
@@ -132,7 +121,7 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
|
||||
)
|
||||
|
||||
await rulebooks_svc.add_rule_relation(
|
||||
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||
world["owner"], world["own"], partner.id, "co_surfaces",
|
||||
note="they fail together",
|
||||
)
|
||||
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
|
||||
@@ -142,20 +131,16 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_a_suppression_outranks_an_edge(world):
|
||||
"""The edge says these belong together; the suppression says this project
|
||||
does not want that one. An explicit decision beats an inferred one."""
|
||||
# Untagged on purpose, unlike the partner above: this test is about the
|
||||
# SUPPRESSION winning, so the partner should be one that would otherwise
|
||||
# arrive by every available route — the ordinary query AND the edge.
|
||||
partner = await rulebooks_svc.create_rule(
|
||||
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
|
||||
async def test_an_edge_is_not_a_way_into_another_project(world):
|
||||
"""A partner that lives on a different project belongs to that project.
|
||||
The edge says the two fail together; it does not make one project's rule
|
||||
apply to another."""
|
||||
foreign = await rulebooks_svc.create_project_rule(
|
||||
world["elsewhere"], world["owner"], "Another project's rule", "Not here.",
|
||||
when_to_apply="working on the other project",
|
||||
)
|
||||
await rulebooks_svc.add_rule_relation(
|
||||
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||
)
|
||||
await rulebooks_svc.suppress_rule_for_project(
|
||||
world["pid"], partner.id, world["owner"],
|
||||
world["owner"], world["own"], foreign.id, "co_surfaces",
|
||||
)
|
||||
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
|
||||
assert "Muted partner" not in {r["title"] for r in surfaced["rules"]}
|
||||
assert "Another project's rule" not in {r["title"] for r in surfaced["rules"]}
|
||||
|
||||
@@ -78,7 +78,7 @@ async def test_get_milestone_returns_body_steps_and_rules():
|
||||
step = MagicMock()
|
||||
step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"}
|
||||
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
|
||||
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]}
|
||||
"project_rules": [{"id": 3, "title": "own"}]}
|
||||
with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone",
|
||||
AsyncMock(return_value=m)), \
|
||||
patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone_progress",
|
||||
|
||||
@@ -9,7 +9,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_planning_tool_delegates_to_service():
|
||||
payload = {"milestone": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [],
|
||||
payload = {"milestone": {"id": 5}, "applicable_rules": [], "project_rules": [],
|
||||
"applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0}
|
||||
with patch("scribe.mcp.tools.tasks.planning_svc.start_planning",
|
||||
AsyncMock(return_value=payload)) as mock:
|
||||
@@ -26,7 +26,7 @@ async def test_start_planning_tool_delegates_to_service():
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_task_augments_plan_with_rules():
|
||||
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
|
||||
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]}
|
||||
"project_rules": [{"id": 3, "title": "own"}]}
|
||||
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
|
||||
AsyncMock(return_value=(fake_task(task_kind="plan", id=9, project_id=3), "owner"))), \
|
||||
patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
|
||||
@@ -34,7 +34,8 @@ async def test_get_task_augments_plan_with_rules():
|
||||
from scribe.mcp.tools.tasks import get_task
|
||||
out = await get_task(task_id=9)
|
||||
assert out["applicable_rules"] == [{"id": 1, "title": "r"}]
|
||||
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}]
|
||||
assert out["project_rules"] == [{"id": 3, "title": "own"}]
|
||||
assert "subscribed_rulebooks" not in out
|
||||
assert out["applicable_rules_truncated"] is False
|
||||
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ async def test_get_project_enriches_with_milestone_summary():
|
||||
p = fake_project(id=5, title="found")
|
||||
milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}]
|
||||
applicable_payload = {
|
||||
"rules": [], "truncated": False, "subscribed_rulebooks": [],
|
||||
"rules": [], "truncated": False,
|
||||
}
|
||||
with patch(
|
||||
"scribe.mcp.tools.projects.projects_svc.get_project",
|
||||
@@ -91,9 +91,10 @@ async def test_get_project_enriches_with_milestone_summary():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
|
||||
async def test_get_project_includes_applicable_rules_and_project_rules():
|
||||
"""The augmented get_project response includes applicable_rules and
|
||||
subscribed_rulebooks pulled from services/rulebooks.get_applicable_rules.
|
||||
project_rules pulled from services/rulebooks.get_applicable_rules, and
|
||||
nothing about subscriptions (milestone 414).
|
||||
"""
|
||||
p = fake_project(id=3, title="Fabled Assistant")
|
||||
milestone_summary = []
|
||||
@@ -105,7 +106,6 @@ async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
|
||||
"rulebook_title": "FabledSword family"},
|
||||
],
|
||||
"truncated": False,
|
||||
"subscribed_rulebooks": [{"id": 1, "title": "FabledSword family"}],
|
||||
}
|
||||
with patch(
|
||||
"scribe.mcp.tools.projects.projects_svc.get_project",
|
||||
@@ -119,8 +119,10 @@ async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
|
||||
):
|
||||
out = await get_project(project_id=3)
|
||||
assert out["applicable_rules"][0]["title"] == "dev is home"
|
||||
assert out["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}]
|
||||
assert out["project_rules"] == []
|
||||
assert out["applicable_rules_truncated"] is False
|
||||
for gone in ("subscribed_rulebooks", "suppressed_rules", "suppressed_topics"):
|
||||
assert gone not in out, gone
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -173,7 +175,6 @@ async def test_enter_project_composes_full_context():
|
||||
"topic_title": "t", "rulebook_title": "rb"}],
|
||||
"project_rules": [{"id": 99, "title": "pr1", "statement": "ps"}],
|
||||
"truncated": False,
|
||||
"subscribed_rulebooks": [{"id": 2, "title": "rb"}],
|
||||
}
|
||||
milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}]
|
||||
|
||||
@@ -202,10 +203,9 @@ async def test_enter_project_composes_full_context():
|
||||
assert out["project"] == {"id": 5, "title": "P", "status": "active", "goal": ""}
|
||||
assert out["milestone_summary"] == milestone_summary
|
||||
# Rules arrive in full by retrieval; the handshake lists the project's own
|
||||
# by id and title and drops the subscription bookkeeping (#4045).
|
||||
# by id and title (#4045), and nothing about subscriptions (milestone 414).
|
||||
assert out["project_rules"] == [{"id": 99, "title": "pr1"}]
|
||||
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}]
|
||||
for gone in ("applicable_rules", "applicable_rules_truncated",
|
||||
for gone in ("applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks",
|
||||
"suppressed_rules", "suppressed_topics", "recent_notes"):
|
||||
assert gone not in out, gone
|
||||
assert out["open_tasks"] == [{
|
||||
@@ -243,8 +243,7 @@ async def test_enter_project_surfaces_the_systems_vocabulary():
|
||||
AsyncMock(return_value=p),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
|
||||
AsyncMock(return_value={"rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": []}),
|
||||
AsyncMock(return_value={"rules": [], "truncated": False}),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
|
||||
AsyncMock(return_value=[]),
|
||||
@@ -266,8 +265,7 @@ def _enter_project_stubs(p):
|
||||
patch("scribe.mcp.tools.projects.projects_svc.get_project",
|
||||
AsyncMock(return_value=p)),
|
||||
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
|
||||
AsyncMock(return_value={"rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": []})),
|
||||
AsyncMock(return_value={"rules": [], "truncated": False})),
|
||||
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
|
||||
AsyncMock(return_value=[])),
|
||||
patch("scribe.mcp.tools.projects.notes_svc.list_notes",
|
||||
@@ -357,8 +355,7 @@ async def test_enter_project_hands_back_the_design_system_when_the_project_has_o
|
||||
AsyncMock(return_value=p),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
|
||||
AsyncMock(return_value={"rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": []}),
|
||||
AsyncMock(return_value={"rules": [], "truncated": False}),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
|
||||
AsyncMock(return_value=[]),
|
||||
@@ -417,11 +414,10 @@ async def test_create_project_with_inception_args_decides_via_mcp():
|
||||
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
|
||||
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
|
||||
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
|
||||
out = await create_project(title="P", subscribe_rulebooks=[1], design_system_id=-1, seed_systems=True)
|
||||
out = await create_project(title="P", design_system_id=-1, seed_systems=True)
|
||||
kw = decide.await_args.kwargs
|
||||
assert decide.await_args.args[1] == 5 and kw["via"] == "mcp"
|
||||
assert kw["choices"] == {"subscribe_rulebooks": [1],
|
||||
"design_system_id": None, "seed_systems": True}
|
||||
assert kw["choices"] == {"design_system_id": None, "seed_systems": True}
|
||||
assert out["inception"]["via"] == "mcp" and "inception_effects" in out
|
||||
|
||||
|
||||
@@ -437,8 +433,7 @@ async def test_decide_project_inception_tool_records_an_inherit_all_decision_whe
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
|
||||
applicable = {"rules": [], "project_rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": []}
|
||||
applicable = {"rules": [], "project_rules": [], "truncated": False}
|
||||
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
|
||||
|
||||
async def run(project):
|
||||
@@ -471,6 +466,8 @@ def test_inception_routes_and_tool_are_registered():
|
||||
mcp = build_mcp_server()
|
||||
assert mcp._tool_manager.get_tool("decide_project_inception") is not None
|
||||
tool = mcp._tool_manager.get_tool("create_project")
|
||||
for name in ("subscribe_rulebooks", "design_system_id", "seed_systems"):
|
||||
for name in ("design_system_id", "seed_systems"):
|
||||
assert name in tool.parameters.get("properties", {}), name
|
||||
# Rules left inception with subscriptions (milestone 414).
|
||||
assert "subscribe_rulebooks" not in tool.parameters.get("properties", {})
|
||||
|
||||
|
||||
@@ -186,30 +186,6 @@ async def test_delete_rule_with_confirmed_soft_deletes():
|
||||
assert mock_delete.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_project_to_rulebook_calls_service():
|
||||
mock = AsyncMock()
|
||||
with patch(
|
||||
"scribe.mcp.tools.rulebooks.rulebooks_svc.subscribe_project", mock,
|
||||
):
|
||||
from scribe.mcp.tools.rulebooks import subscribe_project_to_rulebook
|
||||
out = await subscribe_project_to_rulebook(project_id=3, rulebook_id=1)
|
||||
assert out["subscribed"] is True
|
||||
assert mock.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_project_from_rulebook_calls_service():
|
||||
mock = AsyncMock()
|
||||
with patch(
|
||||
"scribe.mcp.tools.rulebooks.rulebooks_svc.unsubscribe_project", mock,
|
||||
):
|
||||
from scribe.mcp.tools.rulebooks import unsubscribe_project_from_rulebook
|
||||
out = await unsubscribe_project_from_rulebook(project_id=3, rulebook_id=1)
|
||||
assert out["subscribed"] is False
|
||||
assert mock.called
|
||||
|
||||
|
||||
def test_register_attaches_every_tool():
|
||||
"""Every tool in the module reaches the server.
|
||||
|
||||
@@ -227,7 +203,9 @@ def test_register_attaches_every_tool():
|
||||
# (milestone 399).
|
||||
# 28 since milestone 394 took list_always_on_rules and the two
|
||||
# always-on exclusion tools with the tier they served.
|
||||
assert len(mcp.names) == 28
|
||||
# 22 since milestone 414 retired subscriptions and suppressions: the two
|
||||
# subscribe tools and the four suppress/unsuppress tools.
|
||||
assert len(mcp.names) == 22
|
||||
# spot-check a few names
|
||||
assert "list_rulebooks" in mcp.names
|
||||
assert "create_rule" in mcp.names
|
||||
@@ -237,17 +215,17 @@ def test_register_attaches_every_tool():
|
||||
# get_preference to look for here.
|
||||
assert "create_preference" in mcp.names
|
||||
assert "update_preference" in mcp.names
|
||||
assert "subscribe_project_to_rulebook" in mcp.names
|
||||
assert "create_project_rule" in mcp.names
|
||||
assert "suppress_rule_for_project" in mcp.names
|
||||
# milestone 312: the sweep, and the stamp that answers it
|
||||
assert "rules_due_for_verification" in mcp.names
|
||||
assert "mark_rule_verified" in mcp.names
|
||||
# milestone 323: what a rule used to say
|
||||
assert "rule_history" in mcp.names
|
||||
assert "unsuppress_rule_for_project" in mcp.names
|
||||
assert "suppress_topic_for_project" in mcp.names
|
||||
assert "unsuppress_topic_for_project" in mcp.names
|
||||
# milestone 414: a rule's home is its scope; nothing subscribes or mutes.
|
||||
for gone in ("subscribe_project_to_rulebook", "unsubscribe_project_from_rulebook",
|
||||
"suppress_rule_for_project", "unsuppress_rule_for_project",
|
||||
"suppress_topic_for_project", "unsuppress_topic_for_project"):
|
||||
assert gone not in mcp.names
|
||||
|
||||
|
||||
|
||||
@@ -306,50 +284,6 @@ async def test_create_project_rule_uses_explicit_title_when_given():
|
||||
assert kwargs["title"] == "no auto-docstrings"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suppress_rule_for_project_passes_through():
|
||||
mock = AsyncMock(return_value=None)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.suppress_rule_for_project", mock):
|
||||
from scribe.mcp.tools.rulebooks import suppress_rule_for_project
|
||||
out = await suppress_rule_for_project(project_id=3, rule_id=17)
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
|
||||
assert out == {"project_id": 3, "rule_id": 17, "suppressed": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsuppress_rule_for_project_passes_through():
|
||||
mock = AsyncMock(return_value=None)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.unsuppress_rule_for_project", mock):
|
||||
from scribe.mcp.tools.rulebooks import unsuppress_rule_for_project
|
||||
out = await unsuppress_rule_for_project(project_id=3, rule_id=17)
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
|
||||
assert out == {"project_id": 3, "rule_id": 17, "suppressed": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suppress_topic_for_project_passes_through():
|
||||
mock = AsyncMock(return_value=None)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.suppress_topic_for_project", mock):
|
||||
from scribe.mcp.tools.rulebooks import suppress_topic_for_project
|
||||
out = await suppress_topic_for_project(project_id=3, topic_id=22)
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
|
||||
assert out == {"project_id": 3, "topic_id": 22, "suppressed": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsuppress_topic_for_project_passes_through():
|
||||
mock = AsyncMock(return_value=None)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.unsuppress_topic_for_project", mock):
|
||||
from scribe.mcp.tools.rulebooks import unsuppress_topic_for_project
|
||||
out = await unsuppress_topic_for_project(project_id=3, topic_id=22)
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
|
||||
assert out == {"project_id": 3, "topic_id": 22, "suppressed": False}
|
||||
|
||||
|
||||
# ── Typed edges between rules (milestone 307) ───────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -80,8 +80,7 @@ def _task(tid: int, milestone_id: int | None) -> MagicMock:
|
||||
|
||||
def _enter_stubs(project, milestones: list[dict], tasks: list, *, rules=None, systems=None,
|
||||
design=None):
|
||||
applicable = rules or {"rules": [], "project_rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": []}
|
||||
applicable = rules or {"rules": [], "project_rules": [], "truncated": False}
|
||||
return [
|
||||
patch("scribe.mcp.tools.projects.projects_svc.get_project",
|
||||
AsyncMock(return_value=project)),
|
||||
@@ -121,8 +120,7 @@ async def test_enter_project_stays_small_however_large_the_project():
|
||||
"rules": [{"id": i, "title": f"r{i}", "statement": PLAN} for i in range(50)],
|
||||
"project_rules": [{"id": 100 + i, "title": f"pr{i}", "statement": PLAN,
|
||||
"when_to_apply": PLAN} for i in range(60)],
|
||||
"truncated": True, "subscribed_rulebooks": [{"id": 1, "title": "Family"}],
|
||||
"suppressed_rules": [], "suppressed_topics": [],
|
||||
"truncated": True,
|
||||
}
|
||||
systems = []
|
||||
for i in range(40):
|
||||
@@ -181,8 +179,7 @@ async def test_get_project_lists_every_milestone_without_plans():
|
||||
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
|
||||
AsyncMock(return_value=_history(10))), \
|
||||
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
|
||||
AsyncMock(return_value={"rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": []})):
|
||||
AsyncMock(return_value={"rules": [], "truncated": False})):
|
||||
out = await get_project(project_id=5)
|
||||
assert len(out["milestone_summary"]) == 10
|
||||
assert all("body" not in m for m in out["milestone_summary"])
|
||||
|
||||
@@ -48,9 +48,7 @@ def test_service_signatures_require_user_id():
|
||||
"set_rule_systems", "add_rule_relation", "remove_rule_relation",
|
||||
"list_rules",
|
||||
"get_rule", "update_rule", "delete_rule",
|
||||
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
||||
"suppress_rule_for_project", "unsuppress_rule_for_project",
|
||||
"suppress_topic_for_project", "unsuppress_topic_for_project",
|
||||
"get_applicable_rules",
|
||||
):
|
||||
sig = inspect.signature(getattr(svc, fn_name))
|
||||
assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"
|
||||
@@ -70,38 +68,32 @@ def test_create_project_rule_route_exists():
|
||||
assert callable(getattr(rb_routes, "create_project_rule"))
|
||||
|
||||
|
||||
def test_suppression_route_handlers_exist():
|
||||
"""The 4 suppression endpoint handlers are registered as Python callables."""
|
||||
from scribe.routes import rulebooks as rb_routes
|
||||
for name in (
|
||||
"suppress_project_rule", "unsuppress_project_rule",
|
||||
"suppress_project_topic", "unsuppress_project_topic",
|
||||
):
|
||||
assert callable(getattr(rb_routes, name)), f"missing route handler: {name}"
|
||||
|
||||
|
||||
def test_suppression_association_tables_declared():
|
||||
"""Migration 0060 created two new association tables; the models module
|
||||
must declare matching Table() objects so the rest of the service layer
|
||||
can reference them via .c.<column>."""
|
||||
def test_subscriptions_and_suppressions_are_gone():
|
||||
"""Milestone 414: a rule's home is its scope. Nothing subscribes a project
|
||||
to a rulebook or mutes a rule for one — not a route, a service function or
|
||||
a table. A partial removal would leave a door that 500s on a missing table.
|
||||
"""
|
||||
from scribe.models import rulebook as rb_models
|
||||
for tbl_name in ("project_rule_suppressions", "project_topic_suppressions"):
|
||||
tbl = getattr(rb_models, tbl_name, None)
|
||||
assert tbl is not None, f"models.rulebook missing {tbl_name}"
|
||||
cols = {c.name for c in tbl.columns}
|
||||
assert "project_id" in cols
|
||||
assert "rule_id" in cols or "topic_id" in cols
|
||||
from scribe.routes import rulebooks as rb_routes
|
||||
from scribe.services import rulebooks as svc
|
||||
for name in ("subscribe_project", "unsubscribe_project",
|
||||
"suppress_project_rule", "unsuppress_project_rule",
|
||||
"suppress_project_topic", "unsuppress_project_topic"):
|
||||
assert not hasattr(rb_routes, name), f"route handler still present: {name}"
|
||||
for name in ("subscribe_project", "unsubscribe_project",
|
||||
"suppress_rule_for_project", "unsuppress_rule_for_project",
|
||||
"suppress_topic_for_project", "unsuppress_topic_for_project"):
|
||||
assert not hasattr(svc, name), f"service function still present: {name}"
|
||||
for name in ("project_rulebook_subscriptions", "project_rule_suppressions",
|
||||
"project_topic_suppressions"):
|
||||
assert not hasattr(rb_models, name), f"table still declared: {name}"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_rule_and_subscription_handlers_callable():
|
||||
def test_rule_handlers_callable():
|
||||
from scribe.routes import rulebooks as rb_routes
|
||||
for name in (
|
||||
"list_rules", "create_rule", "get_rule", "update_rule", "delete_rule",
|
||||
"subscribe_project", "unsubscribe_project", "get_project_rules",
|
||||
"get_project_rules",
|
||||
# The typed edges — both doors carry them (rule 33).
|
||||
"relate_rules", "unrelate_rules",
|
||||
):
|
||||
|
||||
@@ -330,7 +330,6 @@ def test_rules_payload_records_both_the_family_and_project_halves():
|
||||
"rules": [{"id": 10}, {"id": 11}],
|
||||
"project_rules": [{"id": 12}],
|
||||
"truncated": False,
|
||||
"subscribed_rulebooks": [],
|
||||
},
|
||||
user_id=1,
|
||||
source="enter_project",
|
||||
@@ -342,9 +341,9 @@ def test_rules_payload_records_both_the_family_and_project_halves():
|
||||
|
||||
|
||||
def test_brief_rules_payload_lists_titles_and_records_only_what_it_shows():
|
||||
"""The handshake's form (#4045): project rules by id and title, the
|
||||
subscribed rulebooks, nothing else. A subscription-derived rule it doesn't
|
||||
show must not count as surfaced."""
|
||||
"""The handshake's form (#4045): project rules by id and title, nothing
|
||||
else (subscriptions went in milestone 414). A global rule it doesn't show
|
||||
must not count as surfaced."""
|
||||
from scribe.services import rulebooks as svc
|
||||
|
||||
rec = MagicMock()
|
||||
@@ -354,17 +353,13 @@ def test_brief_rules_payload_lists_titles_and_records_only_what_it_shows():
|
||||
"rules": [{"id": 10, "title": "family", "statement": "s"}],
|
||||
"project_rules": [{"id": 12, "title": "own", "statement": "s"}],
|
||||
"truncated": False,
|
||||
"subscribed_rulebooks": [{"id": 1, "title": "Family"}],
|
||||
},
|
||||
user_id=1,
|
||||
source="enter_project",
|
||||
brief=True,
|
||||
)
|
||||
|
||||
assert out == {
|
||||
"project_rules": [{"id": 12, "title": "own"}],
|
||||
"subscribed_rulebooks": [{"id": 1, "title": "Family"}],
|
||||
}
|
||||
assert out == {"project_rules": [{"id": 12, "title": "own"}]}
|
||||
assert rec.call_args.kwargs["rule_ids"] == [12]
|
||||
|
||||
|
||||
|
||||
@@ -249,11 +249,7 @@ def test_the_column_guard_covers_every_table_with_a_row_helper():
|
||||
# REAL table names, as _BACKED_UP holds them — not the shorter keys the
|
||||
# payload uses for the same sections. Getting this wrong is what the guard
|
||||
# caught on its own first run.
|
||||
join_tables = {
|
||||
"project_rulebook_subscriptions", "project_rule_suppressions",
|
||||
"project_topic_suppressions",
|
||||
"rule_systems",
|
||||
}
|
||||
join_tables = {"rule_systems"}
|
||||
covered = set(_column_guard_targets()) | join_tables
|
||||
assert set(backup._BACKED_UP) - covered == set()
|
||||
# And no stale entries: every declaration must name a real target.
|
||||
@@ -306,15 +302,6 @@ def test_every_table_is_either_backed_up_or_explicitly_excluded():
|
||||
)
|
||||
|
||||
|
||||
def test_join_table_row_helpers_are_pure():
|
||||
subs = [SimpleNamespace(project_id=1, rulebook_id=2)]
|
||||
rsup = [SimpleNamespace(project_id=1, rule_id=9)]
|
||||
tsup = [SimpleNamespace(project_id=1, topic_id=7)]
|
||||
assert backup._subscription_rows(subs) == [{"project_id": 1, "rulebook_id": 2}]
|
||||
assert backup._rule_suppression_rows(rsup) == [{"project_id": 1, "rule_id": 9}]
|
||||
assert backup._topic_suppression_rows(tsup) == [{"project_id": 1, "topic_id": 7}]
|
||||
|
||||
|
||||
class _Result:
|
||||
def scalars(self):
|
||||
return self
|
||||
@@ -350,8 +337,6 @@ async def test_export_full_backup_contains_every_declared_section():
|
||||
# The sections v2 silently dropped, the six v5 added, v6's
|
||||
# note_supersessions, and v7's code_shapes (all empty here).
|
||||
for key in ("rulebooks", "rulebook_topics", "rules",
|
||||
"rulebook_subscriptions", "rule_suppressions",
|
||||
"topic_suppressions",
|
||||
"systems", "record_systems", "design_systems",
|
||||
"design_tokens", "note_usage_events", "repo_bindings",
|
||||
"note_supersessions", "code_shapes", "code_shape_events",
|
||||
|
||||
@@ -13,7 +13,7 @@ async def test_start_planning_creates_milestone_and_returns_rules():
|
||||
"rules": [{"id": 1, "title": "dev is home", "statement": "...",
|
||||
"topic_title": "git-workflow", "rulebook_title": "FabledSword family"}],
|
||||
"truncated": False,
|
||||
"subscribed_rulebooks": [{"id": 2, "title": "FabledSword family"}],
|
||||
"project_rules": [{"id": 3, "title": "own", "statement": "..."}],
|
||||
}
|
||||
with patch("scribe.services.planning.milestones_svc.create_milestone",
|
||||
AsyncMock(return_value=fake_milestone)) as mock_create, \
|
||||
@@ -34,7 +34,8 @@ async def test_start_planning_creates_milestone_and_returns_rules():
|
||||
# Returned shape
|
||||
assert out["milestone"]["id"] == 5
|
||||
assert out["applicable_rules"][0]["title"] == "dev is home"
|
||||
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "FabledSword family"}]
|
||||
assert out["project_rules"][0]["id"] == 3
|
||||
assert "subscribed_rulebooks" not in out
|
||||
assert out["open_task_count"] == 3
|
||||
|
||||
|
||||
|
||||
@@ -172,23 +172,7 @@ async def test_get_rule_returns_none_when_not_owner():
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── Subscriptions + applicable_rules ────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_project_requires_owned_rulebook():
|
||||
"""subscribe_project raises if user doesn't own the rulebook."""
|
||||
mock_session = make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.rulebooks import subscribe_project
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await subscribe_project(
|
||||
project_id=1, rulebook_id=999, user_id=7,
|
||||
)
|
||||
|
||||
# ── applicable_rules ────────────────────────────────────────────────────
|
||||
|
||||
def _empty():
|
||||
"""A MagicMock result whose .all() returns [] (or .scalars().all() returns [])."""
|
||||
@@ -217,13 +201,18 @@ def _no_edges():
|
||||
)
|
||||
|
||||
|
||||
def _areas(*canonical_ids):
|
||||
"""The project-areas query's result: `.scalars().all()` of canonical ids."""
|
||||
r = MagicMock()
|
||||
r.scalars.return_value.all.return_value = list(canonical_ids)
|
||||
return r
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_applicable_rules_returns_shape():
|
||||
"""get_applicable_rules returns the full projection — including the
|
||||
new suppression fields and rulebook/topic IDs on each rule."""
|
||||
"""The listing is a project's own rules plus the global rules tagged to its
|
||||
areas (milestone 414) — no subscriptions or suppressions in the shape."""
|
||||
mock_session = make_mock_session()
|
||||
sub_result = MagicMock()
|
||||
sub_result.all.return_value = [(1, "FabledSword family")]
|
||||
rules_result = MagicMock()
|
||||
rules_result.all.return_value = [
|
||||
# The query selects the ENTITY plus three labels, so rule_brief stays
|
||||
@@ -232,10 +221,9 @@ async def test_get_applicable_rules_returns_shape():
|
||||
"git-workflow", 1, "FabledSword family")
|
||||
for i in range(50)
|
||||
]
|
||||
# Execute order: sub_q, suppressed_rules_q, suppressed_topics_q,
|
||||
# project-areas_q (milestone 307), rules_q, proj_rules_q
|
||||
# Execute order: project-areas_q, rules_q (only with areas), proj_rules_q
|
||||
mock_session.execute = AsyncMock(side_effect=[
|
||||
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(),
|
||||
_areas(4), rules_result, _empty(),
|
||||
])
|
||||
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
@@ -244,19 +232,11 @@ async def test_get_applicable_rules_returns_shape():
|
||||
from scribe.services.rulebooks import get_applicable_rules
|
||||
result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
|
||||
|
||||
assert "rules" in result
|
||||
assert "project_rules" in result
|
||||
assert "suppressed_rules" in result
|
||||
assert "suppressed_topics" in result
|
||||
assert "truncated" in result
|
||||
assert "subscribed_rulebooks" in result
|
||||
assert result["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}]
|
||||
assert set(result) == {"rules", "project_rules", "truncated"}
|
||||
assert len(result["rules"]) == 50
|
||||
assert result["rules"][0]["topic_id"] == 2
|
||||
assert result["rules"][0]["rulebook_id"] == 1
|
||||
assert result["project_rules"] == []
|
||||
assert result["suppressed_rules"] == []
|
||||
assert result["suppressed_topics"] == []
|
||||
assert result["truncated"] is False
|
||||
|
||||
|
||||
@@ -264,14 +244,12 @@ async def test_get_applicable_rules_returns_shape():
|
||||
async def test_get_applicable_rules_truncates_when_over_limit():
|
||||
"""When limit+1 rows are returned, truncated=True and only `limit` returned."""
|
||||
mock_session = make_mock_session()
|
||||
sub_result = MagicMock()
|
||||
sub_result.all.return_value = []
|
||||
rules_result = MagicMock()
|
||||
rules_result.all.return_value = [
|
||||
(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(), _empty(), rules_result, _empty(),
|
||||
_areas(4), rules_result, _empty(),
|
||||
])
|
||||
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
@@ -284,6 +262,24 @@ async def test_get_applicable_rules_truncates_when_over_limit():
|
||||
assert len(result["rules"]) == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_project_with_no_areas_lists_no_global_rules():
|
||||
"""Untagged global rules apply everywhere and arrive by retrieval; the
|
||||
listing only names the ones bound by area, so no areas means none — and
|
||||
the global-rules query is not run at all."""
|
||||
mock_session = make_mock_session()
|
||||
mock_session.execute = AsyncMock(side_effect=[_areas(), _empty()])
|
||||
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.rulebooks import get_applicable_rules
|
||||
result = await get_applicable_rules(project_id=3, user_id=7)
|
||||
|
||||
assert result["rules"] == []
|
||||
assert mock_session.execute.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_applicable_rules_includes_project_scoped_rules():
|
||||
"""Project-scoped rules surface in the project_rules field."""
|
||||
@@ -295,9 +291,7 @@ async def test_get_applicable_rules_includes_project_scoped_rules():
|
||||
(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(), _empty(), proj_rules_result,
|
||||
])
|
||||
mock_session.execute = AsyncMock(side_effect=[_areas(), proj_rules_result])
|
||||
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
|
||||
@@ -311,38 +305,28 @@ async def test_get_applicable_rules_includes_project_scoped_rules():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_applicable_rules_surfaces_suppressed_with_context():
|
||||
"""Suppressed rules and topics come back with full title + rulebook context
|
||||
so the UI can render them without an extra round-trip."""
|
||||
async def test_a_co_surfaces_partner_on_another_project_is_not_dragged_in():
|
||||
"""An edge is not a way into a project: a partner that is global or on this
|
||||
project arrives; one on a different project does not."""
|
||||
mock_session = make_mock_session()
|
||||
suppressed_rules_result = MagicMock()
|
||||
suppressed_rules_result.all.return_value = [
|
||||
# (rule_id, title, topic_id, topic_title, rulebook_id, rulebook_title)
|
||||
(17, "Old rule", 5, "old-topic", 1, "FabledSword family"),
|
||||
proj_rules_result = MagicMock()
|
||||
proj_rules_result.all.return_value = [(fake_rule(id=100, topic_id=None, project_id=3),)]
|
||||
mock_session.execute = AsyncMock(side_effect=[_areas(), proj_rules_result])
|
||||
partners = [
|
||||
fake_rule(id=200, topic_id=9, project_id=None, title="global partner"),
|
||||
fake_rule(id=201, topic_id=None, project_id=3, title="same-project partner"),
|
||||
fake_rule(id=202, topic_id=None, project_id=8, title="other-project partner"),
|
||||
]
|
||||
suppressed_topics_result = MagicMock()
|
||||
suppressed_topics_result.all.return_value = [
|
||||
# (topic_id, topic_title, rulebook_id, rulebook_title)
|
||||
(22, "design-system", 1, "FabledSword family"),
|
||||
]
|
||||
mock_session.execute = AsyncMock(side_effect=[
|
||||
# sub_q, suppressed_rules_q, suppressed_topics_q, project-areas_q,
|
||||
# rules_q, proj_rules_q
|
||||
_empty(), suppressed_rules_result, suppressed_topics_result,
|
||||
_empty(), _empty(), _empty(),
|
||||
])
|
||||
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls, \
|
||||
patch("scribe.services.rulebooks.co_surfaced_partners", AsyncMock(return_value=partners)), \
|
||||
patch("scribe.services.rulebooks.list_rule_relations", AsyncMock(return_value={})), \
|
||||
patch("scribe.services.rulebooks.list_rule_systems", AsyncMock(return_value={})):
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.rulebooks import get_applicable_rules
|
||||
result = await get_applicable_rules(project_id=3, user_id=7)
|
||||
|
||||
assert len(result["suppressed_rules"]) == 1
|
||||
assert result["suppressed_rules"][0]["id"] == 17
|
||||
assert result["suppressed_rules"][0]["rulebook_title"] == "FabledSword family"
|
||||
assert len(result["suppressed_topics"]) == 1
|
||||
assert result["suppressed_topics"][0]["title"] == "design-system"
|
||||
assert [r["id"] for r in result["rules"]] == [200, 201]
|
||||
|
||||
|
||||
# ── rule_brief (milestone 307) ──────────────────────────────────────────
|
||||
|
||||
@@ -44,16 +44,15 @@ async def test_delete_returns_none_when_not_found():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_cascades_to_notes_milestones_project_rules_and_suppressions():
|
||||
async def test_delete_project_cascades_to_notes_milestones_and_project_rules():
|
||||
session = make_mock_session()
|
||||
# exists-check + 6 cascade ops:
|
||||
# exists-check + 4 cascade ops:
|
||||
# notes (soft) → milestones (soft) → project-scoped rules (soft) →
|
||||
# project_rule_suppressions (hard DELETE) → project_topic_suppressions (hard DELETE) →
|
||||
# project (soft)
|
||||
# The two suppression hard-DELETEs went with their tables (milestone 414).
|
||||
session.execute = AsyncMock(side_effect=[
|
||||
_exists_result(True),
|
||||
MagicMock(), MagicMock(), MagicMock(),
|
||||
MagicMock(), MagicMock(),
|
||||
MagicMock(),
|
||||
])
|
||||
with patch("scribe.services.trash.async_session") as cls:
|
||||
@@ -61,7 +60,7 @@ async def test_delete_project_cascades_to_notes_milestones_project_rules_and_sup
|
||||
from scribe.services.trash import delete
|
||||
batch = await delete(user_id=1, entity_type="project", entity_id=3)
|
||||
assert isinstance(batch, str)
|
||||
assert session.execute.await_count == 7
|
||||
assert session.execute.await_count == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user