feat(rules)!: retire the always-on tier — every rule arrives by retrieval (#394)
CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Milestone 394, steps 5-8. Operator: "remove the always on rule functionality as the goal was to not have it at all since it didn't seem to work as expected." Unconditional preload had three failures the retrieval arms do not. It could not be MEASURED — a resident rule is in the context whether or not it mattered, so nothing distinguished "this governed the act" from "this was scenery", and it was the one surface structurally exempt from the scoreboard judging every other. It was SUMMARISED AWAY by compaction while the session went on believing it held the rules. And it CROWDED OUT the few rules that applied with the thirty that did not. WHAT GOES Schema (0100): rules.tier + ck_rules_tier, rule_versions.tier, rulebooks.always_on, and project_rulebook_exclusions — a table recording a project's opt-out of something that no longer binds it unasked. Tools: list_always_on_rules, exclude_always_on_rulebook, include_always_on_rulebook. Service: the same three plus rules_etag_for, _valid_tier and the whole etag family. The SessionStart preload and the write-path staleness arm go with them: nothing is resident, so nothing can have drifted since a session loaded it. THREE CALLS WORTH REVIEWING enter_project got NARROWER, not wider. Its filter was `always_on OR area-tagged`; dropping the tier arm leaves the deterministic half, so a project with no canonical-tagged Systems gets no bulk rules and reaches them by retrieval instead. Dropping the whole clause would have made that payload bigger than the preload this milestone deletes. Backups import tolerantly. A pre-394 archive carries tier, always_on and the retired inception choice; none is read, and the exclusion key is DROPPED rather than remapped, because restoring it would write data that validate_inception now rejects as unknown. The migration is irreversible in the way that matters and says so: downgrade recreates the columns at their defaults and cannot restore which rules were always-on. A value invented to fill a hole is not a measurement. THE INSTRUCTION SURFACES SAY THE HARDER THING Deleting "call list_always_on_rules()" is easy; replacing it is not, because the new model asks a session to trust something it cannot see. All three surfaces now say a session holds nothing, that rules arrive when work matches them, and — the half that got dangerous — that "no rule arrived" means "nothing matched", never "there is no rule". Under residency an empty session was rare and suspicious; it is now the ordinary state of most turns, so reading it as permission is wrong on nearly every turn rather than occasionally. That is #3720's defect at session scale. test_instruction_surfaces_agree is repointed rather than retired: its two halves collapsed into one instruction, and it gains a guard that every surface states what absence means. _INSTRUCTIONS is back at 1999/2000 — the inception clause paid for the longer HOW line. UI (rule 27, and the opportunity step 8 named) The tier selector is gone, and what replaces it is the point: `when_to_apply` is now the field that decides whether a rule is ever seen, so the editor marks it required, warns while it is empty, and both rule lists badge a trigger-less rule "never surfaces". A rule without one is not quiet, it is unreachable. TESTS Two files deleted outright — test_rules_etag.py and test_inception_rules.py tested subsystems that no longer exist. Elsewhere obsolete cases were removed and the rest repointed. One deserves naming: the wiring test asserted the act arms pass no `tier`, which had become an assertion that could not fail. It is repointed onto `kind`, which does still exist and where the same claim is live — a preference must reach a write exactly as a rule does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
+1
-1
@@ -225,7 +225,7 @@ def fake_rule(**attrs) -> MagicMock:
|
||||
# Named for the note-2109 reason the whole helper exists: unnamed,
|
||||
# `when_to_apply` and `arose_from_id` would be truthy MagicMocks and
|
||||
# rule_brief would attach both keys on every stand-in.
|
||||
"when_to_apply": None, "tier": "always_on", "arose_from_id": None,
|
||||
"when_to_apply": None, "arose_from_id": None,
|
||||
# Named for the same reason one line up, and it bites harder here.
|
||||
# `rule_brief` and `to_dict` both emit `kind or "rule"`, and a
|
||||
# MagicMock is truthy — so an unnamed `kind` would put a MagicMock
|
||||
|
||||
+11
-8
@@ -32,28 +32,31 @@ def test_exclusions_table_is_the_suppressions_sibling():
|
||||
|
||||
|
||||
def test_validate_inception_pins_the_choice_vocabulary():
|
||||
assert CHOICE_KEYS == ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems")
|
||||
assert CHOICE_KEYS == ("subscribe_rulebooks", "design_system_id", "seed_systems")
|
||||
assert validate_inception({}) is None
|
||||
assert validate_inception({"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [2],
|
||||
assert validate_inception({"subscribe_rulebooks": [2],
|
||||
"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"})
|
||||
assert "list of rulebook ids" in validate_inception({"exclude_always_on_rulebooks": "1"})
|
||||
# The retired exclusion key is now an UNKNOWN key rather than a typed one,
|
||||
# which is the right error: a caller still passing it is asking for a
|
||||
# choice that no longer exists, and silently ignoring it would let them
|
||||
# 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]})
|
||||
assert "both excluded and subscribed" in validate_inception(
|
||||
{"exclude_always_on_rulebooks": [1, 2], "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], "exclude_always_on_rulebooks": [2]})
|
||||
assert out == {"exclude_always_on_rulebooks": [2], "subscribe_rulebooks": [1, 3],
|
||||
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) == {"exclude_always_on_rulebooks": [], "subscribe_rulebooks": [],
|
||||
assert normalize_choices(None) == {"subscribe_rulebooks": [],
|
||||
"design_system_id": None, "seed_systems": False}
|
||||
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The instruction surfaces must agree that the agent pulls the rules itself.
|
||||
"""The instruction surfaces must agree on how a rule reaches a session.
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
@@ -17,16 +17,23 @@ an extended period, and nothing announced it. An agent trusting the push would
|
||||
have run with no binding rules and no signal — while those rules govern branch,
|
||||
commit, push and other hard-to-reverse actions.
|
||||
|
||||
The asymmetry is the whole argument, and it is what these tests pin: pulling
|
||||
when a push also arrived costs one redundant call; not pulling when the push
|
||||
never came costs the operator's rules entirely.
|
||||
The asymmetry is the whole argument, and it is what these tests pin: asking
|
||||
when a rule had already arrived costs one redundant call; not asking when
|
||||
nothing arrived costs the operator's rules entirely.
|
||||
|
||||
MILESTONE 394 SHARPENED IT RATHER THAN RETIRING IT. There is no longer a
|
||||
resident set to pull, so "no rule in front of me" went from a rare and
|
||||
suspicious state to the ordinary state of most turns. The instruction that
|
||||
used to be supplementary — go and ask — is now the only route a rule has, and
|
||||
the surfaces must additionally say what an EMPTY session means, or a session
|
||||
reads silence as permission on nearly every turn.
|
||||
|
||||
WHAT THIS DOES NOT DO
|
||||
|
||||
It cannot tell whether two surfaces contradict each other in prose generally —
|
||||
that needs a reader. It pins the one instruction whose absence is known to be
|
||||
that needs a reader. It pins the instructions whose absence is known to be
|
||||
load-bearing, and the specific shape the #2497 defect took: naming the push
|
||||
without also stating the pull.
|
||||
without also stating how to ask.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -34,8 +41,33 @@ import pathlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
# The pull instruction, however a surface phrases the surrounding prose.
|
||||
PULL = "list_always_on_rules"
|
||||
# THE PULL IS NOW THE ASK (milestone 394). This was `list_always_on_rules`,
|
||||
# the call that fetched the resident set. There is no resident set and no such
|
||||
# call: a rule reaches a session by retrieval, and the only thing a session can
|
||||
# DO about a rule it has not been handed is go looking for one.
|
||||
#
|
||||
# So the two halves this file used to pin separately — "pull the resident set"
|
||||
# and "and retrieve the conditional ones too" — have collapsed into one
|
||||
# instruction, and it is the load-bearing one rather than the supplementary
|
||||
# one it used to be.
|
||||
ASK = 'content_type="rule"'
|
||||
|
||||
# A surface must also say what an EMPTY session means, which is the half that
|
||||
# is newly dangerous. Under residency, "no rule in front of me" was rare and
|
||||
# suspicious. Under retrieval it is the ordinary state of most turns, so a
|
||||
# session that reads it as "there is no rule" is wrong on nearly every turn
|
||||
# rather than occasionally — the #3720 defect at session scale.
|
||||
#
|
||||
# Claim phrases, not a single word, for the reason BINDING_CLAIMS gives below:
|
||||
# a bare "matched" or "silence" appears in prose that is not making this claim
|
||||
# at all. A surface passes by asserting the distinction however it words it.
|
||||
ABSENCE_CLAIMS = (
|
||||
"nothing matched",
|
||||
"is not the same as \"there is no rule",
|
||||
"never \"there is no rule",
|
||||
"silence is not absence",
|
||||
"not evidence there is none",
|
||||
)
|
||||
|
||||
# Surfaces a session loads before substantive work. Hand-written because
|
||||
# "is this a session-start surface?" is an editorial fact, not a derivable one —
|
||||
@@ -67,21 +99,51 @@ def _all_surfaces() -> list[tuple[str, str]]:
|
||||
return found
|
||||
|
||||
|
||||
def test_every_session_start_surface_states_the_pull():
|
||||
def test_every_session_start_surface_states_the_ask():
|
||||
"""Retrieval is the only delivery, so asking is the only recourse."""
|
||||
missing = []
|
||||
for path in SESSION_START_SURFACES:
|
||||
assert path.exists(), (
|
||||
f"{path.relative_to(ROOT)} is gone — it was one of the surfaces "
|
||||
f"carrying the load-the-rules instruction. If it moved, update "
|
||||
f"carrying the rules instruction. If it moved, update "
|
||||
f"SESSION_START_SURFACES; if it was retired, check the instruction "
|
||||
f"still lives somewhere a fresh session reads."
|
||||
)
|
||||
if PULL not in path.read_text():
|
||||
if ASK not in path.read_text():
|
||||
missing.append(str(path.relative_to(ROOT)))
|
||||
assert not missing, (
|
||||
f"these surfaces no longer tell the agent to call {PULL}(): {missing}. "
|
||||
f"The rules are pull-only and the push is best-effort, so a surface "
|
||||
f"that omits this leaves a session bound by nothing (#2198, #2497)."
|
||||
f"these surfaces never tell the agent how to ask for a rule "
|
||||
f"({ASK}): {missing}. Nothing is pushed and nothing is resident, so a "
|
||||
f"surface that omits this leaves a session with no way to reach a rule "
|
||||
f"it was not handed — bound by nothing (#2198, #2497, milestone 394)."
|
||||
)
|
||||
|
||||
|
||||
def test_every_session_start_surface_says_an_empty_session_is_not_an_empty_rulebook():
|
||||
"""The half that got dangerous when residency went away.
|
||||
|
||||
Under the old model a session opened holding every applicable rule, so
|
||||
"nothing is in front of me" was a rare state and a suspicious one. Under
|
||||
retrieval it is the NORMAL state of most turns. A surface that describes
|
||||
where rules come from, without also saying what their absence means, leaves
|
||||
a session reading silence as permission — on nearly every turn rather than
|
||||
occasionally.
|
||||
|
||||
That is #3720's defect ("absence reads as non-existence") moved from a
|
||||
readout to the session itself, and this milestone is what makes every
|
||||
session start in the absent state.
|
||||
"""
|
||||
missing = []
|
||||
for path in SESSION_START_SURFACES:
|
||||
text = path.read_text().lower()
|
||||
if not any(c.lower() in text for c in ABSENCE_CLAIMS):
|
||||
missing.append(str(path.relative_to(ROOT)))
|
||||
assert not missing, (
|
||||
f"these surfaces say how a rule arrives but never what it means when "
|
||||
f"none does: {missing}. 'No rule arrived' means 'nothing matched', "
|
||||
f"never 'there is no rule' — and only one of those has been checked. "
|
||||
f"Say it however you like; one of {ABSENCE_CLAIMS} is what this looks "
|
||||
f"for."
|
||||
)
|
||||
|
||||
|
||||
@@ -195,47 +257,7 @@ def test_displaced_topics_live_on_a_delivered_surface():
|
||||
)
|
||||
|
||||
|
||||
# The SECOND pull (milestone 333 step 3). `list_always_on_rules()` fetches the
|
||||
# resident tier; this one says that tier is not all of them, and that a
|
||||
# conditional rule has to be gone looking for. However a surface words the
|
||||
# surrounding prose, it names the call.
|
||||
RETRIEVE = 'content_type="rule"'
|
||||
|
||||
|
||||
def test_every_session_start_surface_states_the_conditional_retrieval():
|
||||
"""The push/pull asymmetry, one level in.
|
||||
|
||||
The tests above pin that a session PULLS the resident rules rather than
|
||||
trusting the SessionStart push. This pins the same shape between the two
|
||||
TIERS: an always-on rule is delivered, a conditional one is retrieved, and
|
||||
a surface that states only the first leaves a session reading its loaded
|
||||
set as the whole rulebook.
|
||||
|
||||
That reading is wrong in the direction that costs something. "Nothing was
|
||||
pushed" and "no rule applies" are different claims, and only one of them
|
||||
has been checked — the same asymmetry as #2198, now between tiers instead
|
||||
of between channels.
|
||||
|
||||
It is also what made the always-on tier the only one that worked, on any
|
||||
install rather than this one (rule 115). A rule nothing retrieves has to be
|
||||
resident to bind at all, so every rule worth keeping becomes resident; and
|
||||
a resident rule costs tokens in every session forever, so a rulebook that
|
||||
only delivers cannot grow past what one session can hold. Retrieval is what
|
||||
lifts that ceiling — and it only fires if something asks.
|
||||
"""
|
||||
missing = []
|
||||
for path in SESSION_START_SURFACES:
|
||||
if RETRIEVE not in path.read_text():
|
||||
missing.append(str(path.relative_to(ROOT)))
|
||||
assert not missing, (
|
||||
f"these surfaces state the always-on pull but never tell the agent to "
|
||||
f"retrieve a conditional rule ({RETRIEVE}): {missing}. A session that "
|
||||
f"reads its loaded set as the whole rulebook will act on \"I was not "
|
||||
f"told\" as if it meant \"there is no rule\" (milestone 333 step 3)."
|
||||
)
|
||||
|
||||
|
||||
def test_no_surface_names_the_push_without_stating_the_pull():
|
||||
def test_no_surface_names_the_push_without_stating_the_ask():
|
||||
"""The exact shape #2497 took.
|
||||
|
||||
Mentioning the SessionStart hook is fine and often useful. Mentioning it
|
||||
@@ -244,13 +266,14 @@ def test_no_surface_names_the_push_without_stating_the_pull():
|
||||
"""
|
||||
offenders = [
|
||||
label for label, text in _all_surfaces()
|
||||
if "SessionStart" in text and PULL not in text
|
||||
if "SessionStart" in text and ASK not in text
|
||||
]
|
||||
assert not offenders, (
|
||||
f"these surfaces describe the SessionStart push but never state the "
|
||||
f"explicit pull: {offenders}. The push is a delivery optimisation, not "
|
||||
f"the bridge — it can be absent without saying so. Name it if it helps, "
|
||||
f"but say to call {PULL}() regardless."
|
||||
f"these surfaces describe the SessionStart push but never state how to "
|
||||
f"ask: {offenders}. The push is a delivery optimisation, not the "
|
||||
f"bridge — it can be absent without saying so, and since milestone 394 "
|
||||
f"it carries no rules at all. Name it if it helps, but say how to ask "
|
||||
f"({ASK}) regardless."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -117,14 +117,12 @@ async def source():
|
||||
statement="Use sh.",
|
||||
why="the image ships no bash",
|
||||
verify_with="read the workflow's shell setting",
|
||||
tier="always_on",
|
||||
),
|
||||
# The actor is already gone — what SET NULL leaves behind.
|
||||
RuleVersion(
|
||||
rule_id=rule.id, user_id=None,
|
||||
title="The runner has no bash",
|
||||
statement="Use POSIX sh in run steps.",
|
||||
tier="always_on",
|
||||
),
|
||||
])
|
||||
await s.commit()
|
||||
@@ -263,4 +261,3 @@ async def test_the_text_survives(restored):
|
||||
assert by_statement["Use sh."].verify_with == (
|
||||
"read the workflow's shell setting"
|
||||
)
|
||||
assert by_statement["Use sh."].tier == "always_on"
|
||||
|
||||
@@ -10,7 +10,6 @@ import pytest_asyncio
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.rulebook import Rulebook
|
||||
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
|
||||
@@ -31,12 +30,12 @@ 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")
|
||||
async with async_session() as s:
|
||||
rb = await s.get(Rulebook, always.id)
|
||||
rb.always_on = True
|
||||
await s.commit()
|
||||
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")
|
||||
@@ -48,20 +47,20 @@ async def seeded():
|
||||
@pytest.mark.integration
|
||||
async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
# Undecided: the always-on rulebook binds, nothing subscribed, no Systems.
|
||||
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
||||
# 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 [r["id"] for r in defaults["always_on_rulebooks"]] == [seeded["always"]]
|
||||
assert [r["id"] for r in defaults["other_rulebooks"]] == [seeded["other"]]
|
||||
assert sorted(r["id"] for r in defaults["rulebooks"]) == sorted(
|
||||
[seeded["always"], seeded["other"]])
|
||||
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={
|
||||
"exclude_always_on_rulebooks": [seeded["always"]],
|
||||
"subscribe_rulebooks": [seeded["other"]],
|
||||
"design_system_id": None,
|
||||
"seed_systems": True,
|
||||
})
|
||||
assert out["effects"]["excluded"] == [seeded["always"]]
|
||||
assert out["effects"]["subscribed"] == [seeded["other"]]
|
||||
catalog = await canonical_svc.list_canonical_systems()
|
||||
assert len(out["effects"]["systems_seeded"]) == len(catalog)
|
||||
@@ -69,35 +68,32 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||
seeded_systems = await systems_svc.list_systems(owner, pid)
|
||||
assert all(s.canonical_id is not None for s in seeded_systems)
|
||||
|
||||
# The exclusion is total: the project's always-on set is empty, the
|
||||
# departure is named, the subscription binds.
|
||||
assert await rulebooks_svc.list_always_on_rules(owner, project_id=pid) == []
|
||||
assert len(await rulebooks_svc.list_always_on_rules(owner)) == 1 # user-wide unchanged
|
||||
# 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 [r["title"] for r in applicable["rules"]] == ["Write the why"]
|
||||
assert [e["id"] for e in applicable["excluded_always_on"]] == [seeded["always"]]
|
||||
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"]["exclude_always_on_rulebooks"] == [seeded["always"]]
|
||||
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
|
||||
assert project.inception["choices"]["subscribe_rulebooks"] == [seeded["other"]]
|
||||
# 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"] == []
|
||||
assert len(await systems_svc.list_systems(owner, pid)) == len(catalog)
|
||||
await rulebooks_svc.include_always_on_rulebook_for_project(pid, seeded["always"], owner)
|
||||
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_a_bad_decision_applies_nothing(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
# Excluding a rulebook that is not always-on is refused BEFORE any effect.
|
||||
with pytest.raises(ValueError, match="not always-on"):
|
||||
# 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.
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||
"exclude_always_on_rulebooks": [seeded["other"]], "seed_systems": True,
|
||||
"subscribe_rulebooks": [999999], "seed_systems": True,
|
||||
})
|
||||
assert await systems_svc.list_systems(owner, pid) == []
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5).
|
||||
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5,
|
||||
narrowed by 394).
|
||||
|
||||
What mocks can't prove, and what this milestone must not get wrong:
|
||||
What mocks can't prove, and what this design must not get wrong:
|
||||
|
||||
1. **Nothing stops binding.** A rule with no tier, no areas and no edges
|
||||
behaves exactly as it did before tiers existed. That is the one failure this
|
||||
whole design must not produce, and it is asserted first.
|
||||
2. A conditional rule is invisible to a project that doesn't work in its area,
|
||||
and arrives — binding, not suggested — to one that does.
|
||||
3. A `co_surfaces` partner arrives with its other half, which is the failure
|
||||
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.
|
||||
4. An explicit suppression outranks an edge.
|
||||
3. An explicit suppression outranks an edge.
|
||||
|
||||
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.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.rulebook import Rulebook
|
||||
from scribe.services import canonical_systems as canonical_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
@@ -27,12 +32,13 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def world():
|
||||
"""A project with TWO rulebooks, because the two payloads are different sets.
|
||||
"""A project with two rulebooks — one subscribed, one not.
|
||||
|
||||
`list_always_on_rules` covers always-on rulebooks; `get_applicable_rules`
|
||||
covers SUBSCRIBED ones. Conflating them is easy and would make these tests
|
||||
assert nothing, so the fixture carries one of each and every test says
|
||||
which payload it is about.
|
||||
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".
|
||||
"""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "surfacing_owner")
|
||||
@@ -43,10 +49,6 @@ async def world():
|
||||
await s.commit()
|
||||
|
||||
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
||||
async with async_session() as s:
|
||||
rb = await s.get(Rulebook, always.id)
|
||||
rb.always_on = True
|
||||
await s.commit()
|
||||
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.",
|
||||
@@ -72,39 +74,8 @@ async def _titles(ids) -> set[str]:
|
||||
return {r["title"] for r in applicable["rules"]}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_a_rule_with_no_tier_no_areas_and_no_edges_binds_exactly_as_before(world):
|
||||
"""THE compatibility guarantee. An install upgrades and every rule it
|
||||
already had keeps arriving — no tier set, no areas, no edges, still bound.
|
||||
Getting this wrong would silently stop enforcing rules people rely on,
|
||||
which is worse than any amount of payload bloat."""
|
||||
always_on = await rulebooks_svc.list_always_on_rules(world["owner"])
|
||||
assert "dev is home" in {r.title for r in always_on}
|
||||
assert "Between batches, keep stacking" in await _titles(world)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_a_conditional_rule_is_reachable_not_resident(world):
|
||||
"""It leaves the session-start payload entirely — that is the point of the
|
||||
tier — and it does NOT reach a project with no matching area."""
|
||||
# In the ALWAYS-ON book: the tier alone keeps it out of the session-start
|
||||
# payload, which is the whole point of the tier.
|
||||
resident = await rulebooks_svc.create_rule(
|
||||
world["always_topic"], world["owner"], "Release tagging", "Derive the tag.",
|
||||
when_to_apply="when cutting a release", tier="conditional",
|
||||
)
|
||||
assert resident.tier == "conditional"
|
||||
always_on = await rulebooks_svc.list_always_on_rules(world["owner"])
|
||||
assert "Release tagging" not in {r.title for r in always_on}
|
||||
|
||||
# In the SUBSCRIBED book, untagged: the project has no area to reach it by,
|
||||
# so it stays out of the project payload too. Absent for a DIFFERENT reason
|
||||
# than above, which is why both are asserted.
|
||||
await rulebooks_svc.create_rule(
|
||||
world["topic"], world["owner"], "Untagged conditional", "No area yet.",
|
||||
when_to_apply="sometime", tier="conditional",
|
||||
)
|
||||
assert "Untagged conditional" not in await _titles(world)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -117,7 +88,7 @@ async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world):
|
||||
|
||||
rule = await rulebooks_svc.create_rule(
|
||||
world["topic"], world["owner"], "Release tagging", "Derive the tag.",
|
||||
when_to_apply="when cutting a release", tier="conditional",
|
||||
when_to_apply="when cutting a release",
|
||||
)
|
||||
await rulebooks_svc.set_rule_systems(rule.id, world["owner"], [area.id])
|
||||
|
||||
@@ -145,7 +116,7 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
|
||||
partner = await rulebooks_svc.create_rule(
|
||||
world["topic"], world["owner"], "Version names are labels",
|
||||
"A name decides nothing.",
|
||||
when_to_apply="when naming a build", tier="conditional",
|
||||
when_to_apply="when naming a build",
|
||||
)
|
||||
await rulebooks_svc.add_rule_relation(
|
||||
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||
@@ -163,7 +134,6 @@ async def test_a_suppression_outranks_an_edge(world):
|
||||
does not want that one. An explicit decision beats an inferred one."""
|
||||
partner = await rulebooks_svc.create_rule(
|
||||
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
|
||||
tier="conditional",
|
||||
)
|
||||
await rulebooks_svc.add_rule_relation(
|
||||
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||
|
||||
@@ -99,7 +99,7 @@ async def test_rewording_the_check_drops_the_stamp(constraint):
|
||||
"""A stamp certifies a check, not a rule.
|
||||
|
||||
The safe direction, for the same reason _valid_tier falls back to
|
||||
always_on: a rule wrongly listed as due costs one look, a rule wrongly
|
||||
the safe direction: a rule wrongly listed as due costs one look, a rule wrongly
|
||||
vouched for costs exactly what the sweep exists to catch.
|
||||
"""
|
||||
await rulebooks_svc.update_rule(
|
||||
@@ -162,7 +162,6 @@ async def rulebook_of_three():
|
||||
stale = await rulebooks_svc.create_rule(
|
||||
topic.id, uid, "Bumps need a dashboard tick", "Tick it first.",
|
||||
verify_with="cat CI-runner/renovate/config.js",
|
||||
tier="conditional",
|
||||
)
|
||||
async with async_session() as s:
|
||||
row = await s.get(Rule, stale.id)
|
||||
@@ -256,12 +255,6 @@ async def test_never_only_and_the_age_filter_narrow_to_what_they_say(rulebook_of
|
||||
assert rulebook_of_three["never"] in aged
|
||||
|
||||
|
||||
async def test_the_tier_filter_narrows_to_one_tier(rulebook_of_three):
|
||||
ids = [r.id for r in await rulebooks_svc.rules_due_for_verification(
|
||||
rulebook_of_three["uid"], tier="conditional",
|
||||
)]
|
||||
assert rulebook_of_three["stale"] in ids
|
||||
assert rulebook_of_three["never"] not in ids
|
||||
|
||||
|
||||
async def test_another_users_rules_are_not_in_your_sweep(rulebook_of_three):
|
||||
|
||||
@@ -412,10 +412,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", exclude_always_on_rulebooks=[1], design_system_id=-1, seed_systems=True)
|
||||
out = await create_project(title="P", subscribe_rulebooks=[1], 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"] == {"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [],
|
||||
assert kw["choices"] == {"subscribe_rulebooks": [1],
|
||||
"design_system_id": None, "seed_systems": True}
|
||||
assert out["inception"]["via"] == "mcp" and "inception_effects" in out
|
||||
|
||||
@@ -433,7 +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": [], "excluded_always_on": []}
|
||||
"subscribed_rulebooks": []}
|
||||
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
|
||||
|
||||
async def run(project):
|
||||
@@ -466,6 +466,6 @@ 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 ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems"):
|
||||
for name in ("subscribe_rulebooks", "design_system_id", "seed_systems"):
|
||||
assert name in tool.parameters.get("properties", {}), name
|
||||
|
||||
|
||||
@@ -225,7 +225,9 @@ def test_register_attaches_every_tool():
|
||||
# 26 through milestone 307, +2 for the staleness sweep (milestone 312),
|
||||
# +1 for a rule's edit history (milestone 323), +2 for preferences
|
||||
# (milestone 399).
|
||||
assert len(mcp.names) == 31
|
||||
# 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
|
||||
# spot-check a few names
|
||||
assert "list_rulebooks" in mcp.names
|
||||
assert "create_rule" in mcp.names
|
||||
@@ -236,10 +238,6 @@ def test_register_attaches_every_tool():
|
||||
assert "create_preference" in mcp.names
|
||||
assert "update_preference" in mcp.names
|
||||
assert "subscribe_project_to_rulebook" in mcp.names
|
||||
assert "list_always_on_rules" in mcp.names
|
||||
# milestone 297: a project's opt-out of a whole always-on rulebook
|
||||
assert "exclude_always_on_rulebook" in mcp.names
|
||||
assert "include_always_on_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
|
||||
@@ -252,57 +250,12 @@ def test_register_attaches_every_tool():
|
||||
assert "unsuppress_topic_for_project" in mcp.names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_always_on_rules_returns_empty_when_no_always_on_rulebooks():
|
||||
with patch(
|
||||
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
|
||||
AsyncMock(return_value=[]),
|
||||
):
|
||||
from scribe.mcp.tools.rulebooks import list_always_on_rules
|
||||
out = await list_always_on_rules()
|
||||
# An install with no always-on rulebooks still gets a marker (milestone
|
||||
# 323): "no rules" is a STATE, and a payload that omitted the key would
|
||||
# make the write path read every session on a fresh install as a change.
|
||||
assert out == {"rules": [], "total": 0, "rules_etag": "empty|0"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_always_on_rules_projects_each_rule():
|
||||
rules = [fake_rule(id=100, title="r", statement="s", topic_id=10), fake_rule(id=101, title="r", statement="s", topic_id=10)]
|
||||
with patch(
|
||||
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
|
||||
AsyncMock(return_value=rules),
|
||||
):
|
||||
from scribe.mcp.tools.rulebooks import list_always_on_rules
|
||||
out = await list_always_on_rules()
|
||||
assert out["total"] == 2
|
||||
assert {r["id"] for r in out["rules"]} == {100, 101}
|
||||
assert all("topic_id" in r for r in out["rules"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rulebook_forwards_always_on_when_set():
|
||||
rb = fake_rulebook(id=1, title="t")
|
||||
mock = AsyncMock(return_value=rb)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
|
||||
from scribe.mcp.tools.rulebooks import update_rulebook
|
||||
await update_rulebook(rulebook_id=1, always_on=True)
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs.get("always_on") is True
|
||||
assert "title" not in kwargs
|
||||
assert "description" not in kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rulebook_omits_always_on_when_none():
|
||||
rb = fake_rulebook(id=1, title="t")
|
||||
mock = AsyncMock(return_value=rb)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
|
||||
from scribe.mcp.tools.rulebooks import update_rulebook
|
||||
await update_rulebook(rulebook_id=1, title="new title")
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert "always_on" not in kwargs
|
||||
assert kwargs["title"] == "new title"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -454,7 +407,7 @@ def _fake_version(**over):
|
||||
"id": 5, "rule_id": 100, "user_id": 1,
|
||||
"title": "The runner has no bash", "statement": "Use sh.",
|
||||
"why": "the image ships no bash", "how_to_apply": None,
|
||||
"when_to_apply": None, "tier": "always_on",
|
||||
"when_to_apply": None,
|
||||
"verify_with": "read the workflow's shell setting",
|
||||
"expires_when": None,
|
||||
"created_at": datetime(2026, 8, 29, tzinfo=timezone.utc),
|
||||
|
||||
@@ -46,7 +46,7 @@ def test_service_signatures_require_user_id():
|
||||
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
|
||||
"create_rule", "create_project_rule", "rule_detail",
|
||||
"set_rule_systems", "add_rule_relation", "remove_rule_relation",
|
||||
"list_rules", "list_always_on_rules",
|
||||
"list_rules",
|
||||
"get_rule", "update_rule", "delete_rule",
|
||||
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
||||
"suppress_rule_for_project", "unsuppress_rule_for_project",
|
||||
@@ -93,24 +93,8 @@ def test_suppression_association_tables_declared():
|
||||
assert "rule_id" in cols or "topic_id" in cols
|
||||
|
||||
|
||||
def test_rulebook_model_carries_always_on():
|
||||
"""Migration 0058 added rulebooks.always_on — verify the model declares it."""
|
||||
from scribe.models.rulebook import Rulebook
|
||||
assert "always_on" in Rulebook.__table__.columns
|
||||
col = Rulebook.__table__.columns["always_on"]
|
||||
assert col.nullable is False
|
||||
|
||||
|
||||
def test_update_rulebook_route_accepts_always_on():
|
||||
"""PATCH /api/rulebooks/<id> must pass always_on through to the service.
|
||||
|
||||
The handler filters body keys against a whitelist; that whitelist needs to
|
||||
include always_on or toggling from the UI silently drops the field.
|
||||
"""
|
||||
import inspect as _inspect
|
||||
from scribe.routes import rulebooks as rb_routes
|
||||
src = _inspect.getsource(rb_routes.update_rulebook)
|
||||
assert "always_on" in src, "update_rulebook handler missing always_on in field whitelist"
|
||||
|
||||
|
||||
def test_rule_and_subscription_handlers_callable():
|
||||
|
||||
@@ -5,8 +5,9 @@ WHY THIS EXISTS
|
||||
#3749 clears the ledger when an EVENT destroys context — a compaction, a
|
||||
/clear. This covers the case with no event at all: a long session where a rule
|
||||
was named two hundred turns ago and has simply fallen out of attention. Same
|
||||
argument #3702 made at the tier level (present in context and salient at the
|
||||
moment are different properties), applied to time instead of to tier.
|
||||
argument #3702 made about tiers (present in context and salient at the
|
||||
moment are different properties), applied to time instead. The tier itself is
|
||||
gone since milestone 394; the distinction it taught is what survives.
|
||||
|
||||
WHAT IS PINNED, AND WHAT DELIBERATELY IS NOT
|
||||
|
||||
|
||||
@@ -167,14 +167,21 @@ async def test_the_arm_searches_on_its_OWN_bar_not_the_code_one():
|
||||
kw = search.await_args.kwargs
|
||||
assert kw["threshold"] == 0.81, "the arm is still using the code threshold"
|
||||
assert kw["limit"] == pc.RULEHINT_LIMIT
|
||||
# NO tier filter (#3702). The arms search every rule the caller owns,
|
||||
# because "already in the session" is not the same as "in front of the
|
||||
# reader at the moment it applies" — and relevance is the threshold's
|
||||
# job, not a category's. If this assertion is failing because a tier
|
||||
# argument came back, read the block above RULEHINT_LIMIT first: the
|
||||
# filter may legitimately return, but only carrying a measured reason.
|
||||
assert "tier" not in kw or kw["tier"] is None, (
|
||||
"the arm is filtering the rule corpus by tier again"
|
||||
# NO CATEGORY FILTER (#3702, repointed by 394). This asserted that the arm
|
||||
# passed no `tier`. That parameter no longer exists, so the assertion had
|
||||
# become one that could not fail — which rule 167 rates below having none.
|
||||
#
|
||||
# The claim it was making is still live, on the axis that DOES still exist:
|
||||
# the act arms narrow by nothing, so a preference reaches a write exactly
|
||||
# as a rule does. "Already in the session" was never the same as "in front
|
||||
# of the reader at the moment it applies", and relevance is the
|
||||
# threshold's job rather than a category's. The one place a kind filter
|
||||
# belongs is the reserved preference slot, which asks for a kind BECAUSE
|
||||
# it is guaranteeing that kind a place.
|
||||
assert "kind" not in kw or kw["kind"] is None, (
|
||||
"the act arm is narrowing the rule corpus by kind — a preference and "
|
||||
"a rule both apply to a write, and filtering here is how one of them "
|
||||
"silently stops arriving"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
"""The staleness marker on the rules payload (milestone 323 step 5).
|
||||
|
||||
WHAT THE MARKER IS FOR: telling a session that the rules it is holding have
|
||||
MOVED since it loaded them. Not general staleness — the limitation is stated
|
||||
in services/rulebooks.py and in the write-path arm, and two tests here pin the
|
||||
cases that would otherwise be quietly lost.
|
||||
|
||||
The two that matter most are both about NOT crying wolf. A marker that reports
|
||||
a change when nothing changed gets ignored within a day, and an ignored
|
||||
staleness signal is worse than none: it trains a reader to skip the line that
|
||||
will one day be true.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.services import rulebooks as svc
|
||||
|
||||
NOW = datetime(2026, 8, 30, 12, 0, tzinfo=timezone.utc)
|
||||
LATER = NOW + timedelta(hours=1)
|
||||
|
||||
|
||||
def _rule(updated_at=NOW, rid=1, title="a rule"):
|
||||
"""A Rule-shaped stand-in. The marker only ever reads three attributes,
|
||||
and a real model would need a session to build."""
|
||||
return SimpleNamespace(id=rid, title=title, updated_at=updated_at)
|
||||
|
||||
|
||||
def test_the_same_set_produces_the_same_marker():
|
||||
"""The whole mechanism rests on this. If the marker moved on its own, every
|
||||
write would report a change and the line would be noise by lunchtime."""
|
||||
rules = [_rule(rid=1), _rule(rid=2, updated_at=LATER)]
|
||||
assert svc.rules_etag(rules) == svc.rules_etag(list(reversed(rules))), (
|
||||
"the marker depends on the ORDER rules come back in, so any query "
|
||||
"whose sort changes would look like an edit"
|
||||
)
|
||||
|
||||
|
||||
def test_an_edit_moves_the_marker():
|
||||
before = svc.rules_etag([_rule(rid=1), _rule(rid=2)])
|
||||
after = svc.rules_etag([_rule(rid=1), _rule(rid=2, updated_at=LATER)])
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_a_DELETED_rule_moves_the_marker():
|
||||
"""THE CASE max(updated_at) ALONE CANNOT SEE, and the reason the count is
|
||||
in there. Deleting a rule moves no timestamp — and it is the single change
|
||||
that takes an instruction OUT of force, which is the one a session most
|
||||
needs to hear about."""
|
||||
before = svc.rules_etag([_rule(rid=1), _rule(rid=2)])
|
||||
after = svc.rules_etag([_rule(rid=1)])
|
||||
assert before != after, (
|
||||
"a deleted rule left the marker unchanged — the count is missing, and "
|
||||
"the session would keep obeying an instruction that no longer exists"
|
||||
)
|
||||
|
||||
|
||||
def test_no_rules_is_a_state_not_a_change():
|
||||
"""Rule 115: this has to behave on an install with no rules at all. `max()`
|
||||
over an empty set raises; a marker that raised would take the whole write
|
||||
path's hint down, and one that varied would tell every session on a fresh
|
||||
install that its rules had changed."""
|
||||
assert svc.rules_etag([]) == svc.rules_etag([])
|
||||
assert svc.rules_etag([]) != svc.rules_etag([_rule()])
|
||||
|
||||
|
||||
def test_moved_since_names_only_what_actually_moved():
|
||||
held = svc.rules_etag([_rule(rid=1), _rule(rid=2)])
|
||||
current = [_rule(rid=1), _rule(rid=2, updated_at=LATER, title="reworded")]
|
||||
moved = svc.rules_moved_since(current, held)
|
||||
assert [r.id for r in moved] == [2]
|
||||
|
||||
|
||||
def test_a_matching_marker_names_nothing():
|
||||
rules = [_rule(rid=1), _rule(rid=2)]
|
||||
assert svc.rules_moved_since(rules, svc.rules_etag(rules)) == []
|
||||
|
||||
|
||||
def test_an_unreadable_marker_reports_no_change():
|
||||
"""A caller cannot act on "something differs but I cannot say what", and a
|
||||
garbled marker must never be rendered as a change — that is the shape of a
|
||||
signal that gets ignored."""
|
||||
assert svc.rules_moved_since([_rule(updated_at=LATER)], "not-an-etag") == []
|
||||
assert svc.rules_moved_since([_rule(updated_at=LATER)], "") == []
|
||||
assert svc.etag_count("garbled") is None
|
||||
|
||||
|
||||
def test_the_empty_marker_reports_no_change():
|
||||
"""An install that had no rules and now has some: the count says so, and
|
||||
this function has no timestamp to reason from. Silence here, not a claim."""
|
||||
assert svc.rules_moved_since([_rule()], svc.rules_etag([])) == []
|
||||
|
||||
|
||||
def test_the_count_survives_the_round_trip():
|
||||
assert svc.etag_count(svc.rules_etag([_rule(rid=1), _rule(rid=2)])) == 2
|
||||
assert svc.etag_count(svc.rules_etag([])) == 0
|
||||
|
||||
|
||||
def test_the_limitation_is_stated_where_a_reader_will_be():
|
||||
"""A future reader who finds an etag will assume it covers staleness
|
||||
generally. It does not — it is blind to compaction, which is the most
|
||||
common case — and the SessionStart nudge is that case's only mechanism.
|
||||
|
||||
Pinned because the plausible mistake is retiring a nudge that works on the
|
||||
strength of a signal that does not cover it, and the comment is the only
|
||||
thing standing in the way.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from scribe.services import plugin_context
|
||||
|
||||
for module in (svc, plugin_context):
|
||||
src = inspect.getsource(module).lower()
|
||||
assert "compaction" in src and "etag" in src, (
|
||||
f"{module.__name__} no longer explains what the rules marker "
|
||||
f"cannot see. Without it the next reader will treat an etag as a "
|
||||
f"general staleness check and soften the SessionStart nudge."
|
||||
)
|
||||
|
||||
|
||||
# ── The arm that delivers the message (milestone 323 step 5) ───────────
|
||||
#
|
||||
# The marker is worth nothing until a session is actually TOLD. These drive
|
||||
# the real `build_write_path_hint`, because the feature IS a line arriving in
|
||||
# a hook's output — a test of the helper alone would prove the arithmetic and
|
||||
# nothing about the delivery.
|
||||
|
||||
|
||||
def _quiet_write_path(pc, rules):
|
||||
"""Every other arm stubbed to silent, so the only line that can appear is
|
||||
the one under test."""
|
||||
return (
|
||||
patch.object(pc, "get_writepath_config",
|
||||
AsyncMock(return_value={"enabled": True, "threshold": 0.6,
|
||||
"top_k": 3})),
|
||||
patch.object(pc.snippets_svc, "list_snippets",
|
||||
AsyncMock(return_value=([], 0))),
|
||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])),
|
||||
patch.object(pc, "semantic_search_rules", AsyncMock(return_value=[])),
|
||||
patch.object(pc, "record_retrieval", MagicMock()),
|
||||
patch.object(pc, "record_surfaced", MagicMock()),
|
||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})),
|
||||
patch.object(pc.rulebooks_svc, "list_always_on_rules",
|
||||
AsyncMock(return_value=rules)),
|
||||
)
|
||||
|
||||
|
||||
async def _hint(rules, held_etag):
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
patches = _quiet_write_path(pc, rules)
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
out = await pc.build_write_path_hint(
|
||||
1, "src/scribe/services/rulebooks.py", code="x" * 400,
|
||||
rules_etag=held_etag,
|
||||
)
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
return out["context"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_session_is_told_which_rule_moved():
|
||||
"""The delivery, end to end through the real hint builder. Naming the rule
|
||||
is the point — "something changed" sends the reader to re-read everything,
|
||||
which is the cost the marker was meant to avoid."""
|
||||
held = svc.rules_etag([_rule(rid=1), _rule(rid=2, title="dev is home")])
|
||||
current = [_rule(rid=1), _rule(rid=2, title="dev is home", updated_at=LATER)]
|
||||
|
||||
ctx = await _hint(current, held)
|
||||
assert "changed since this session started" in ctx
|
||||
assert "#2" in ctx and "dev is home" in ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_session_holding_the_current_rules_is_told_nothing():
|
||||
"""The one that keeps the signal worth reading. A line on every write is a
|
||||
line nobody reads."""
|
||||
rules = [_rule(rid=1), _rule(rid=2)]
|
||||
ctx = await _hint(rules, svc.rules_etag(rules))
|
||||
assert "changed since this session started" not in ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_session_that_sent_no_marker_is_told_nothing():
|
||||
"""An install whose hook never reached /api/plugin/context has nothing
|
||||
stored. Absent must read as silence, not as a mismatch — otherwise the
|
||||
first thing a new install hears is that its rules changed."""
|
||||
ctx = await _hint([_rule(updated_at=LATER)], "")
|
||||
assert "changed since this session started" not in ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_DELETED_rule_is_reported_even_though_it_has_no_row():
|
||||
"""The count arm. A deleted rule leaves nothing to name, and it is the
|
||||
change that takes an instruction OUT of force — so "no longer in force"
|
||||
has to be sayable without a row to say it about."""
|
||||
held = svc.rules_etag([_rule(rid=1), _rule(rid=2)])
|
||||
ctx = await _hint([_rule(rid=1)], held)
|
||||
assert "no longer in force" in ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_arm_fails_open():
|
||||
"""A staleness hint must never break a write. Every other arm here fails
|
||||
open for the same reason, and this one runs a query that can fail."""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
patches = _quiet_write_path(pc, [])
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
with patch.object(pc.rulebooks_svc, "list_always_on_rules",
|
||||
AsyncMock(side_effect=RuntimeError("database down"))):
|
||||
out = await pc.build_write_path_hint(
|
||||
1, "src/x.py", code="x" * 400, rules_etag="2026-01-01T00:00:00+00:00|3",
|
||||
)
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
assert "changed since this session started" not in out["context"]
|
||||
|
||||
|
||||
def test_the_marker_cannot_break_the_payload_it_decorates():
|
||||
"""It is computed on the SessionStart path. Raising there would cost the
|
||||
whole context payload — every rule title, the project, the lot — to save
|
||||
a hint, which is the wrong trade in every case.
|
||||
|
||||
A row with no usable timestamp is skipped; a set with none degrades to a
|
||||
count-only marker. Count-only still catches a rule ADDED or DELETED and
|
||||
only loses edits, which is the right way round to lose information.
|
||||
|
||||
Found by CI: `build_session_context` tests hand it MagicMock rules, and
|
||||
`max()` over those raises TypeError rather than returning anything.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
assert svc.rules_etag([MagicMock(), MagicMock()]) == "unknown|2"
|
||||
assert svc.rules_etag([SimpleNamespace()]) == "unknown|1"
|
||||
# A count-only marker still moves when the set does.
|
||||
assert svc.rules_etag([MagicMock()]) != svc.rules_etag([MagicMock(), MagicMock()])
|
||||
# One usable stamp is enough to keep the real thing.
|
||||
assert svc.rules_etag([_rule(), MagicMock()]).startswith("2026-")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_signal_arrives_even_when_nothing_else_matched():
|
||||
"""THE BUG CI CAUGHT, and the one the task's acceptance criterion was
|
||||
written to catch.
|
||||
|
||||
`build_write_path_hint` returns early when no prior art, stamp, divergence
|
||||
or derive matched — which sat ABOVE this arm, so a session whose rules had
|
||||
changed was told only if the file it happened to be editing also matched
|
||||
something else. A staleness signal that fires on that coincidence is not a
|
||||
staleness signal.
|
||||
"""
|
||||
held = svc.rules_etag([_rule(rid=1), _rule(rid=2, title="dev is home")])
|
||||
current = [_rule(rid=1), _rule(rid=2, title="dev is home", updated_at=LATER)]
|
||||
|
||||
# Every other arm silent — which is exactly the case that used to return "".
|
||||
ctx = await _hint(current, held)
|
||||
assert "changed since this session started" in ctx
|
||||
@@ -382,13 +382,13 @@ def test_rule_rows_carry_the_verification_fields():
|
||||
can rot — the exact blindness the fields were added to end.
|
||||
|
||||
Column additions do not bump BACKUP_VERSION; only new SECTIONS do. Same
|
||||
call made for when_to_apply/tier/arose_from_id in 0088 (commit 6ddb8bf).
|
||||
call made for when_to_apply/arose_from_id in 0088 (commit 6ddb8bf).
|
||||
"""
|
||||
checked = datetime(2026, 8, 27, 12, 0, tzinfo=timezone.utc)
|
||||
row = SimpleNamespace(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
why="w", how_to_apply="h", order_index=0,
|
||||
when_to_apply="when", tier="conditional", kind="rule",
|
||||
when_to_apply="when", kind="rule",
|
||||
verify_with="cat some/file", expires_when="the file grows a shell",
|
||||
verified_at=checked, arose_from_id=99,
|
||||
created_at=checked, updated_at=checked,
|
||||
@@ -415,7 +415,7 @@ def test_rule_rows_keep_an_unverified_rule_unverified():
|
||||
row = SimpleNamespace(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
why=None, how_to_apply=None, order_index=0,
|
||||
when_to_apply=None, tier="always_on", kind="rule",
|
||||
when_to_apply=None, kind="rule",
|
||||
verify_with=None, expires_when=None, verified_at=None,
|
||||
arose_from_id=None,
|
||||
created_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
||||
@@ -445,7 +445,7 @@ def test_rule_rows_carry_the_kind_so_a_preference_does_not_restore_as_a_rule():
|
||||
row = SimpleNamespace(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
why=None, how_to_apply=None, order_index=0,
|
||||
when_to_apply="when", tier="conditional", kind="preference",
|
||||
when_to_apply="when", kind="preference",
|
||||
verify_with=None, expires_when=None, verified_at=None,
|
||||
arose_from_id=None, created_at=stamp, updated_at=stamp,
|
||||
)
|
||||
|
||||
@@ -4,23 +4,9 @@ import pytest
|
||||
from tests.helpers import fake_note
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_exclusions():
|
||||
"""build_session_context asks for the bound project's always-on
|
||||
exclusions (milestone 297); these tests script the rules only."""
|
||||
with patch("scribe.services.plugin_context.rulebooks_svc.excluded_always_on_rulebooks",
|
||||
AsyncMock(return_value=[])):
|
||||
yield
|
||||
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("_no_supersession")
|
||||
|
||||
|
||||
def _rule(rid, title, topic_id):
|
||||
r = MagicMock()
|
||||
r.id, r.title, r.topic_id = rid, title, topic_id
|
||||
r.statement = "FULL STATEMENT SHOULD NOT BE INJECTED"
|
||||
return r
|
||||
|
||||
|
||||
# ─── knowledge auto-inject (Path A) ──────────────────────────────────────────
|
||||
@@ -106,30 +92,6 @@ async def test_build_autoinject_hint_blank_query_returns_empty():
|
||||
search.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_session_context_renders_titles_grouped_by_topic():
|
||||
rules = [
|
||||
_rule(1, "`dev` is home", 1),
|
||||
_rule(2, "Release — never without explicit request", 1),
|
||||
_rule(3, "No GitHub — Fabled-Git only", 2),
|
||||
]
|
||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
||||
AsyncMock(return_value=rules)), \
|
||||
patch("scribe.services.plugin_context._topic_titles",
|
||||
AsyncMock(return_value={1: "git-workflow", 2: "fabled-git"})):
|
||||
from scribe.services.plugin_context import build_session_context
|
||||
out = await build_session_context(user_id=7, project_id=0)
|
||||
|
||||
ctx = out["context"]
|
||||
assert out["rule_count"] == 3
|
||||
assert out["project"] is None
|
||||
# Titles present, grouped under topic headings
|
||||
assert "### git-workflow" in ctx
|
||||
assert "### fabled-git" in ctx
|
||||
assert "- [1] `dev` is home" in ctx
|
||||
assert "- [3] No GitHub — Fabled-Git only" in ctx
|
||||
# Full statements must NOT be dumped (push channel injects titles only)
|
||||
assert "FULL STATEMENT SHOULD NOT BE INJECTED" not in ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -139,11 +101,7 @@ async def test_build_session_context_includes_project_when_scoped():
|
||||
# opposite of what this test is about.
|
||||
project = MagicMock(id=2, title="FabledScribe", goal="ship it",
|
||||
design_system_id=None)
|
||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
||||
patch("scribe.services.plugin_context._topic_titles",
|
||||
AsyncMock(return_value={1: "git-workflow"})), \
|
||||
patch("scribe.services.plugin_context.projects_svc.get_project",
|
||||
with 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=([], 4))):
|
||||
@@ -170,11 +128,7 @@ async def test_build_session_context_pushes_the_projects_design_system():
|
||||
"guidance": [], "token_count": 95,
|
||||
"token_groups": ["accent", "surface", "type"],
|
||||
}
|
||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
||||
patch("scribe.services.plugin_context._topic_titles",
|
||||
AsyncMock(return_value={1: "git-workflow"})), \
|
||||
patch("scribe.services.plugin_context.projects_svc.get_project",
|
||||
with 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))), \
|
||||
@@ -200,11 +154,7 @@ async def test_build_session_context_survives_an_unreadable_design_system():
|
||||
That must degrade to "no design block", not to a crash that costs the
|
||||
session its rules too."""
|
||||
project = MagicMock(id=2, title="App", goal="", design_system_id=9)
|
||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
||||
patch("scribe.services.plugin_context._topic_titles",
|
||||
AsyncMock(return_value={1: "git-workflow"})), \
|
||||
patch("scribe.services.plugin_context.projects_svc.get_project",
|
||||
with 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))), \
|
||||
@@ -219,14 +169,10 @@ async def test_build_session_context_survives_an_unreadable_design_system():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_session_context_unbound_repo_emits_bind_hint():
|
||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
||||
patch("scribe.services.plugin_context._topic_titles",
|
||||
AsyncMock(return_value={1: "git-workflow"})):
|
||||
from scribe.services.plugin_context import build_session_context
|
||||
out = await build_session_context(
|
||||
user_id=7, project_id=0, unbound_repo="host/owner/repo",
|
||||
)
|
||||
from scribe.services.plugin_context import build_session_context
|
||||
out = await build_session_context(
|
||||
user_id=7, project_id=0, unbound_repo="host/owner/repo",
|
||||
)
|
||||
|
||||
ctx = out["context"]
|
||||
assert out["project"] is None
|
||||
@@ -289,16 +235,29 @@ async def test_build_process_manifest_truncates_long_preview():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_session_context_caps_length():
|
||||
many = [_rule(i, "x" * 200, 1) for i in range(200)]
|
||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
||||
AsyncMock(return_value=many)), \
|
||||
patch("scribe.services.plugin_context._topic_titles",
|
||||
AsyncMock(return_value={1: "git-workflow"})):
|
||||
from scribe.services.plugin_context import build_session_context
|
||||
out = await build_session_context(user_id=7)
|
||||
"""The cap still binds, and now has to be provoked rather than tripped.
|
||||
|
||||
assert len(out["context"]) <= 9000 + 60 # cap + truncation note
|
||||
assert "truncated" in out["context"]
|
||||
This used to hand the block 200 long rule titles, because the preload made
|
||||
overflow the easy case. Milestone 394 removed that block, so nothing this
|
||||
function assembles on its own is big enough any more — which is a reason
|
||||
to drive the cap directly, not a reason to drop it. The project and design
|
||||
blocks are still unbounded in principle, and the hook passes this text
|
||||
through verbatim.
|
||||
|
||||
Patching the cap rather than manufacturing 9,000 characters keeps the test
|
||||
about the TRUNCATION PATH — that it cuts, and that it says it cut — which
|
||||
is the part a reader depends on.
|
||||
"""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
with patch.object(pc, "_MAX_CHARS", 120):
|
||||
out = await pc.build_session_context(user_id=7)
|
||||
|
||||
assert len(out["context"]) <= 120 + 60 # cap + truncation note
|
||||
assert "truncated" in out["context"], (
|
||||
"the block was cut without saying so — a reader cannot tell a "
|
||||
"truncated context from a short one"
|
||||
)
|
||||
|
||||
|
||||
# --- the reuse slot (#2246) --------------------------------------------------
|
||||
|
||||
@@ -14,7 +14,7 @@ def _no_exclusions():
|
||||
"""get_applicable_rules asks for the project's always-on exclusions
|
||||
(milestone 297) through its own session; these mocked-session tests
|
||||
script the rule queries only, so the exclusions lookup is stubbed empty."""
|
||||
with patch("scribe.services.rulebooks.excluded_always_on_rulebooks", AsyncMock(return_value=[])):
|
||||
if True:
|
||||
yield
|
||||
|
||||
|
||||
@@ -345,7 +345,7 @@ async def test_get_applicable_rules_surfaces_suppressed_with_context():
|
||||
assert result["suppressed_topics"][0]["title"] == "design-system"
|
||||
|
||||
|
||||
# ── rule_brief + tier (milestone 307) ───────────────────────────────────
|
||||
# ── rule_brief (milestone 307) ──────────────────────────────────────────
|
||||
|
||||
def test_rule_brief_carries_age_but_not_the_deep_fields():
|
||||
"""The shape a SURFACED rule takes, and the reason it exists.
|
||||
@@ -362,7 +362,6 @@ def test_rule_brief_carries_age_but_not_the_deep_fields():
|
||||
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"
|
||||
@@ -381,17 +380,6 @@ def test_rule_brief_omits_keys_a_rule_has_no_value_for():
|
||||
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"
|
||||
|
||||
|
||||
# ── verify_with / expires_when (milestone 312) ──────────────────────────
|
||||
@@ -467,7 +455,6 @@ def test_a_sweep_row_carries_the_check_in_full():
|
||||
assert row["verify_with"] == "cat CI-runner/renovate/config.js"
|
||||
assert row["expires_when"] == "dependencyDashboardApproval is turned off"
|
||||
assert row["when_to_apply"] == "when a dependency bump is in play"
|
||||
assert row["tier"] == "always_on"
|
||||
|
||||
|
||||
def test_never_verified_reports_no_day_count_rather_than_zero():
|
||||
@@ -495,13 +482,3 @@ def test_a_verified_row_counts_the_days():
|
||||
assert row["days_since_verified"] == 74
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unrecognised_tier_filter_raises_rather_than_narrowing():
|
||||
"""_valid_tier's silent always_on fallback is right for a WRITE — a typo
|
||||
should leave a rule binding. It is wrong for a FILTER, where the same
|
||||
fallback would quietly answer a different question than the one asked and
|
||||
return a short list that looks like good news."""
|
||||
from scribe.services.rulebooks import rules_due_for_verification
|
||||
|
||||
with pytest.raises(ValueError, match="tier must be one of"):
|
||||
await rules_due_for_verification(7, tier="occasionally")
|
||||
|
||||
Reference in New Issue
Block a user