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:
@@ -104,7 +104,7 @@ shared homes general:
|
|||||||
norms that bind *every* project. Cross-project standards only.
|
norms that bind *every* project. Cross-project standards only.
|
||||||
- **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a
|
- **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a
|
||||||
reusable, *themed* module of general rules that binds only projects that opt
|
reusable, *themed* module of general rules that binds only projects that opt
|
||||||
in (e.g. a design system → visual apps). Themed, but still project-agnostic.
|
in (e.g. a review checklist → every service). Themed, but project-agnostic.
|
||||||
- **Project rule** (`create_project_rule`) — anything specific to one project
|
- **Project rule** (`create_project_rule`) — anything specific to one project
|
||||||
(its files, paths, quirks).
|
(its files, paths, quirks).
|
||||||
|
|
||||||
@@ -114,6 +114,26 @@ project rule; a standard a category shares → subscribed rulebook; a universal
|
|||||||
norm → always-on rulebook. Never put project-specific detail in a shared
|
norm → always-on rulebook. Never put project-specific detail in a shared
|
||||||
rulebook — it leaks to every other project that gets it.
|
rulebook — it leaks to every other project that gets it.
|
||||||
|
|
||||||
|
**First ask whether it's a rule at all.** A rule is prose you have to remember
|
||||||
|
and apply; Scribe's other entities are structure a tool can resolve and check.
|
||||||
|
Visual standards belong in a **design system**, not a rulebook — a token can be
|
||||||
|
inherited, resolved per mode, rendered to a stylesheet and diffed against code,
|
||||||
|
and none of that survives being written as a rule. A repeatable procedure is a
|
||||||
|
**process**; reusable code is a **snippet**. Reach for a rule when the thing
|
||||||
|
really is a standing instruction about how to work.
|
||||||
|
|
||||||
|
## Building UI: the project's design system binds
|
||||||
|
|
||||||
|
`enter_project` returns a `design_system` when the project has one, with the
|
||||||
|
guidance **chain-merged** — the house style it inherits plus its own departures
|
||||||
|
from it. Treat it the way you treat a rule.
|
||||||
|
|
||||||
|
Before writing a colour, size, radius, weight or duration by hand, reach for a
|
||||||
|
token: `resolve_design_system(id)` for the values, or
|
||||||
|
`get_design_system_stylesheet(id)` for the rendered sheet. A literal is a value
|
||||||
|
stated outside the system, so it can never follow a palette change — and
|
||||||
|
nothing will tell you it drifted.
|
||||||
|
|
||||||
## Other Scribe process-skills
|
## Other Scribe process-skills
|
||||||
|
|
||||||
This plugin also ships focused process-skills — writing-plans, systematic
|
This plugin also ships focused process-skills — writing-plans, systematic
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ What each part is for, and when to reach for it:
|
|||||||
- Note: durable free-form knowledge — reference material, decisions, logs of
|
- Note: durable free-form knowledge — reference material, decisions, logs of
|
||||||
what happened.
|
what happened.
|
||||||
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
|
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
|
||||||
|
- Design system: the visual standards a project's UI is built from — design
|
||||||
|
tokens (name + value per mode) plus the prose a token table cannot hold
|
||||||
|
(aesthetic, voice, what is out of scope). Systems INHERIT: a child holds only
|
||||||
|
what it changes and the chain supplies the rest, so a family's house style and
|
||||||
|
one app's departures from it are the same structure at two depths. A project
|
||||||
|
points at one with set_project_design_system, and enter_project then hands it
|
||||||
|
back with the guidance chain-merged. Treat it as binding for UI work: reach
|
||||||
|
for a token (resolve_design_system / get_design_system_stylesheet) before
|
||||||
|
writing a colour, size, radius or duration by hand. Do NOT record a design
|
||||||
|
system as a rulebook — rules are for behaviour, and tokens kept as prose
|
||||||
|
cannot be resolved, inherited, rendered to a stylesheet, or checked against
|
||||||
|
code.
|
||||||
- System: a per-project, reusable, self-describing subsystem/area. Associate any
|
- System: a per-project, reusable, self-describing subsystem/area. Associate any
|
||||||
record (note, task, issue) with it via system_ids so research, build-work, and
|
record (note, task, issue) with it via system_ids so research, build-work, and
|
||||||
fixes for the same area line up, and recurring problem-spots surface. Manage
|
fixes for the same area line up, and recurring problem-spots surface. Manage
|
||||||
@@ -144,8 +156,8 @@ right altitude:
|
|||||||
subscribe_project_to_rulebook) — a reusable, THEMED module of general
|
subscribe_project_to_rulebook) — a reusable, THEMED module of general
|
||||||
rules that binds only the projects which subscribe. Its rules must make
|
rules that binds only the projects which subscribe. Its rules must make
|
||||||
sense for every project that could subscribe, never one specific project
|
sense for every project that could subscribe, never one specific project
|
||||||
(e.g. a design-system rulebook: design-specific but project-agnostic — no
|
(e.g. a code-review checklist, or a compliance regime a category of
|
||||||
rule names a single app).
|
projects shares — no rule names a single app).
|
||||||
- Project rule (create_project_rule) — anything specific to ONE project.
|
- Project rule (create_project_rule) — anything specific to ONE project.
|
||||||
Both rulebook tiers are SHARED, so their rules stay general; the difference
|
Both rulebook tiers are SHARED, so their rules stay general; the difference
|
||||||
between them is REACH (all projects vs opt-in by theme), not generality. Rule
|
between them is REACH (all projects vs opt-in by theme), not generality. Rule
|
||||||
@@ -153,6 +165,15 @@ of thumb: names a specific project's files/paths/quirks -> project rule; a
|
|||||||
standard a CATEGORY of projects shares -> subscribed rulebook; a universal
|
standard a CATEGORY of projects shares -> subscribed rulebook; a universal
|
||||||
norm -> always-on rulebook. Coordinate with the operator on which home fits.
|
norm -> always-on rulebook. Coordinate with the operator on which home fits.
|
||||||
|
|
||||||
|
Before writing a rule, check whether another entity already models the thing.
|
||||||
|
A rule is prose an agent must remember and apply; the other entities are
|
||||||
|
structure a tool can resolve, render and check. Visual standards are a DESIGN
|
||||||
|
SYSTEM, not a rulebook — a token can be inherited, resolved per mode, rendered
|
||||||
|
to a stylesheet and diffed against code, and none of that survives being
|
||||||
|
written as a rule. A repeatable procedure is a PROCESS. Reusable code is a
|
||||||
|
SNIPPET. Reach for a rule when the thing genuinely is a standing instruction
|
||||||
|
about how to work, and nothing else can hold it.
|
||||||
|
|
||||||
One thing NOT to do: don't bridge Scribe into a session by writing to the
|
One thing NOT to do: don't bridge Scribe into a session by writing to the
|
||||||
host's native memory. Rules are pull-only, so a fresh session won't reach for
|
host's native memory. Rules are pull-only, so a fresh session won't reach for
|
||||||
them unless its always-loaded context says to — but the bridge for that is the
|
them unless its always-loaded context says to — but the bridge for that is the
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ keeps working.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from scribe.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
|
from scribe.services import design_systems as design_systems_svc
|
||||||
from scribe.services import milestones as milestones_svc
|
from scribe.services import milestones as milestones_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from scribe.services import projects as projects_svc
|
from scribe.services import projects as projects_svc
|
||||||
@@ -53,7 +54,13 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
|
|
||||||
Returns a dict with keys: project, milestone_summary, applicable_rules,
|
Returns a dict with keys: project, milestone_summary, applicable_rules,
|
||||||
project_rules, subscribed_rulebooks, applicable_rules_truncated,
|
project_rules, subscribed_rulebooks, applicable_rules_truncated,
|
||||||
open_tasks, recent_notes.
|
open_tasks, recent_notes, design_system.
|
||||||
|
|
||||||
|
`design_system` is null unless the project points at one. When present it
|
||||||
|
carries the chain-merged guidance (the house style AND this project's
|
||||||
|
departures from it) plus a summary of the token set — treat it as binding
|
||||||
|
for any UI you write, and pull the values with resolve_design_system or
|
||||||
|
get_design_system_stylesheet before reaching for a literal.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
project = await projects_svc.get_project(uid, project_id)
|
project = await projects_svc.get_project(uid, project_id)
|
||||||
@@ -74,9 +81,17 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
uid, is_task=False, project_id=project_id,
|
uid, is_task=False, project_id=project_id,
|
||||||
sort="updated_at", limit=5,
|
sort="updated_at", limit=5,
|
||||||
)
|
)
|
||||||
|
# A project need not have one, and most installs won't — null is ordinary
|
||||||
|
# here, not a missing prerequisite.
|
||||||
|
design_system = None
|
||||||
|
if project.design_system_id:
|
||||||
|
design_system = await design_systems_svc.design_context(
|
||||||
|
uid, project.design_system_id,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"project": project.to_dict(),
|
"project": project.to_dict(),
|
||||||
|
"design_system": design_system,
|
||||||
"milestone_summary": milestone_summary,
|
"milestone_summary": milestone_summary,
|
||||||
"applicable_rules": applicable["rules"],
|
"applicable_rules": applicable["rules"],
|
||||||
"project_rules": applicable.get("project_rules", []),
|
"project_rules": applicable.get("project_rules", []),
|
||||||
|
|||||||
@@ -282,6 +282,68 @@ async def resolve_design_system(
|
|||||||
return resolve_tokens(design_system_id, parents, tokens_by_system)
|
return resolve_tokens(design_system_id, parents, tokens_by_system)
|
||||||
|
|
||||||
|
|
||||||
|
async def design_context(user_id: int, design_system_id: int) -> dict | None:
|
||||||
|
"""What a session needs to know about a design system, before it writes UI.
|
||||||
|
|
||||||
|
This is the DELIVERY side of a design system, and it exists because storing
|
||||||
|
one does not make a session aware of it. Rules get pushed into every session
|
||||||
|
by the plugin's SessionStart hook; a design system had no such channel, so
|
||||||
|
the standards were reachable only by an agent that already knew to go
|
||||||
|
looking — which is the same silent failure as a token nobody declares.
|
||||||
|
|
||||||
|
Guidance is chain-merged, ANCESTOR-FIRST, and that is the point rather than
|
||||||
|
a convenience. A child system holds only what it CHANGES, so its own
|
||||||
|
guidance describes a departure from a house style it never restates. Hand an
|
||||||
|
agent the leaf alone and it builds against a fragment, with no signal that
|
||||||
|
the rest exists.
|
||||||
|
|
||||||
|
Tokens are summarised, not listed: the count and the group names are enough
|
||||||
|
to know what the system covers, and the full set is one call away. Sending
|
||||||
|
a hundred token values into every session start would crowd out the context
|
||||||
|
it is meant to inform.
|
||||||
|
|
||||||
|
None when the caller may not read the system.
|
||||||
|
"""
|
||||||
|
tokens = await resolve_design_system(user_id, design_system_id)
|
||||||
|
if tokens is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
system = await session.get(DesignSystem, design_system_id)
|
||||||
|
if system is None or system.deleted_at is not None:
|
||||||
|
return None
|
||||||
|
parents = await _parent_map(session, system.owner_user_id)
|
||||||
|
chain = ancestry(design_system_id, parents)
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(DesignSystem).where(DesignSystem.id.in_(chain))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
by_id = {s.id: s for s in rows}
|
||||||
|
return {
|
||||||
|
"id": system.id,
|
||||||
|
"title": system.title,
|
||||||
|
"description": system.description or "",
|
||||||
|
# Outermost ancestor first, so the reader meets the house style before
|
||||||
|
# the app's departures from it.
|
||||||
|
"inherits_from": [
|
||||||
|
by_id[sid].title for sid in reversed(chain[1:]) if sid in by_id
|
||||||
|
],
|
||||||
|
"guidance": [
|
||||||
|
{
|
||||||
|
"design_system_id": sid,
|
||||||
|
"title": by_id[sid].title,
|
||||||
|
"guidance": (by_id[sid].guidance or "").strip(),
|
||||||
|
}
|
||||||
|
for sid in reversed(chain)
|
||||||
|
if sid in by_id and (by_id[sid].guidance or "").strip()
|
||||||
|
],
|
||||||
|
"token_count": len(tokens),
|
||||||
|
"token_groups": sorted({t.group_name for t in tokens if t.group_name}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def update_token(
|
async def update_token(
|
||||||
user_id: int, token_id: int, **fields: object
|
user_id: int, token_id: int, **fields: object
|
||||||
) -> DesignToken | None:
|
) -> DesignToken | None:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.rulebook import RulebookTopic
|
from scribe.models.rulebook import RulebookTopic
|
||||||
|
from scribe.services import design_systems as design_systems_svc
|
||||||
from scribe.services import knowledge as knowledge_svc
|
from scribe.services import knowledge as knowledge_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from scribe.services import projects as projects_svc
|
from scribe.services import projects as projects_svc
|
||||||
@@ -837,6 +838,37 @@ async def build_session_context(
|
|||||||
f"Goal: {goal[:200]}" if goal else "",
|
f"Goal: {goal[:200]}" if goal else "",
|
||||||
f"Open todo tasks: {open_count}",
|
f"Open todo tasks: {open_count}",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# A design system binds the same way a rule does, and until this
|
||||||
|
# existed it had no push channel — the standards were reachable only
|
||||||
|
# by an agent that already knew to look for them. Summary only: the
|
||||||
|
# token VALUES are a tool call away, and pasting a hundred of them
|
||||||
|
# into every session would crowd out the context they inform.
|
||||||
|
if project.design_system_id:
|
||||||
|
design = await design_systems_svc.design_context(
|
||||||
|
user_id, project.design_system_id,
|
||||||
|
)
|
||||||
|
if design:
|
||||||
|
inherits = (
|
||||||
|
" (inherits " + " › ".join(design["inherits_from"]) + ")"
|
||||||
|
if design["inherits_from"] else ""
|
||||||
|
)
|
||||||
|
groups = ", ".join(design["token_groups"])
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"## Design system: {design['title']} "
|
||||||
|
f"(id {design['id']}){inherits}",
|
||||||
|
f"{design['token_count']} tokens"
|
||||||
|
+ (f" across {groups}" if groups else "")
|
||||||
|
+ ". This project's UI is built from these, not from "
|
||||||
|
"literals — reach for a token before writing a colour, "
|
||||||
|
"size, radius or duration by hand.",
|
||||||
|
f"Values: `resolve_design_system({design['id']})` · "
|
||||||
|
f"stylesheet: `get_design_system_stylesheet({design['id']})` "
|
||||||
|
f"· the prose (aesthetic, voice, where the accent may "
|
||||||
|
f"appear): `enter_project` returns it, or "
|
||||||
|
f"`get_design_system({design['id']})`.",
|
||||||
|
]
|
||||||
elif unbound_repo:
|
elif unbound_repo:
|
||||||
lines += [
|
lines += [
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ def _bind_user():
|
|||||||
_user_id_ctx.reset(token)
|
_user_id_ctx.reset(token)
|
||||||
|
|
||||||
|
|
||||||
def _fake_project(**overrides) -> MagicMock:
|
def _fake_project(design_system_id=None, **overrides) -> MagicMock:
|
||||||
p = MagicMock()
|
p = MagicMock()
|
||||||
base = {"id": 1, "title": "P", "description": "", "goal": "",
|
base = {"id": 1, "title": "P", "description": "", "goal": "",
|
||||||
"status": "active", "color": None}
|
"status": "active", "color": None}
|
||||||
base.update(overrides)
|
base.update(overrides)
|
||||||
p.to_dict.return_value = base
|
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
|
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]["id"] == 100
|
||||||
assert out["open_tasks"][0]["status"] == "in_progress"
|
assert out["open_tasks"][0]["status"] == "in_progress"
|
||||||
assert out["recent_notes"][0]["id"] == 200
|
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
|
@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 result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
|
||||||
assert "--fs-moss: #4a5d3f;" in result["css"]
|
assert "--fs-moss: #4a5d3f;" in result["css"]
|
||||||
assert "FabledSword" 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
|
@pytest.mark.asyncio
|
||||||
async def test_build_session_context_includes_project_when_scoped():
|
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",
|
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
||||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
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 out["project"] == {"id": 2, "title": "FabledScribe"}
|
||||||
assert "## Active project: FabledScribe (id 2)" in out["context"]
|
assert "## Active project: FabledScribe (id 2)" in out["context"]
|
||||||
assert "Open todo tasks: 4" 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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user