CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 38s
Milestone #254 step 3 (#2288). Reuses #251's prose extractor as the reader and adds the part that makes it an import rather than a list of claims. **The join is the whole trick.** A rulebook states a design system in two places and neither half is a token: one rule names the colours ("Obsidian #14171A (page bg, deepest surface)"), another names the custom properties (`--fs-obsidian/iron/slate`). The import pairs them on the word — `--fs-obsidian` ends with `obsidian` — which is the only reason it produces something usable instead of seventy empty names. The parenthetical becomes the token's purpose, which is the field a bare hex could never carry. **Prohibitions arrive as replacements, per the operator's reframe.** Rule 52 declares Parchment and forbids pure white in one breath, so the import emits "write --fs-parchment instead of #ffffff" — the same fact stated forwards. It attaches to the FIRST token that rule supplied a value for, not to every token of that rule, because claiming Vellum is also the replacement for white would be putting words in the rulebook's mouth. **A token the rulebook names but states no readable value for is still proposed, with an empty value.** Radius steps and type sizes are prose ("Small 4px") and nothing here parses them; inventing a parse per shape would be guessing. The name is real and the value needs a human, so the proposal says exactly that — and the UI leads with the COUNT of those, because an import that hid them would look more complete than it is. Preview is the default on both surfaces and in the UI. An import is a proposal: rulebooks are written aspirationally and some of what they describe was never built, so every entry carries the rule id and the sentence it came from and a reviewer can check the claim rather than trust it. Existing token names are never overwritten. A value already in the record was put there deliberately — most likely correcting this importer — so a re-run fills gaps and lists the rest as skipped, which also makes it safe to repeat. Colours the rulebook names but never exposes as a custom property produce no token: it never asked for one, and inventing a name would put something in the record no rule sanctions.
405 lines
16 KiB
Python
405 lines
16 KiB
Python
"""Design-system expectations — turning rulebook prose into checkable claims.
|
|
|
|
Milestone #251 step 2. The drift panel compares what the design rulebook SAYS
|
|
against what the stylesheet and components actually DO. This module owns the
|
|
first half: reading a rulebook's rules and extracting the claims that can be
|
|
mechanically checked.
|
|
|
|
WHY THIS LIVES SERVER-SIDE. The frontend has no test runner — `vue-tsc --noEmit`
|
|
is the entire check — and this is the one genuinely fiddly piece of the feature.
|
|
Extraction happens here where pytest can assert on it; the comparison itself is
|
|
set arithmetic and stays in the browser, where the live token values are.
|
|
|
|
WHY NOT NLP. Rule statements are prose written for humans, and they should stay
|
|
that way — they are read by people far more often than they are parsed. So this
|
|
extracts only what is unambiguous in ANY prose: the hex colours and CSS custom
|
|
property names a rule mentions. Everything subtler (padding scales, type ramps)
|
|
needs a rule author to opt into a structured form, which is deliberately left for
|
|
when someone wants it rather than invented up front.
|
|
|
|
RULE #115. Nothing here assumes a design rulebook exists, or that it is this
|
|
operator's. An install designates one; an install that hasn't gets an empty
|
|
result and a panel that explains itself.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
from scribe.models.rulebook import Rule
|
|
from scribe.services.settings import get_setting
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Which rulebook describes this install's design system. A plain setting rather
|
|
# than a column: no migration, discoverable in the Settings UI (rule #25), and
|
|
# honest about being a per-install choice rather than a property of the rulebook.
|
|
DESIGN_RULEBOOK_SETTING = "design_rulebook_id"
|
|
|
|
# `#abc` and `#aabbcc`, plus the 4/8-digit alpha forms.
|
|
_HEX = re.compile(r"#([0-9a-fA-F]{3,8})\b")
|
|
|
|
# A custom-property name as written in prose, including the slash shorthand the
|
|
# rulebook uses: `--fs-radius-sm/md/lg/xl`, `--fs-obsidian/iron/slate/pewter`.
|
|
_TOKEN = re.compile(r"(--[a-zA-Z][\w-]*(?:/[\w-]+)*)")
|
|
|
|
# Sentence-ish split. Rules use semicolons as hard breaks as often as periods.
|
|
_SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+|\n+")
|
|
|
|
# Negation markers. Checked PER SENTENCE, which is the whole trick — see
|
|
# _extract_from_sentence.
|
|
_NEGATIONS = ("never", "not ", "no ", "avoid", "don't", "must not", "excluded")
|
|
|
|
|
|
@dataclass
|
|
class Expectation:
|
|
"""One mechanically-checkable claim a rule makes."""
|
|
|
|
kind: str # "token" | "color" | "prohibited_color"
|
|
value: str # "--fs-obsidian" | "#14171a"
|
|
rule_id: int
|
|
rule_title: str
|
|
context: str # the sentence it came from, for showing your work
|
|
|
|
def as_dict(self) -> dict:
|
|
return {
|
|
"kind": self.kind,
|
|
"value": self.value,
|
|
"rule_id": self.rule_id,
|
|
"rule_title": self.rule_title,
|
|
"context": self.context,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class ExpectationSet:
|
|
rulebook_id: int | None = None
|
|
expectations: list[Expectation] = field(default_factory=list)
|
|
|
|
def as_dict(self) -> dict:
|
|
return {
|
|
"rulebook_id": self.rulebook_id,
|
|
"expectations": [e.as_dict() for e in self.expectations],
|
|
}
|
|
|
|
|
|
def normalize_hex(value: str) -> str | None:
|
|
"""Fold a hex colour to a comparable form, or None if it isn't one.
|
|
|
|
Load-bearing for the whole comparison: the rulebook writes `#FFFFFF` and the
|
|
code writes `#fff`, and those must compare equal or the single largest drift
|
|
finding (#2275) reads as zero. Expands 3-digit shorthand and lowercases.
|
|
|
|
Alpha forms (4 and 8 digit) keep their alpha — `#fff` and `#ffff` are not the
|
|
same colour, and silently dropping the alpha would invent equality.
|
|
"""
|
|
match = _HEX.fullmatch(value.strip()) or _HEX.match(value.strip())
|
|
if not match:
|
|
return None
|
|
digits = match.group(1).lower()
|
|
if len(digits) in (3, 4):
|
|
digits = "".join(c * 2 for c in digits)
|
|
if len(digits) not in (6, 8):
|
|
return None
|
|
return f"#{digits}"
|
|
|
|
|
|
def expand_token_shorthand(raw: str) -> list[str]:
|
|
"""`--fs-radius-sm/md/lg/xl` -> the four names it stands for.
|
|
|
|
The rulebook writes token families in a slash shorthand, and both forms it
|
|
uses expand correctly under one rule: take everything up to and including the
|
|
LAST hyphen of the first segment as the prefix, then append each alternative.
|
|
|
|
--fs-radius-sm/md/lg/xl prefix `--fs-radius-` -> sm, md, lg, xl
|
|
--fs-obsidian/iron/slate prefix `--fs-` -> obsidian, iron, slate
|
|
--fs-dur-fast/base/slow prefix `--fs-dur-` -> fast, base, slow
|
|
|
|
A name with no slash is returned as-is.
|
|
"""
|
|
if "/" not in raw:
|
|
return [raw]
|
|
head, *rest = raw.split("/")
|
|
cut = head.rfind("-")
|
|
if cut <= 1: # no hyphen beyond the leading `--`
|
|
return [head, *rest]
|
|
prefix = head[: cut + 1]
|
|
return [head, *[f"{prefix}{part}" for part in rest if part]]
|
|
|
|
|
|
def _is_negated(sentence: str) -> bool:
|
|
return any(marker in sentence.lower() for marker in _NEGATIONS)
|
|
|
|
|
|
def _extract_from_sentence(sentence: str, rule: Rule) -> list[Expectation]:
|
|
"""Claims in ONE sentence, with negation scoped to that sentence.
|
|
|
|
Sentence scope is what makes the prohibition detection usable. Rule 52 reads:
|
|
|
|
"Text tokens: Parchment #E8E4D8 …, Vellum #C2BFB4 …, Ash #9C9A92 ….
|
|
Pure white #FFFFFF is NEVER used as text color."
|
|
|
|
Three colours the palette REQUIRES and one it FORBIDS, in one statement.
|
|
Detecting negation across the whole statement would mark all four as
|
|
forbidden; detecting it per sentence gets all four right.
|
|
"""
|
|
out: list[Expectation] = []
|
|
negated = _is_negated(sentence)
|
|
|
|
for match in _HEX.finditer(sentence):
|
|
value = normalize_hex(match.group(0))
|
|
if not value:
|
|
continue
|
|
out.append(Expectation(
|
|
kind="prohibited_color" if negated else "color",
|
|
value=value,
|
|
rule_id=int(rule.id),
|
|
rule_title=rule.title,
|
|
context=sentence.strip(),
|
|
))
|
|
|
|
# Token names are not negated in practice — a rule says which tokens should
|
|
# exist, never which must not — so they are recorded as expectations
|
|
# regardless. If that ever changes, it needs its own kind rather than
|
|
# borrowing the colour one.
|
|
for match in _TOKEN.finditer(sentence):
|
|
for name in expand_token_shorthand(match.group(1)):
|
|
out.append(Expectation(
|
|
kind="token",
|
|
value=name,
|
|
rule_id=int(rule.id),
|
|
rule_title=rule.title,
|
|
context=sentence.strip(),
|
|
))
|
|
|
|
return out
|
|
|
|
|
|
def extract_expectations(rules: list[Rule]) -> list[Expectation]:
|
|
"""Every checkable claim across a set of rules, deduped on (kind, value).
|
|
|
|
First occurrence wins so the reported rule is the one that introduced the
|
|
claim, which is usually the most specific place to send a reader.
|
|
"""
|
|
seen: set[tuple[str, str]] = set()
|
|
out: list[Expectation] = []
|
|
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():
|
|
continue
|
|
for expectation in _extract_from_sentence(sentence, rule):
|
|
key = (expectation.kind, expectation.value)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(expectation)
|
|
return out
|
|
|
|
|
|
async def get_design_rulebook_id(user_id: int) -> int | None:
|
|
"""The rulebook this install designated as its design system, if any."""
|
|
raw = (await get_setting(user_id, DESIGN_RULEBOOK_SETTING, "")).strip()
|
|
if not raw:
|
|
return None
|
|
try:
|
|
value = int(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return value if value > 0 else None
|
|
|
|
|
|
async def design_expectations(user_id: int) -> ExpectationSet:
|
|
"""Checkable claims from the designated design rulebook.
|
|
|
|
Returns an empty set when no rulebook is designated — the normal case for
|
|
any install but the one that set it up (rule #115). The caller shows an
|
|
explanatory empty state rather than treating this as an error.
|
|
"""
|
|
rulebook_id = await get_design_rulebook_id(user_id)
|
|
if rulebook_id is None:
|
|
return ExpectationSet()
|
|
|
|
from scribe.services import rulebooks as rulebooks_svc
|
|
|
|
try:
|
|
rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id)
|
|
except Exception:
|
|
logger.warning("Design rulebook %s could not be read", rulebook_id, exc_info=True)
|
|
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
|