CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / integration (push) Successful in 31s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 27s
The ranked rule arm became measurable in M333. The preload did not — and that is the surface whose value is actually in question. `list_always_on_rules`, the SessionStart block and every `rules_payload` caller handed rules over wholesale and emitted nothing, so the resident set's token cost was certain and its usefulness could not be tested even in principle. Bulk deliveries now record as AMBIENT, beside the ranked count and never inside pull-through. Folding them in would mean growing the always-on set depressed the arm's measured precision and trimming it flattered the arm, neither for any reason to do with the arm. `RANKED_SOURCES` inverts the note twin's `AMBIENT_SOURCES` deliberately: there is one ranked rule source and this change adds seven bulk ones, so naming the rare half makes a forgotten surface default to ambient — under-counting it — rather than padding the denominator with surfacings nobody chose. Two lookalike call sites are deliberately left silent, with a test to keep them that way: the write-path etag arm and `rules_etag_for` read the rules to build or compare a MARKER and show nobody anything. No migration — `event` and `source` are plain Text with no CHECK (rule 36 does not apply). Snippet #2858 updated to the new `rules_payload` contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
70 lines
3.4 KiB
Python
70 lines
3.4 KiB
Python
"""Milestone 297 step 2 — always-on exclusions reach every rule surface.
|
|
|
|
The SQL is the integration lane's; here the contracts: rules_payload carries
|
|
the seventh key, list_always_on_rules takes project_id, the session-start
|
|
block names the excluded rulebooks, and the MCP tools mount.
|
|
"""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from scribe.services.rulebooks import rules_payload
|
|
|
|
|
|
def test_rules_payload_carries_excluded_always_on_as_the_seventh_key():
|
|
out = rules_payload({
|
|
"rules": [], "truncated": False, "subscribed_rulebooks": [],
|
|
"excluded_always_on": [{"id": 1, "title": "Family"}],
|
|
}, user_id=1, source="enter_project")
|
|
assert set(out) == {
|
|
"applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks",
|
|
"project_rules", "suppressed_rules", "suppressed_topics", "excluded_always_on",
|
|
}
|
|
assert out["excluded_always_on"] == [{"id": 1, "title": "Family"}]
|
|
# An older applicable dict without the key still renders (empty list).
|
|
assert rules_payload(
|
|
{"rules": [], "truncated": False, "subscribed_rulebooks": []},
|
|
user_id=1, source="enter_project",
|
|
)["excluded_always_on"] == []
|
|
|
|
|
|
def test_list_always_on_rules_service_and_tool_take_a_project_id():
|
|
import inspect
|
|
|
|
from scribe.mcp.tools import rulebooks as tools
|
|
from scribe.services import rulebooks as svc
|
|
assert "project_id" in inspect.signature(svc.list_always_on_rules).parameters
|
|
assert "project_id" in inspect.signature(tools.list_always_on_rules).parameters
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_context_names_the_excluded_always_on_rulebooks():
|
|
from types import SimpleNamespace as NS
|
|
|
|
from scribe.services.plugin_context import build_session_context
|
|
rules = [NS(id=1, title="`dev` is home", topic_id=1, statement="x")]
|
|
project = NS(id=9, title="Widget", goal="", design_system_id=None)
|
|
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
|
AsyncMock(return_value=rules)) as lao, \
|
|
patch("scribe.services.plugin_context.rulebooks_svc.excluded_always_on_rulebooks",
|
|
AsyncMock(return_value=[{"id": 5, "title": "Design standards"}])), \
|
|
patch("scribe.services.plugin_context._topic_titles", AsyncMock(return_value={1: "git"})), \
|
|
patch("scribe.services.plugin_context.projects_svc.get_project", AsyncMock(return_value=project)), \
|
|
patch("scribe.services.plugin_context.notes_svc.list_notes", AsyncMock(return_value=([], 0))), \
|
|
patch("scribe.services.plugin_context.rulebooks_svc.get_applicable_rules",
|
|
AsyncMock(return_value={"rules": [], "truncated": False, "subscribed_rulebooks": [],
|
|
"project_rules": [], "suppressed_rules": [],
|
|
"suppressed_topics": [], "excluded_always_on": []})):
|
|
out = await build_session_context(user_id=7, project_id=9)
|
|
# The always-on set was asked FOR THIS PROJECT, and the departure is named.
|
|
assert lao.await_args.kwargs.get("project_id") == 9
|
|
assert "Excluded for this project by its inception decision" in out["context"]
|
|
assert "Design standards (#5)" in out["context"]
|
|
|
|
|
|
def test_exclusion_routes_are_registered():
|
|
from scribe.app import create_app
|
|
rules = {r.rule for r in create_app().url_map.iter_rules()}
|
|
assert "/api/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>" in rules
|
|
|