diff --git a/frontend/src/api/designSystems.ts b/frontend/src/api/designSystems.ts index 64830ba..ffae9f2 100644 --- a/frontend/src/api/designSystems.ts +++ b/frontend/src/api/designSystems.ts @@ -123,3 +123,39 @@ export const setProjectDesignSystem = ( `/api/projects/${projectId}/design-system`, { design_system_id: designSystemId }, ); + +/** One token an import proposes, with the evidence for it. */ +export interface ProposedToken { + name: string; + value_by_mode: Record; + group_name: string | null; + purpose: string | null; + supersedes: string[]; + source_rule_id: number | null; + source_rule_title: string; + source_context: string; +} + +export interface ImportReport { + rulebook_id: number; + proposed: ProposedToken[]; + created: DesignToken[]; + /** Proposals whose name the system already defines. Never overwritten. */ + skipped: string[]; +} + +/** Seed a design system from a rulebook that describes one in prose. + * + * Defaults to a PREVIEW: an import is a proposal, since rulebooks are written + * aspirationally and some of what they describe was never built. Pass + * `apply: true` to write. Existing token names are never overwritten, so a + * second run fills gaps and reports the rest. */ +export const importFromRulebook = ( + designSystemId: number, + rulebookId: number, + apply = false, +) => + apiPost(`/api/design-systems/${designSystemId}/import`, { + rulebook_id: rulebookId, + apply, + }); diff --git a/frontend/src/views/DesignSystemsView.vue b/frontend/src/views/DesignSystemsView.vue index 5c1aeee..2688b13 100644 --- a/frontend/src/views/DesignSystemsView.vue +++ b/frontend/src/views/DesignSystemsView.vue @@ -29,12 +29,15 @@ import { fetchDesignSystems, fetchDesignTokens, fetchResolvedTokens, + importFromRulebook, updateDesignSystem, updateDesignToken, type DesignSystem, type DesignToken, + type ImportReport, type ResolvedToken, } from "@/api/designSystems"; +import { listRulebooks, type Rulebook } from "@/api/rulebooks"; import { ApiError } from "@/api/client"; import { useToastStore } from "@/stores/toast"; @@ -382,6 +385,56 @@ const overrideCount = computed( () => resolved.value.filter((t) => Object.values(t.origin_by_mode).some((id) => id === selectedId.value)).length, ); +// --- import from a rulebook ------------------------------------------------- + +const rulebooks = ref([]); +const importRulebookId = ref(null); +const importReport = ref(null); +const importing = ref(false); +const showImport = ref(false); + +async function loadRulebooks() { + try { + rulebooks.value = await listRulebooks(); + } catch { + rulebooks.value = []; + } +} +onMounted(loadRulebooks); + +/** Preview, then apply — never one step. + * + * A rulebook is prose written aspirationally, so an import is a proposal and + * the operator has to be able to read it before it becomes records. Applying + * from an unread preview is still one click; applying without one is not + * possible, which is the point. */ +async function runImport(apply: boolean) { + if (selectedId.value === null || importRulebookId.value === null || importing.value) return; + importing.value = true; + try { + const report = await importFromRulebook(selectedId.value, importRulebookId.value, apply); + importReport.value = report; + if (apply) { + await loadDetail(selectedId.value); + toast.show(`Imported ${report.created.length} tokens`); + } + } catch { + toast.show("Import failed", "error"); + } finally { + importing.value = false; + } +} + +/** Proposals the rulebook names but states no readable value for. + * + * Surfaced as a count rather than buried: the rulebook writes radius steps and + * type sizes as prose ("Small 4px"), which nothing parses, so these arrive as + * names awaiting a value. That is an honest gap, and hiding it would make the + * import look more complete than it is. */ +const proposalsWithoutValues = computed( + () => importReport.value?.proposed.filter((p) => !Object.keys(p.value_by_mode).length).length ?? 0, +); + function isColourish(value: string): boolean { return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim()); } @@ -529,6 +582,106 @@ function isColourish(value: string): boolean { + +
+
+

Import from a rulebook

+ +
+ + +
+
@@ -1066,6 +1219,10 @@ function isColourish(value: string): boolean { gap: 0.35rem; } +.import-summary { + margin-top: 0.75rem; +} + .supersedes { font-size: 0.75rem; color: var(--color-text-muted); diff --git a/src/scribe/mcp/tools/design_systems.py b/src/scribe/mcp/tools/design_systems.py index 805b118..91f014d 100644 --- a/src/scribe/mcp/tools/design_systems.py +++ b/src/scribe/mcp/tools/design_systems.py @@ -139,6 +139,42 @@ async def delete_design_system(design_system_id: int) -> dict: return {"message": f"Design system {design_system_id} deleted."} +async def import_design_system_from_rulebook( + design_system_id: int, + rulebook_id: int, + apply: bool = False, +) -> dict: + """Seed a design system from a rulebook that describes one in prose. + + Reads the rulebook's colour and token declarations and proposes the tokens + they add up to — joining "Obsidian #14171A" in one rule to `--fs-obsidian` + in another, since neither alone is a token. + + Defaults to a PREVIEW. An import is a proposal: rulebooks are written + aspirationally and some of what they describe was never built, so read + `proposed` before setting apply=True. Every entry carries the rule and + sentence it came from so the claim can be checked rather than trusted. + + Existing token names are never overwritten — a second run fills gaps and + lists the rest under `skipped`, so it is safe to repeat. + + Args: + design_system_id: The system to seed. + rulebook_id: The rulebook to read. + apply: False (default) previews; True writes the tokens. + """ + uid = current_user_id() + report = await ds_svc.import_from_rulebook( + uid, design_system_id, rulebook_id, apply=apply, + ) + if report is None: + raise ValueError( + f"design system {design_system_id} not writable, or rulebook " + f"{rulebook_id} not readable" + ) + return report + + # ── Tokens ────────────────────────────────────────────────────────────── async def create_design_token( @@ -271,6 +307,7 @@ def register(mcp) -> None: resolve_design_system, update_design_system, delete_design_system, + import_design_system_from_rulebook, create_design_token, list_design_tokens, update_design_token, diff --git a/src/scribe/routes/design_systems.py b/src/scribe/routes/design_systems.py index a6ffe6a..c4cd9ea 100644 --- a/src/scribe/routes/design_systems.py +++ b/src/scribe/routes/design_systems.py @@ -114,6 +114,30 @@ async def resolve_design_system(design_system_id: int): }) +@design_systems_bp.post("/design-systems//import") +@login_required +async def import_design_system(design_system_id: int): + """Seed a design system from a rulebook's prose. + + `{"rulebook_id": N}` previews; add `"apply": true` to write. Preview is the + default because an import is a PROPOSAL — rulebooks are written + aspirationally and some of what they describe was never built. + + Existing token names are never overwritten, so a second run fills gaps and + reports the rest rather than undoing corrections. + """ + data = await request.get_json() or {} + rulebook_id = data.get("rulebook_id") + if not isinstance(rulebook_id, int) or rulebook_id <= 0: + return jsonify({"error": "rulebook_id is required"}), 400 + report = await ds_svc.import_from_rulebook( + _uid(), design_system_id, rulebook_id, apply=bool(data.get("apply")), + ) + if report is None: + return _not_found("design system or rulebook") + return jsonify(report) + + # ── Tokens ────────────────────────────────────────────────────────────── @design_systems_bp.get("/design-systems//tokens") diff --git a/src/scribe/services/design_rulebook_import.py b/src/scribe/services/design_rulebook_import.py index 7f637fd..34573c5 100644 --- a/src/scribe/services/design_rulebook_import.py +++ b/src/scribe/services/design_rulebook_import.py @@ -230,3 +230,175 @@ async def design_expectations(user_id: int) -> ExpectationSet: return ExpectationSet(rulebook_id=rulebook_id) return ExpectationSet(rulebook_id=rulebook_id, expectations=extract_expectations(rules)) + + +# --------------------------------------------------------------------------- +# Import — turning a rulebook into a PROPOSED design system (milestone #254 step 3) +# --------------------------------------------------------------------------- +# +# The extraction above answers "what claims does this rulebook make?", which is +# what a drift panel needs. Seeding a design system needs a different shape: +# tokens with names AND values, which the rulebook states in two separate +# places. Rule 51 names the colours ("Obsidian #14171A (page bg, deepest +# surface)"); rule 72 names the custom properties (`--fs-obsidian/iron/...`). +# Neither alone is a token. +# +# So the import joins them on the WORD: `--fs-obsidian` ends with `obsidian`, +# and a colour called Obsidian was declared elsewhere. That join is mechanical +# and it is the only reason an import produces something usable rather than 70 +# empty names. +# +# AN IMPORT IS A PROPOSAL, NOT A TRUTH. Rulebooks are written aspirationally and +# some of what they describe was never built. Every proposed token therefore +# carries the rule and sentence it came from, so a reviewer can check the claim +# rather than trust it. + +# "Obsidian #14171A (page bg, deepest surface)" — a capitalised name, a hex, and +# an optional parenthetical saying what it is for. +_NAMED_COLOUR = re.compile( + r"\b([A-Z][A-Za-z]*(?:\s+[A-Z][A-Za-z]*)?)\s+(#[0-9a-fA-F]{3,8})\b" + r"(?:\s*\(([^)]{0,80})\))?" +) + + +@dataclass +class ProposedToken: + """One token an import suggests, with the evidence for it. + + `value_by_mode` is empty when the rulebook names the token but states no + value this can read — radius steps, type sizes and durations are prose + (`Small 4px`), not hex, and inventing a parse for each would be guessing. + An empty value is the honest output: the name is real, the value needs a + human. Reporting how many landed that way is part of the result. + """ + + name: str + value_by_mode: dict[str, str] = field(default_factory=dict) + group_name: str | None = None + purpose: str | None = None + supersedes: list[str] = field(default_factory=list) + source_rule_id: int | None = None + source_rule_title: str = "" + source_context: str = "" + + def as_dict(self) -> dict: + return { + "name": self.name, + "value_by_mode": self.value_by_mode, + "group_name": self.group_name, + "purpose": self.purpose, + "supersedes": self.supersedes, + "source_rule_id": self.source_rule_id, + "source_rule_title": self.source_rule_title, + "source_context": self.source_context, + } + + +@dataclass +class _NamedColour: + value: str + purpose: str | None + rule_id: int + rule_title: str + context: str + + +def _group_from_name(name: str) -> str | None: + """`--fs-radius-sm` -> "radius"; `--fs-obsidian` -> None. + + A family name has a middle segment; a flat one does not. Structural rather + than a lookup table, so it works on a naming scheme this code has never + seen — which rule #115 requires, since the prefix is each install's own. + """ + parts = [p for p in name.lstrip("-").split("-") if p] + return parts[1] if len(parts) >= 3 else None + + +def _named_colours(rules: list[Rule]) -> dict[str, _NamedColour]: + """Every `Name #hex (purpose)` a rulebook declares, keyed by lowercased name. + + First declaration wins, matching `extract_expectations` — the rule that + introduces a colour is the one worth citing. + """ + out: dict[str, _NamedColour] = {} + for rule in rules: + text = " ".join(filter(None, [rule.statement or "", rule.how_to_apply or ""])) + for sentence in _SENTENCE_SPLIT.split(text): + if not sentence.strip() or _is_negated(sentence): + continue + for match in _NAMED_COLOUR.finditer(sentence): + label, raw_hex, purpose = match.groups() + value = normalize_hex(raw_hex) + key = label.strip().lower() + if not value or key in out: + continue + out[key] = _NamedColour( + value=value, + purpose=(purpose or "").strip() or None, + rule_id=int(rule.id), + rule_title=rule.title, + context=sentence.strip(), + ) + return out + + +def _prohibitions_by_rule(rules: list[Rule]) -> dict[int, list[str]]: + """Forbidden colours, grouped by the rule that forbids them.""" + out: dict[int, list[str]] = {} + for expectation in extract_expectations(rules): + if expectation.kind == "prohibited_color": + out.setdefault(expectation.rule_id, []).append(expectation.value) + return out + + +def propose_tokens(rules: list[Rule]) -> list[ProposedToken]: + """Turn a rulebook into the design system it is describing. + + One proposal per custom-property NAME the rulebook declares, valued from the + named colour whose word matches the token's last segment. + + Prohibitions attach as `supersedes` on the first token drawn from the SAME + rule that forbids them. Rule 52 declares Parchment/Vellum/Ash and forbids + pure white in one breath, so pure white becomes "write --fs-parchment + instead" — the positive form of what the rule was saying. Guessing which + token inherits the prohibition is acceptable precisely because this is a + proposal a human reviews; guessing silently would not be, which is why every + entry carries its source sentence. + """ + colours = _named_colours(rules) + prohibited = _prohibitions_by_rule(rules) + claimed_prohibitions: set[int] = set() + + proposals: list[ProposedToken] = [] + seen: set[str] = set() + + for expectation in extract_expectations(rules): + if expectation.kind != "token" or expectation.value in seen: + continue + seen.add(expectation.value) + + suffix = expectation.value.rsplit("-", 1)[-1].lower() + colour = colours.get(suffix) + + proposal = ProposedToken( + name=expectation.value, + value_by_mode={"base": colour.value} if colour else {}, + group_name=_group_from_name(expectation.value), + purpose=colour.purpose if colour else None, + source_rule_id=colour.rule_id if colour else expectation.rule_id, + source_rule_title=colour.rule_title if colour else expectation.rule_title, + source_context=colour.context if colour else expectation.context, + ) + + # The prohibition rides on the first token that rule supplied a value + # for — its primary. Attaching it to every token of that rule would + # claim the rulebook said something it didn't. + if colour and colour.rule_id not in claimed_prohibitions: + forbidden = prohibited.get(colour.rule_id) + if forbidden: + proposal.supersedes = list(forbidden) + claimed_prohibitions.add(colour.rule_id) + + proposals.append(proposal) + + return proposals diff --git a/src/scribe/services/design_systems.py b/src/scribe/services/design_systems.py index ea8b478..be8ea78 100644 --- a/src/scribe/services/design_systems.py +++ b/src/scribe/services/design_systems.py @@ -331,3 +331,74 @@ async def set_project_design_system( project.updated_at = datetime.now(timezone.utc) await session.commit() return True + + +# --- import from a rulebook ------------------------------------------------- + +async def import_from_rulebook( + user_id: int, + design_system_id: int, + rulebook_id: int, + apply: bool = False, +) -> dict | None: + """Propose (and optionally create) tokens for a system from a rulebook. + + Returns None when the caller may not write the system or read the rulebook. + Otherwise a report with three lists, and the split between them is the whole + point of running it with `apply=False` first: + + proposed — everything the rulebook describes, each entry carrying the + rule and sentence it came from + created — what was actually written (empty unless `apply`) + skipped — proposals whose name the system already defines + + **Existing tokens are never overwritten.** An import is a proposal built by + reading prose; a value already in the record was put there deliberately, and + a re-run must not undo an operator's correction. That also makes the whole + operation safe to repeat — it fills gaps and reports the rest. + + Tokens with no value are still created when `apply` is set. The rulebook + names them, so their absence from the system is itself a finding, and a + named token with a blank value says "this exists and needs deciding" where + silence says nothing at all. + """ + if not await access.can_write_design_system(user_id, design_system_id): + return None + + from scribe.services import rulebooks as rulebooks_svc + from scribe.services.design_rulebook_import import propose_tokens + + rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id) + if not rules: + return {"rulebook_id": rulebook_id, "proposed": [], "created": [], "skipped": []} + + proposals = propose_tokens(rules) + existing = {t.name for t in await list_tokens(user_id, design_system_id)} + + created: list[dict] = [] + skipped: list[str] = [] + for index, proposal in enumerate(proposals): + if proposal.name in existing: + skipped.append(proposal.name) + continue + if not apply: + continue + token = await create_token( + user_id, + design_system_id=design_system_id, + name=proposal.name, + value_by_mode=proposal.value_by_mode, + group_name=proposal.group_name, + purpose=proposal.purpose, + supersedes=proposal.supersedes, + order_index=index, + ) + if token is not None: + created.append(token.to_dict()) + + return { + "rulebook_id": rulebook_id, + "proposed": [p.as_dict() for p in proposals], + "created": created, + "skipped": skipped, + } diff --git a/tests/test_design_rulebook_propose.py b/tests/test_design_rulebook_propose.py new file mode 100644 index 0000000..4197f33 --- /dev/null +++ b/tests/test_design_rulebook_propose.py @@ -0,0 +1,145 @@ +"""Rulebook prose -> a PROPOSED design system (milestone #254 step 3). + +The extraction tested in test_design_rulebook_import.py answers "what claims does +this rulebook make". This answers a harder question — "what design system is it +describing" — which needs the two halves joined: one rule names the colours, +another names the custom properties, and neither alone is a token. + +Rule text is representative rather than copied from this operator's rulebook +(rule #115): a test that only passes against their exact wording would be +testing the instance. +""" +from types import SimpleNamespace + +from scribe.services.design_rulebook_import import propose_tokens + + +def _rule(rule_id, title, statement, how_to_apply=None): + return SimpleNamespace( + id=rule_id, title=title, statement=statement, how_to_apply=how_to_apply + ) + + +SURFACES = _rule( + 51, "Universal surfaces", + "Obsidian #14171A (page bg, deepest surface), Iron #1E2228 (cards), " + "Slate #2C313A (hovered surfaces).", +) +TEXT = _rule( + 52, "Text palette", + "Text tokens: Parchment #E8E4D8 (primary text), Vellum #C2BFB4 (secondary). " + "Pure white #FFFFFF is NEVER used as text color.", +) +PROPERTIES = _rule( + 72, "CSS custom properties", + "Expose the system as custom properties: surfaces " + "(--fs-obsidian/iron/slate), text (--fs-parchment/vellum), and radius " + "(--fs-radius-sm/md/lg).", +) + + +def _by_name(proposals): + return {p.name: p for p in proposals} + + +# --- the join --------------------------------------------------------------- + +def test_a_token_takes_its_value_from_the_colour_of_the_same_name(): + """THE mechanism. `--fs-obsidian` and "Obsidian #14171A" are declared in + different rules and neither is a token on its own. Joining them on the word + is the only reason an import produces something usable instead of a list of + empty names.""" + proposals = _by_name(propose_tokens([SURFACES, PROPERTIES])) + assert proposals["--fs-obsidian"].value_by_mode == {"base": "#14171a"} + assert proposals["--fs-iron"].value_by_mode == {"base": "#1e2228"} + + +def test_the_parenthetical_becomes_the_tokens_purpose(): + """Rulebooks say what a colour is FOR right beside its value, and that is + the field a bare hex can never carry.""" + proposals = _by_name(propose_tokens([SURFACES, PROPERTIES])) + assert proposals["--fs-obsidian"].purpose == "page bg, deepest surface" + + +def test_a_token_with_no_matching_colour_is_proposed_with_no_value(): + """HONEST OUTPUT, not a failure. The rulebook states radius steps as prose + ("Small 4px"), which nothing here parses. The name is real and the value + needs a human — proposing the name with an empty value says exactly that, + where dropping it would hide a token the rulebook asked for.""" + proposals = _by_name(propose_tokens([SURFACES, PROPERTIES])) + assert proposals["--fs-radius-sm"].value_by_mode == {} + assert "--fs-radius-lg" in proposals + + +def test_every_proposal_carries_the_rule_and_sentence_it_came_from(): + """An import is a proposal a human reviews, and a claim you cannot trace is + a claim you have to take on faith.""" + obsidian = _by_name(propose_tokens([SURFACES, PROPERTIES]))["--fs-obsidian"] + assert obsidian.source_rule_id == 51 + assert obsidian.source_rule_title == "Universal surfaces" + assert "Obsidian #14171A" in obsidian.source_context + + +# --- prohibitions become replacements --------------------------------------- + +def test_a_prohibition_becomes_supersedes_on_that_rules_primary_token(): + """The reframe, end to end. Rule 52 declares Parchment and forbids pure + white in one breath; the import turns that into "write --fs-parchment + instead of #ffffff" — the same fact, stated forwards, and actionable.""" + proposals = _by_name(propose_tokens([TEXT, PROPERTIES])) + assert proposals["--fs-parchment"].supersedes == ["#ffffff"] + + +def test_a_prohibition_attaches_to_one_token_not_every_token_of_its_rule(): + """Rule 52 declares two colours. Attaching the prohibition to both would + claim the rulebook said something it didn't — that Vellum is also the + replacement for white.""" + proposals = _by_name(propose_tokens([TEXT, PROPERTIES])) + assert proposals["--fs-vellum"].supersedes == [] + + +def test_a_forbidden_colour_never_becomes_a_token_value(): + """Sentence-scoped negation carried through to the import: #FFFFFF appears + in rule 52 as a hex, and a naive read would make it Parchment's value.""" + proposals = propose_tokens([TEXT, PROPERTIES]) + for proposal in proposals: + assert proposal.value_by_mode.get("base") != "#ffffff" + + +# --- grouping --------------------------------------------------------------- + +def test_a_family_token_is_grouped_by_its_middle_segment(): + """`--fs-radius-sm` -> "radius". Structural, so it works on a naming scheme + this code has never seen — the prefix is each install's own (rule #115).""" + proposals = _by_name(propose_tokens([SURFACES, PROPERTIES])) + assert proposals["--fs-radius-sm"].group_name == "radius" + + +def test_a_flat_token_is_left_ungrouped_rather_than_guessed_at(): + proposals = _by_name(propose_tokens([SURFACES, PROPERTIES])) + assert proposals["--fs-obsidian"].group_name is None + + +# --- shape ------------------------------------------------------------------ + +def test_each_token_name_is_proposed_exactly_once(): + """The slash shorthand expands and rules repeat colours; neither may produce + a duplicate, since two live rows with one name is the duplicate-definition + bug the unique index exists to refuse.""" + names = [p.name for p in propose_tokens([SURFACES, TEXT, PROPERTIES])] + assert len(names) == len(set(names)) + + +def test_a_rulebook_that_names_no_tokens_proposes_nothing(): + """Most rulebooks are not design rulebooks. That has to be an empty result + rather than an error — an install can point this at anything.""" + unrelated = _rule(1, "Branching", "Work happens on the dev branch.") + assert propose_tokens([unrelated]) == [] + + +def test_colours_declared_without_a_token_name_are_not_invented_into_tokens(): + """A rulebook naming a colour it never exposes as a custom property has not + asked for a token, and inventing a name for it would put a token in the + record that no rule sanctions.""" + proposals = propose_tokens([SURFACES]) + assert proposals == [] diff --git a/tests/test_routes_design_systems.py b/tests/test_routes_design_systems.py index f52d4f3..723167f 100644 --- a/tests/test_routes_design_systems.py +++ b/tests/test_routes_design_systems.py @@ -24,8 +24,8 @@ def test_route_handlers_callable(): for name in ( "list_design_systems", "create_design_system", "get_design_system", "update_design_system", "delete_design_system", "resolve_design_system", - "list_design_tokens", "create_design_token", "update_design_token", - "delete_design_token", "set_project_design_system", + "import_design_system", "list_design_tokens", "create_design_token", + "update_design_token", "delete_design_token", "set_project_design_system", ): assert callable(getattr(routes, name)) @@ -44,6 +44,7 @@ def test_every_endpoint_is_reachable_on_the_app(): "/api/design-systems", "/api/design-systems/", "/api/design-systems//resolved", + "/api/design-systems//import", "/api/design-systems//tokens", "/api/design-tokens/", "/api/projects//design-system", @@ -57,7 +58,7 @@ def test_service_functions_take_user_id(): "create_design_system", "list_design_systems", "get_design_system", "update_design_system", "delete_design_system", "resolve_design_system", "create_token", "list_tokens", "update_token", "delete_token", - "set_project_design_system", + "set_project_design_system", "import_from_rulebook", ): fn = getattr(svc, fn_name) assert callable(fn) @@ -84,6 +85,12 @@ def test_agent_and_web_surfaces_stay_at_parity(): assert callable(getattr(tools, name)), f"MCP tool missing: {name}" assert callable(getattr(routes, name)), f"REST route missing: {name}" + # Import is the one verb whose handler names differ between the surfaces + # (the tool says what it reads FROM; the route is already under the system), + # so the loop above can't pair it. It still has to exist on both. + assert callable(tools.import_design_system_from_rulebook) + assert callable(routes.import_design_system) + def test_every_mcp_tool_in_the_module_is_registered(): """A tool written but never registered is invisible to an agent, and nothing diff --git a/tests/test_services_design_systems.py b/tests/test_services_design_systems.py index 6f8aa88..9619866 100644 --- a/tests/test_services_design_systems.py +++ b/tests/test_services_design_systems.py @@ -274,3 +274,102 @@ async def test_create_token_records_the_literals_it_replaces(): value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"], ) assert captured["supersedes"] == ["#fff", "#ffffff"] + + +# --- import from a rulebook (step 3) ---------------------------------------- + +def _proposal(name, value=None): + from scribe.services.design_rulebook_import import ProposedToken + return ProposedToken(name=name, value_by_mode={"base": value} if value else {}) + + +@pytest.mark.asyncio +async def test_import_denied_without_write_on_the_system(): + with patch("scribe.services.design_systems.access") as acc: + acc.can_write_design_system = AsyncMock(return_value=False) + from scribe.services.design_systems import import_from_rulebook + assert await import_from_rulebook(1, 3, 9) is None + + +@pytest.mark.asyncio +async def test_preview_proposes_without_creating_anything(): + """apply=False is the default because an import is a PROPOSAL. A preview + that quietly wrote would make the review step decorative.""" + created = AsyncMock() + with patch("scribe.services.design_systems.access") as acc, \ + patch("scribe.services.design_systems.create_token", created), \ + patch("scribe.services.design_systems.list_tokens", AsyncMock(return_value=[])), \ + patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[object()])), \ + patch("scribe.services.design_rulebook_import.propose_tokens", + MagicMock(return_value=[_proposal("--fs-obsidian", "#14171a")])): + acc.can_write_design_system = AsyncMock(return_value=True) + from scribe.services.design_systems import import_from_rulebook + report = await import_from_rulebook(1, 3, 9, apply=False) + + assert [p["name"] for p in report["proposed"]] == ["--fs-obsidian"] + assert report["created"] == [] + created.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_import_never_overwrites_a_token_the_system_already_defines(): + """LOAD-BEARING for re-running it. A value already in the record was put + there deliberately — very likely correcting this importer — and a second run + must fill gaps rather than undo the correction.""" + existing = MagicMock() + existing.name = "--fs-obsidian" + created_token = MagicMock() + created_token.to_dict.return_value = {"name": "--fs-iron"} + + with patch("scribe.services.design_systems.access") as acc, \ + patch("scribe.services.design_systems.create_token", + AsyncMock(return_value=created_token)) as create, \ + patch("scribe.services.design_systems.list_tokens", + AsyncMock(return_value=[existing])), \ + patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[object()])), \ + patch("scribe.services.design_rulebook_import.propose_tokens", + MagicMock(return_value=[ + _proposal("--fs-obsidian", "#000000"), + _proposal("--fs-iron", "#1e2228"), + ])): + acc.can_write_design_system = AsyncMock(return_value=True) + from scribe.services.design_systems import import_from_rulebook + report = await import_from_rulebook(1, 3, 9, apply=True) + + assert report["skipped"] == ["--fs-obsidian"] + assert [c["name"] for c in report["created"]] == ["--fs-iron"] + assert create.await_count == 1 + + +@pytest.mark.asyncio +async def test_a_valueless_proposal_is_still_created(): + """The rulebook names it, so its absence from the system is itself a + finding. A named token with a blank value says "this exists and needs + deciding"; silence says nothing at all.""" + token = MagicMock() + token.to_dict.return_value = {"name": "--fs-radius-sm"} + with patch("scribe.services.design_systems.access") as acc, \ + patch("scribe.services.design_systems.create_token", + AsyncMock(return_value=token)) as create, \ + patch("scribe.services.design_systems.list_tokens", AsyncMock(return_value=[])), \ + patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[object()])), \ + patch("scribe.services.design_rulebook_import.propose_tokens", + MagicMock(return_value=[_proposal("--fs-radius-sm")])): + acc.can_write_design_system = AsyncMock(return_value=True) + from scribe.services.design_systems import import_from_rulebook + report = await import_from_rulebook(1, 3, 9, apply=True) + + assert len(report["created"]) == 1 + assert create.await_args.kwargs["value_by_mode"] == {} + + +@pytest.mark.asyncio +async def test_an_unreadable_or_empty_rulebook_reports_nothing_rather_than_failing(): + """An install can point this at any rulebook, and most rulebooks are not + design rulebooks (rule #115).""" + with patch("scribe.services.design_systems.access") as acc, \ + patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[])): + acc.can_write_design_system = AsyncMock(return_value=True) + from scribe.services.design_systems import import_from_rulebook + report = await import_from_rulebook(1, 3, 9, apply=True) + assert report == {"rulebook_id": 9, "proposed": [], "created": [], "skipped": []}