feat(design-systems): give a design system a way to reach the session
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 31s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 31s
Storing a design system never made a session aware of one. Rules get pushed into every session by the SessionStart hook and returned by enter_project; a design system had neither, so its standards were reachable only by an agent that already knew to call resolve_design_system — the same silent failure as a token nobody declares. That gap was invisible while the operator's visual standards also lived in a rulebook. Retiring that rulebook (which is what this unblocks) would have deleted design guidance from every session with nothing to say so. - services/design_systems.design_context() — the delivery side. Guidance is chain-merged ANCESTOR-FIRST: a child system holds only what it CHANGES, so its own guidance describes a departure from a house style it never restates, and the leaf alone is a fragment. Tokens are summarised (count + group names), not listed — a hundred declarations would crowd out the context they are meant to inform. - enter_project returns `design_system`, null when the project has none. - The SessionStart context gains a Design system block with pointers to the values, alongside the always-on rules. - server.py's entity list gains Design system, including the negative: do NOT record one as a rulebook, because a token kept as prose cannot be resolved, inherited, rendered or checked. - The rulebook-tier passage used "a design-system rulebook" as its worked example of a subscribed rulebook — it now teaches the opposite, plus a new "is this a rule at all?" test pointing at design systems, processes and snippets. - using-scribe gains a section on building UI against the project's system. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -17,12 +17,16 @@ def _bind_user():
|
||||
_user_id_ctx.reset(token)
|
||||
|
||||
|
||||
def _fake_project(**overrides) -> MagicMock:
|
||||
def _fake_project(design_system_id=None, **overrides) -> MagicMock:
|
||||
p = MagicMock()
|
||||
base = {"id": 1, "title": "P", "description": "", "goal": "",
|
||||
"status": "active", "color": None}
|
||||
base.update(overrides)
|
||||
p.to_dict.return_value = base
|
||||
# Explicit, because a bare MagicMock hands back a truthy auto-attribute —
|
||||
# which would route every project in this file through the design-system
|
||||
# branch and out to a real database.
|
||||
p.design_system_id = design_system_id
|
||||
return p
|
||||
|
||||
|
||||
@@ -176,6 +180,45 @@ async def test_enter_project_composes_full_context():
|
||||
assert out["open_tasks"][0]["id"] == 100
|
||||
assert out["open_tasks"][0]["status"] == "in_progress"
|
||||
assert out["recent_notes"][0]["id"] == 200
|
||||
# No design system on this project -> the key is present and null, not
|
||||
# absent. A caller that has to distinguish "no key" from "no system" will
|
||||
# eventually get it wrong.
|
||||
assert out["design_system"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_project_hands_back_the_design_system_when_the_project_has_one():
|
||||
"""The handshake is where an agent learns what binds it, and a design
|
||||
system binds the same way a rule does. Before this it was reachable only by
|
||||
an agent that already knew to call resolve_design_system — so the standards
|
||||
were present in the store and absent from the work."""
|
||||
p = _fake_project(id=5, design_system_id=9)
|
||||
design = {"id": 9, "title": "App kit", "guidance": [{"title": "House"}],
|
||||
"token_count": 95, "token_groups": ["surface"],
|
||||
"inherits_from": ["House"], "description": ""}
|
||||
|
||||
with 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": []}),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
|
||||
AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.notes_svc.list_notes",
|
||||
AsyncMock(side_effect=[([], 0), ([], 0)]),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.design_systems_svc.design_context",
|
||||
AsyncMock(return_value=design),
|
||||
) as ctx:
|
||||
out = await enter_project(project_id=5)
|
||||
|
||||
assert out["design_system"]["token_count"] == 95
|
||||
assert out["design_system"]["inherits_from"] == ["House"]
|
||||
assert ctx.await_args.args == (7, 9) # caller's id, the project's system
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -321,3 +321,94 @@ async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself():
|
||||
assert result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
|
||||
assert "--fs-moss: #4a5d3f;" in result["css"]
|
||||
assert "FabledSword" in result["css"]
|
||||
|
||||
|
||||
# --- design_context: the delivery side --------------------------------------
|
||||
|
||||
def _ctx_token(name: str, group: str | None):
|
||||
from scribe.services.design_cascade import ResolvedToken
|
||||
return ResolvedToken(
|
||||
name=name, contributions={}, group_name=group,
|
||||
purpose=None, rationale=None, supersedes=(), order_index=0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_design_context_merges_guidance_ANCESTOR_FIRST():
|
||||
"""LOAD-BEARING. A child system holds only what it CHANGES, so its own
|
||||
guidance describes a departure from a house style it never restates. An
|
||||
agent handed the leaf alone builds against a fragment with no signal that
|
||||
the rest exists — which is exactly the failure retiring the design rulebook
|
||||
would otherwise have caused.
|
||||
"""
|
||||
family = MagicMock(id=1, deleted_at=None, owner_user_id=42,
|
||||
description="the house", guidance="Dark-mode-first.")
|
||||
family.title = "House"
|
||||
app = MagicMock(id=3, deleted_at=None, owner_user_id=42,
|
||||
description="one app", guidance="Accent on the wordmark.")
|
||||
app.title = "App"
|
||||
|
||||
mock_session = _make_mock_session()
|
||||
mock_session.get = AsyncMock(return_value=app)
|
||||
mock_session.execute = AsyncMock(return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family]))
|
||||
))
|
||||
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems._parent_map",
|
||||
AsyncMock(return_value={3: 1, 1: None})), \
|
||||
patch("scribe.services.design_systems.resolve_design_system",
|
||||
AsyncMock(return_value=[
|
||||
_ctx_token("--a", "surface"), _ctx_token("--b", "type"),
|
||||
_ctx_token("--c", "surface"), _ctx_token("--d", None),
|
||||
])):
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import design_context
|
||||
out = await design_context(user_id=42, design_system_id=3)
|
||||
|
||||
assert [g["title"] for g in out["guidance"]] == ["House", "App"]
|
||||
assert out["inherits_from"] == ["House"]
|
||||
assert out["token_count"] == 4
|
||||
# Group names deduped and sorted; an ungrouped token contributes nothing.
|
||||
assert out["token_groups"] == ["surface", "type"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_design_context_denied_returns_none():
|
||||
"""It rides on resolve_design_system's ACL rather than re-deriving one —
|
||||
a second permission path is a second thing to get wrong."""
|
||||
with patch("scribe.services.design_systems.resolve_design_system",
|
||||
AsyncMock(return_value=None)):
|
||||
from scribe.services.design_systems import design_context
|
||||
assert await design_context(user_id=1, design_system_id=3) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_design_context_omits_systems_with_no_guidance():
|
||||
"""A system that overrides one token and says nothing about it should not
|
||||
contribute an empty section — a heading with nothing under it reads as
|
||||
missing content rather than as an absence of content."""
|
||||
family = MagicMock(id=1, deleted_at=None, owner_user_id=42,
|
||||
description="", guidance="Dark-mode-first.")
|
||||
family.title = "House"
|
||||
app = MagicMock(id=3, deleted_at=None, owner_user_id=42,
|
||||
description="", guidance=" ")
|
||||
app.title = "App"
|
||||
|
||||
mock_session = _make_mock_session()
|
||||
mock_session.get = AsyncMock(return_value=app)
|
||||
mock_session.execute = AsyncMock(return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family]))
|
||||
))
|
||||
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems._parent_map",
|
||||
AsyncMock(return_value={3: 1, 1: None})), \
|
||||
patch("scribe.services.design_systems.resolve_design_system",
|
||||
AsyncMock(return_value=[])):
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import design_context
|
||||
out = await design_context(user_id=42, design_system_id=3)
|
||||
|
||||
assert [g["title"] for g in out["guidance"]] == ["House"]
|
||||
assert out["token_count"] == 0
|
||||
|
||||
@@ -130,7 +130,11 @@ async def test_build_session_context_renders_titles_grouped_by_topic():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_session_context_includes_project_when_scoped():
|
||||
project = MagicMock(id=2, title="FabledScribe", goal="ship it")
|
||||
# design_system_id explicitly None: a bare MagicMock would hand back a truthy
|
||||
# auto-attribute and send this through the design branch, which is the
|
||||
# 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",
|
||||
@@ -145,6 +149,68 @@ async def test_build_session_context_includes_project_when_scoped():
|
||||
assert out["project"] == {"id": 2, "title": "FabledScribe"}
|
||||
assert "## Active project: FabledScribe (id 2)" in out["context"]
|
||||
assert "Open todo tasks: 4" in out["context"]
|
||||
# No design system on the project -> no design block at all. An install with
|
||||
# none is the ordinary case, not a degraded one.
|
||||
assert "## Design system" not in out["context"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_session_context_pushes_the_projects_design_system():
|
||||
"""The gap this closes: a design system had no push channel, so its
|
||||
standards reached a session only if the agent already knew to go looking —
|
||||
the same silent failure as a token nobody declares."""
|
||||
project = MagicMock(id=2, title="App", goal="", design_system_id=9)
|
||||
design = {
|
||||
"id": 9, "title": "App kit", "description": "",
|
||||
"inherits_from": ["House"],
|
||||
"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",
|
||||
AsyncMock(return_value=project)), \
|
||||
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
||||
AsyncMock(return_value=([], 0))), \
|
||||
patch("scribe.services.plugin_context.design_systems_svc.design_context",
|
||||
AsyncMock(return_value=design)):
|
||||
from scribe.services.plugin_context import build_session_context
|
||||
out = await build_session_context(user_id=7, project_id=2)
|
||||
|
||||
ctx = out["context"]
|
||||
assert "## Design system: App kit (id 9) (inherits House)" in ctx
|
||||
assert "95 tokens across accent, surface, type" in ctx
|
||||
# A pointer to the values, never the values themselves — a hundred token
|
||||
# declarations would crowd out the context they are meant to inform. The
|
||||
# summary carries counts and group NAMES only, which is why design_context
|
||||
# returns those rather than the resolved tokens.
|
||||
assert "resolve_design_system(9)" in ctx
|
||||
assert "get_design_system_stylesheet(9)" in ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_session_context_survives_an_unreadable_design_system():
|
||||
"""design_context returns None when the caller may not read the 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",
|
||||
AsyncMock(return_value=project)), \
|
||||
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
||||
AsyncMock(return_value=([], 0))), \
|
||||
patch("scribe.services.plugin_context.design_systems_svc.design_context",
|
||||
AsyncMock(return_value=None)):
|
||||
from scribe.services.plugin_context import build_session_context
|
||||
out = await build_session_context(user_id=7, project_id=2)
|
||||
|
||||
assert "## Design system" not in out["context"]
|
||||
assert "## Active project: App (id 2)" in out["context"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user