Files
FabledScribe/tests/test_inception_rules.py
T
bvandeusenandClaude Opus 5 8b9b3a1d9b
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
feat(telemetry): the preload emits, and the always-on set stops being unfalsifiable (#3473)
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
2026-09-02 23:06:40 -04:00

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