"""Design system + token persistence, and the guard on the parent chain. Access goes through `services/access.py` (rule #78), where reaching a system via a project you can see grants READ but never write — see `get_design_system_permission` for why that asymmetry exists. The cascade itself lives in `services/design_cascade.py` as pure functions. This module is the part that needs a database: loading the shape of the hierarchy, refusing writes that would break it, and storing tokens. Nothing here seeds or implies a default system. An install with no design systems is an ordinary install, and every caller must handle an empty list as the normal case rather than a missing prerequisite. """ import logging from datetime import datetime, timezone from sqlalchemy import select from scribe.models import async_session from scribe.models.design_system import DesignSystem, DesignToken from scribe.models.project import Project from scribe.services import access from scribe.services.design_stylesheet import ( check_code_against_tokens, derivation_report, duplicate_values, render_stylesheet, ) from scribe.services.design_starter_roles import ( DEFAULT_TOKEN_PREFIX, starter_tokens, ) from scribe.services.design_cascade import ( ResolvedToken, ancestry, resolve_tokens, would_cycle, ) logger = logging.getLogger(__name__) class DesignSystemCycle(ValueError): """A parent assignment that would close an inheritance loop. Raised rather than returned as None because the two outcomes need different answers: None already means "not found, or not yours", and a caller that conflated them would show "no such design system" for what is really "that parent is one of its own descendants". Routes map this to a 400. """ async def _parent_map(session, owner_user_id: int) -> dict[int, int | None]: """`{id: parent_id}` for one owner's live systems — the hierarchy's shape. Scoped to the OWNER of the systems, not the caller, and the distinction is load-bearing on the read path: a caller reading through a shared project does not own any link in the chain, and a caller-scoped map would hand them an empty forest and a cascade truncated to one system. They would get a page that renders with plausible wrong values and no error anywhere. Owner-scoping is also the constraint on parenting: a chain may only be built from systems its owner controls, or someone else's delete or re-parent would silently restyle your app. """ rows = ( await session.execute( select(DesignSystem.id, DesignSystem.parent_id).where( DesignSystem.owner_user_id == owner_user_id, DesignSystem.deleted_at.is_(None), ) ) ).all() return dict(rows) # --- design systems --------------------------------------------------------- async def create_design_system( user_id: int, title: str, description: str | None = None, guidance: str | None = None, parent_id: int | None = None, starter_role_groups: list[str] | None = None, token_prefix: str = DEFAULT_TOKEN_PREFIX, ) -> DesignSystem | None: """Create a system, with or without a parent. Returns None when `parent_id` names a system the caller may not write — which, per the ACL, means one they do not own. `starter_role_groups` seeds the system with named, VALUELESS token roles (#2349) — the moment a role is missing is the moment a literal gets written instead, so the cheapest time to name them is now. Pass a list of group names to choose, `[]` for none, or None for none. None and `[]` deliberately mean the same thing here, unlike in `starter_tokens` where None means "all": creation must not seed 40 rows into a system whose caller never asked. Opting in is the caller's job, and the UI's default of everything-checked lives in the UI. """ if parent_id is not None and not await access.can_write_design_system( user_id, parent_id ): return None async with async_session() as session: system = DesignSystem( owner_user_id=user_id, title=title.strip(), description=description, guidance=guidance, parent_id=parent_id, ) session.add(system) await session.commit() await session.refresh(system) if starter_role_groups: for row in starter_tokens(starter_role_groups, prefix=token_prefix): session.add(DesignToken(design_system_id=system.id, **row)) await session.commit() return system async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem | 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 if not await access.can_read_design_system(user_id, design_system_id): return None return system async def list_design_systems(user_id: int) -> list[DesignSystem]: """The caller's own systems, ordered by title. Empty is normal.""" async with async_session() as session: rows = await session.execute( select(DesignSystem) .where( DesignSystem.owner_user_id == user_id, DesignSystem.deleted_at.is_(None), ) .order_by(DesignSystem.title) ) return list(rows.scalars().all()) async def update_design_system( user_id: int, design_system_id: int, **fields: object ) -> DesignSystem | None: """Partial update. Raises DesignSystemCycle if `parent_id` would loop. `parent_id` is handled apart from the other fields because None is a meaningful value for it — "make this a root" — where for every other field None means "leave alone". Callers signal it by passing the key at all. """ if not await access.can_write_design_system(user_id, design_system_id): 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 if "parent_id" in fields: parent_id = fields.pop("parent_id") if parent_id is not None: parent_id = int(parent_id) if not await access.can_write_design_system(user_id, parent_id): return None if would_cycle( design_system_id, parent_id, await _parent_map(session, system.owner_user_id), ): raise DesignSystemCycle( f"Design system {design_system_id} cannot inherit from " f"{parent_id}: that system already inherits from it." ) system.parent_id = parent_id for key, value in fields.items(): if key in ("title", "description", "guidance") and value is not None: setattr(system, key, value) system.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(system) return system async def delete_design_system(user_id: int, design_system_id: int) -> bool: """Soft-delete a system. Children survive as roots (the FK is SET NULL).""" if not await access.can_write_design_system(user_id, design_system_id): return False 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 False system.deleted_at = datetime.now(timezone.utc) await session.commit() return True # --- tokens ----------------------------------------------------------------- async def create_token( user_id: int, design_system_id: int, name: str, value_by_mode: dict | None = None, group_name: str | None = None, purpose: str | None = None, rationale: str | None = None, supersedes: list | None = None, order_index: int = 0, ) -> DesignToken | None: if not await access.can_write_design_system(user_id, design_system_id): return None async with async_session() as session: token = DesignToken( design_system_id=design_system_id, name=name.strip(), # `or {}` and not the argument as given: the column is NOT NULL so # that absence has exactly one spelling. Passing None here would # otherwise store JSON null and reintroduce the second empty state. value_by_mode=value_by_mode or {}, group_name=group_name, purpose=purpose, rationale=rationale, # `or []` for the same reason as value_by_mode above: the column is # NOT NULL so absence has one spelling, and None would store JSON # null instead of an empty array. supersedes=supersedes or [], order_index=order_index, ) session.add(token) await session.commit() await session.refresh(token) return token async def list_tokens(user_id: int, design_system_id: int) -> list[DesignToken]: """One system's OWN tokens — its override set, not its effective set. Resolving the chain is step 2's job; this deliberately answers the narrower question ("what does this system change?") that the model exists to make free. """ if not await access.can_read_design_system(user_id, design_system_id): return [] async with async_session() as session: rows = await session.execute( select(DesignToken) .where( DesignToken.design_system_id == design_system_id, DesignToken.deleted_at.is_(None), ) .order_by(DesignToken.order_index.asc(), DesignToken.name.asc()) ) return list(rows.scalars().all()) async def resolve_design_system( user_id: int, design_system_id: int ) -> list[ResolvedToken] | None: """A system's EFFECTIVE token set — everything it inherits, with its own on top. None when the caller may not read the system; an empty list when the chain genuinely holds no tokens, which is an ordinary state for a system that has just been created. Two queries regardless of how deep the chain runs: one for the hierarchy's shape, one for every token in it. The flattening itself is `design_cascade.resolve_tokens` — pure, so the cascade rule is tested without a database and this function is only the loading. """ if not await access.can_read_design_system(user_id, design_system_id): 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(DesignToken) .where( DesignToken.design_system_id.in_(chain), DesignToken.deleted_at.is_(None), ) .order_by(DesignToken.order_index.asc(), DesignToken.name.asc()) ) ).scalars().all() tokens_by_system: dict[int, list[DesignToken]] = {} for token in rows: tokens_by_system.setdefault(token.design_system_id, []).append(token) 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( user_id: int, token_id: int, **fields: object ) -> DesignToken | None: allowed = { "name", "value_by_mode", "group_name", "purpose", "rationale", "supersedes", "order_index", } async with async_session() as session: token = await session.get(DesignToken, token_id) if token is None or token.deleted_at is not None: return None if not await access.can_write_design_system(user_id, token.design_system_id): return None for key, value in fields.items(): if key in allowed and value is not None: setattr(token, key, value) token.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(token) return token async def delete_token(user_id: int, token_id: int) -> bool: async with async_session() as session: token = await session.get(DesignToken, token_id) if token is None or token.deleted_at is not None: return False if not await access.can_write_design_system(user_id, token.design_system_id): return False token.deleted_at = datetime.now(timezone.utc) await session.commit() return True # --- the project pointer ---------------------------------------------------- async def set_project_design_system( user_id: int, project_id: int, design_system_id: int | None ) -> bool: """Point a project at a design system, or at nothing (None clears it). Needs write on the project and READ on the system: pointing at a system is consuming it, not changing it, so a system shared with you through another project is a legitimate choice here. """ if not await access.can_write_project(user_id, project_id): return False if design_system_id is not None and not await access.can_read_design_system( user_id, design_system_id ): return False async with async_session() as session: project = await session.get(Project, project_id) if project is None or project.deleted_at is not None: return False project.design_system_id = design_system_id project.updated_at = datetime.now(timezone.utc) await session.commit() return True # --- the master sheet ------------------------------------------------------- async def stylesheet_for_system( user_id: int, design_system_id: int, root_selector: str = ":root" ) -> dict | None: """The master CSS sheet a design system generates, plus its reuse report. Purpose tokens only — the sheet styles no elements. See `services/design_stylesheet.py` for why that split is the design rather than a shortcut. Returns None if the caller may not read the system. The `duplicates` half is advisory: two tokens sharing a value are either a deliberate alias or one idea recorded twice, and only the operator knows which. """ resolved = await resolve_design_system(user_id, design_system_id) if resolved is None: return None system = await get_design_system(user_id, design_system_id) return { "design_system_id": design_system_id, "css": render_stylesheet( resolved, root_selector=root_selector, title=system.title if system else "", design_system_id=design_system_id, ), "token_count": len(resolved), "valueless": [t.name for t in resolved if not t.value_by_mode], "duplicates": duplicate_values(resolved), # Formulas: which tokens are computed from others, and which of those # point at nothing. A broken formula is dropped by the browser without # any error, so the sheet cannot show it for itself. "derivation": derivation_report(resolved), } async def check_snippets_against_system( user_id: int, design_system_id: int, project_id: int = 0 ) -> dict | None: """Which recorded snippets disagree with this design system's sheet. The relation the operator named — "the snippets use the tags from the sheet" — turned into a check. For each snippet: `var(--x)` references with no such token, literals the sheet says to stop writing, and custom properties the snippet mints for itself instead of using shared ones. Snippets with nothing to report are omitted entirely. A list of everything that is fine is a list nobody reads twice. """ resolved = await resolve_design_system(user_id, design_system_id) if resolved is None: return None from scribe.services import snippets as snippets_svc # `list_snippets` returns (rows, total) and caps limit at 100; `project_id` # must be None — not 0 — to reach across every project, since 0 would filter # to a project with that id. rows, _total = await snippets_svc.list_snippets( user_id=user_id, project_id=project_id or None, limit=100, ) findings: list[dict] = [] for row in rows: snippet_id = row.get("id") if snippet_id is None: continue # The list rows carry a preview, not the code. The check has to read the # whole body or it would report on a truncation. note = await snippets_svc.get_snippet(user_id=user_id, snippet_id=int(snippet_id)) if note is None: continue report = check_code_against_tokens(note.body or "", resolved) if not ( report["unknown"] or report["superseded_literals"] or report["local_definitions"] ): continue findings.append({ "snippet_id": int(snippet_id), "title": note.title or "", **report, }) return { "design_system_id": design_system_id, "checked": len(rows), "findings": findings, }