Drop the rulebook import — a migration, not a product feature (#90)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 19s

This commit was merged in pull request #90.
This commit is contained in:
2026-07-31 09:49:48 -04:00
9 changed files with 5 additions and 749 deletions
-36
View File
@@ -136,42 +136,6 @@ export const setProjectDesignSystem = (
{ design_system_id: designSystemId },
);
/** One token an import proposes, with the evidence for it. */
export interface ProposedToken {
name: string;
value_by_mode: Record<string, string>;
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<ImportReport>(`/api/design-systems/${designSystemId}/import`, {
rulebook_id: rulebookId,
apply,
});
export interface StylesheetResult {
design_system_id: number;
/** The master sheet: purpose tokens only, no element or class rules. */
-157
View File
@@ -31,17 +31,14 @@ import {
fetchResolvedTokens,
checkSnippets,
fetchStylesheet,
importFromRulebook,
updateDesignSystem,
updateDesignToken,
type DesignSystem,
type DesignToken,
type ImportReport,
type ResolvedToken,
type SnippetCheck,
type StylesheetResult,
} from "@/api/designSystems";
import { listRulebooks, type Rulebook } from "@/api/rulebooks";
import DesignTabs from "@/components/DesignTabs.vue";
import { ApiError } from "@/api/client";
import { useToastStore } from "@/stores/toast";
@@ -472,56 +469,6 @@ watch(selectedId, () => {
snippetCheck.value = null;
});
// --- import from a rulebook -------------------------------------------------
const rulebooks = ref<Rulebook[]>([]);
const importRulebookId = ref<number | null>(null);
const importReport = ref<ImportReport | null>(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());
}
@@ -836,106 +783,6 @@ function isColourish(value: string): boolean {
</template>
</section>
<!-- Import from a rulebook -->
<section class="ds-section">
<div class="section-head">
<h2>Import from a rulebook</h2>
<button class="btn-ghost btn-small" @click="showImport = !showImport">
{{ showImport ? "Hide" : "Show" }}
</button>
</div>
<template v-if="showImport">
<p class="section-note">
Reads a rulebook's colour and token declarations and proposes the
tokens they add up to — joining "Obsidian #14171A" in one rule to
<code>--fs-obsidian</code> in another, since neither alone is a
token. Preview first: a rulebook is written aspirationally, so
this is a proposal rather than a fact.
</p>
<p v-if="!rulebooks.length" class="muted">
No rulebooks to import from.
</p>
<template v-else>
<div class="field">
<label class="field-label" for="import-rulebook">Rulebook</label>
<select id="import-rulebook" v-model="importRulebookId" class="input">
<option :value="null">Choose one…</option>
<option v-for="rb in rulebooks" :key="rb.id" :value="rb.id">{{ rb.title }}</option>
</select>
</div>
<div class="row-actions">
<button
class="btn-ghost" :disabled="importRulebookId === null || importing"
@click="runImport(false)"
>
{{ importing ? "Reading" : "Preview" }}
</button>
<button
v-if="importReport && importReport.proposed.length"
class="btn-primary" :disabled="importing" @click="runImport(true)"
>
Import {{ importReport.proposed.length - importReport.skipped.length }} tokens
</button>
</div>
<template v-if="importReport">
<p class="section-note import-summary">
<strong>{{ importReport.proposed.length }}</strong> proposed ·
<strong>{{ proposalsWithoutValues }}</strong> with no value the
rulebook states in a readable form ·
<strong>{{ importReport.skipped.length }}</strong> already defined here
<template v-if="importReport.created.length">
· <strong>{{ importReport.created.length }}</strong> created
</template>
</p>
<p v-if="!importReport.proposed.length" class="muted">
That rulebook names no custom properties, so there is nothing
to propose. Most rulebooks aren't design rulebooks.
</p>
<ul v-else class="token-list">
<li v-for="p in importReport.proposed" :key="p.name">
<code class="token-name">{{ p.name }}</code>
<span class="token-values">
<span
v-for="(value, mode) in p.value_by_mode" :key="mode" class="mode-chip"
>
<span
v-if="isColourish(value)" class="swatch"
:style="{ background: value }" aria-hidden="true"
/>
<code>{{ value }}</code>
</span>
<span v-if="!Object.keys(p.value_by_mode).length" class="supersedes">
needs a value
</span>
</span>
<span class="token-meta">
{{ p.purpose || "" }}
<span v-if="p.supersedes.length" class="supersedes">
instead of {{ p.supersedes.join(", ") }}
</span>
<span v-if="p.source_rule_id" class="supersedes">
rule #{{ p.source_rule_id }}
</span>
</span>
<span
v-if="importReport.skipped.includes(p.name)" class="origin-badge"
>
already defined
</span>
</li>
</ul>
</template>
</template>
</template>
</section>
<!-- Overrides -->
<section class="ds-section">
<div class="section-head">
@@ -1565,10 +1412,6 @@ textarea.input {
padding: 0.15rem 0;
}
.import-summary {
margin-top: 0.75rem;
}
.supersedes {
font-size: 0.75rem;
color: var(--color-text-muted);
-37
View File
@@ -210,42 +210,6 @@ async def check_snippets_against_design_system(
return result
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(
@@ -388,7 +352,6 @@ def register(mcp) -> None:
delete_design_system,
get_design_system_stylesheet,
check_snippets_against_design_system,
import_design_system_from_rulebook,
create_design_token,
list_design_tokens,
update_design_token,
-24
View File
@@ -156,30 +156,6 @@ async def check_snippets_against_system(design_system_id: int):
return jsonify(result)
@design_systems_bp.post("/design-systems/<int:design_system_id>/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/<int:design_system_id>/tokens")
@@ -230,175 +230,3 @@ 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
-71
View File
@@ -342,77 +342,6 @@ async def set_project_design_system(
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,
}
# --- the master sheet -------------------------------------------------------
async def stylesheet_for_system(
-145
View File
@@ -1,145 +0,0 @@
"""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 == []
+5 -8
View File
@@ -24,7 +24,7 @@ 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",
"import_design_system", "get_design_system_stylesheet",
"get_design_system_stylesheet",
"check_snippets_against_system",
"list_design_tokens", "create_design_token",
"update_design_token", "delete_design_token", "set_project_design_system",
@@ -46,7 +46,6 @@ def test_every_endpoint_is_reachable_on_the_app():
"/api/design-systems",
"/api/design-systems/<int:design_system_id>",
"/api/design-systems/<int:design_system_id>/resolved",
"/api/design-systems/<int:design_system_id>/import",
"/api/design-systems/<int:design_system_id>/stylesheet",
"/api/design-systems/<int:design_system_id>/snippet-check",
"/api/design-systems/<int:design_system_id>/tokens",
@@ -62,7 +61,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", "import_from_rulebook",
"set_project_design_system",
"stylesheet_for_system", "check_snippets_against_system",
):
fn = getattr(svc, fn_name)
@@ -91,11 +90,9 @@ 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)
# The snippet check is the one verb whose handler names differ between the
# surfaces (the tool says what it checks AGAINST; 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.check_snippets_against_design_system)
assert callable(routes.check_snippets_against_system)
-99
View File
@@ -276,105 +276,6 @@ async def test_create_token_records_the_literals_it_replaces():
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": []}
# --- the master sheet -------------------------------------------------------
@pytest.mark.asyncio