diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index 43bc94c..7b6665d 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -2298,7 +2298,17 @@ async def build_write_path_hint( # of this guard: that one runs a SEMANTIC search, and moving it here would # run an embedding query on every write in the session. Its gating is a # separate question from this one (see the note on #3244). + # The design arm (#4256) is decided HERE, above the guard, so a UI write + # that matched no prior art still carries it — and it returns on its own + # rather than joining the guard's condition, because joining it would let + # a design line switch the standing-rule arm below on for writes where it + # has never run, moving that arm's call distribution under its floor. + design_text, design_dedup = await _design_arm( + user_id, project_id, path, set(exclude_derive or []), + ) if not staleness and not synced and not menu and not stamped and not divergence and not derive: + if design_text: + return {**empty, "context": design_text, "derive_keys": [design_dedup]} return empty owners = await owner_names_for({ @@ -2320,6 +2330,9 @@ async def build_write_path_hint( # Seeded with the staleness line, which is decided above the early # return and so cannot wait for this list to exist. lines: list[str] = list(staleness) + # First after staleness: it BINDS, where everything below is prior art. + if design_text: + lines.append(design_text) sync_note_ids: list[int] = [] if synced: # The sync framing (#2708). Deliberately imperative about the record — @@ -2552,7 +2565,9 @@ async def build_write_path_hint( "stamped": stamped, "divergence": divergence, "derive": derive, - "derive_keys": [d["key"] for d in derive], + "derive_keys": [d["key"] for d in derive] + ( + [design_dedup] if design_dedup else [] + ), "rule_ids": rule_ids, "checkpoint": checkpoint, } @@ -2703,6 +2718,106 @@ async def build_tool_rule_hint( return out +# --- the design-guidance arm (#4256) ---------------------------------------- +# A design system BINDS like a rule, and until this it had one channel: the +# session-start block, which names it and the call that reads its prose. That +# is complete for a session that knows to ask and silent for one that is +# writing a component — the same gap every unasked arm exists to close. +# +# A TRIGGER, NOT A SEARCH. A project has exactly one design system +# (projects.design_system_id is a single FK), so there is nothing to rank and +# no vector to compute: the question "does this guidance apply here" is +# answered by the file being UI. Deterministic and cheap, and it takes no +# slot from the ranked menu — the band, floor and budget the other arms were +# tuned against are untouched by construction, which is why this adds no +# retrieval_logs row: there is no score distribution for it to join. +# +# AN INDEX, NOT THE PROSE. Resolved guidance runs to thousands of characters +# (a house style is long by nature), which would take most of the hook's +# additionalContext cap on its own. So the line names the SECTIONS of each +# inherited layer — the headings are self-describing ("Where the accent must +# NOT appear", "Voice and tone") the way rule titles are — and inlines only a +# layer short enough to be a line: in practice the leaf, since a child system +# holds just its departure from the house style. Choosing a paragraph by +# meaning would need the guidance embedded per section; that is justified +# only if this index turns out not to be read. +# +# ONCE PER SESSION PER SYSTEM, on the hook's token-keyed channel +# (`exclude_derive`, keyed `design:`). That channel already dedups opaque +# keys on its own file, so the arm needs no new plugin state. +_DESIGN_UI_EXTENSIONS = frozenset({ + ".vue", ".svelte", ".css", ".scss", ".sass", ".less", + ".tsx", ".jsx", ".html", +}) +# A guidance layer this short is shown whole; anything longer is indexed. +_DESIGN_INLINE_CHARS = 500 +_DESIGN_HEADING = re.compile(r"^##\s+(.+?)\s*$", re.M) + + +def design_key(design_system_id: int) -> str: + """The dedup token for the design arm on the hook's keyed channel.""" + return f"design:{int(design_system_id)}" + + +def is_ui_path(path: str) -> bool: + """Whether writing `path` is writing UI — the design arm's trigger.""" + name = (path or "").rsplit("/", 1)[-1].lower() + return any(name.endswith(ext) for ext in _DESIGN_UI_EXTENSIONS) + + +def _design_line(path: str, design: dict) -> str: + """Name the design system that binds this file, and what its prose covers.""" + ds_id = design["id"] + layers: list[str] = [] + for layer in design.get("guidance") or []: + text = (layer.get("guidance") or "").strip() + if not text: + continue + flat = " ".join(text.split()) + headings = _DESIGN_HEADING.findall(text) + if len(flat) <= _DESIGN_INLINE_CHARS: + layers.append(f"{layer['title']}: \"{flat}\"") + elif headings: + layers.append(f"{layer['title']} covers " + " · ".join(headings)) + else: + short, _cut = elide(flat, _DESIGN_INLINE_CHARS) + layers.append(f"{layer['title']}: \"{short}\"") + inherits = ( + " (inherits " + " › ".join(design["inherits_from"]) + ")" + if design.get("inherits_from") else "" + ) + out = ( + f"> Design system binds `{path}`: {design['title']} (id {ds_id}){inherits}. " + f"Read `get_design_system({ds_id})` → `resolved_guidance` before writing " + f"UI here, and take values from `resolve_design_system({ds_id})` rather " + f"than hand-writing them." + ) + if layers: + out += " " + "; ".join(layers) + "." + return out + " (Shown once per session.)" + + +async def _design_arm( + user_id: int, project_id: int, path: str, skip: set[str], +) -> tuple[str, str]: + """(line, dedup key) for a UI write in a project with a design system, + or ("", "") — never raises: a design hint must never break a write.""" + if not project_id or not is_ui_path(path): + return "", "" + try: + project = await projects_svc.get_project(user_id, project_id) + ds_id = getattr(project, "design_system_id", None) if project else None + if not ds_id or design_key(ds_id) in skip: + return "", "" + design = await design_systems_svc.design_context(user_id, ds_id) + if not design: + return "", "" + return _design_line(path, design), design_key(ds_id) + except Exception: + logger.debug("write-path design arm failed", exc_info=True) + return "", "" + + def _derive_line(path: str, derive: list[dict]) -> str: """The ledger's word on the names being written (#2900): a duplicate family to derive, or a canon to reuse — said at the write.""" diff --git a/tests/test_write_path_design_arm.py b/tests/test_write_path_design_arm.py new file mode 100644 index 0000000..85a8faf --- /dev/null +++ b/tests/test_write_path_design_arm.py @@ -0,0 +1,211 @@ +"""The write-path design arm: a UI write is told which design system binds it (#4256). + +A design system binds like a rule, and before this it reached a session only +through the session-start block — complete for a session that knows to ask, +silent for one writing a component. These tests pin: + +- THE TRIGGER is the file, not a search: a UI path in a project that has a + design system. No vectors, no score, no slot taken from the ranked menu. +- WHAT IT SAYS is an index — each inherited layer's section headings, with a + layer short enough to be a line (the leaf's departure) shown whole. +- IT DOES NOT MOVE THE OTHER ARMS. A design-only write returns without + running the standing-rule arm, which is gated on there being prior art; + letting the design line into that gate would change the rule arm's call + distribution under the floor it was tuned against. +- ONCE PER SESSION PER SYSTEM, on the hook's token-keyed channel. +""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from scribe.services import plugin_context as pc +from tests.helpers import writepath_cfg + +HOUSE = ( + "## Aesthetic\n\nModern-mythic with restraint. " + "Long prose. " * 60 + + "\n\n## Where the accent must NOT appear\n\nNot on buttons.\n\n" + "## Voice and tone\n\nPlain language for anything functional." +) +LEAF = "The accent appears on the wordmark and active navigation." + + +def _design(ds_id=9): + return { + "id": ds_id, "title": "App", "description": "", + "inherits_from": ["House"], + "guidance": [ + {"design_system_id": 1, "title": "House", "guidance": HOUSE}, + {"design_system_id": ds_id, "title": "App", "guidance": LEAF}, + ], + "token_count": 3, "token_groups": ["accent"], + } + + +def _project(ds_id=9): + return MagicMock(id=2, title="App", design_system_id=ds_id) + + +# ── the trigger ─────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("path,ui", [ + ("frontend/src/views/NoteView.vue", True), + ("frontend/src/styles/components.css", True), + ("web/App.TSX", True), + ("templates/index.html", True), + ("src/scribe/services/dedup.py", False), + ("frontend/src/utils/deadWeight.ts", False), + ("README.md", False), + ("", False), +]) +def test_ui_paths(path, ui): + """`.ts` is deliberately not UI: a utility module is logic, and firing on + it would put the design line in front of writes it says nothing about.""" + assert pc.is_ui_path(path) is ui + + +@pytest.mark.asyncio +async def test_no_project_asks_nothing(): + get = AsyncMock() + with patch.object(pc.projects_svc, "get_project", get): + assert await pc._design_arm(1, 0, "a/B.vue", set()) == ("", "") + get.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_non_ui_write_asks_nothing(): + get = AsyncMock() + with patch.object(pc.projects_svc, "get_project", get): + assert await pc._design_arm(1, 2, "src/x.py", set()) == ("", "") + get.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_project_without_a_design_system_says_nothing(): + ctx = AsyncMock() + with patch.object(pc.projects_svc, "get_project", AsyncMock(return_value=_project(None))), \ + patch.object(pc.design_systems_svc, "design_context", ctx): + assert await pc._design_arm(1, 2, "a/B.vue", set()) == ("", "") + ctx.assert_not_called() + + +@pytest.mark.asyncio +async def test_already_shown_this_session_is_not_fetched_again(): + ctx = AsyncMock() + with patch.object(pc.projects_svc, "get_project", AsyncMock(return_value=_project())), \ + patch.object(pc.design_systems_svc, "design_context", ctx): + out = await pc._design_arm(1, 2, "a/B.vue", {pc.design_key(9)}) + assert out == ("", "") + ctx.assert_not_called() + + +@pytest.mark.asyncio +async def test_an_unreadable_design_system_says_nothing(): + with patch.object(pc.projects_svc, "get_project", AsyncMock(return_value=_project())), \ + patch.object(pc.design_systems_svc, "design_context", AsyncMock(return_value=None)): + assert await pc._design_arm(1, 2, "a/B.vue", set()) == ("", "") + + +@pytest.mark.asyncio +async def test_a_failure_never_breaks_the_write(): + with patch.object(pc.projects_svc, "get_project", AsyncMock(side_effect=RuntimeError)): + assert await pc._design_arm(1, 2, "a/B.vue", set()) == ("", "") + + +# ── what it says ────────────────────────────────────────────────────────── + + +def test_the_line_indexes_the_house_style_and_inlines_the_departure(): + line = pc._design_line("a/B.vue", _design()) + assert "App (id 9) (inherits House)" in line + assert "`get_design_system(9)` → `resolved_guidance`" in line + assert "`resolve_design_system(9)`" in line + # The long layer is named by its headings, not pasted. + assert "House covers Aesthetic · Where the accent must NOT appear · Voice and tone" in line + assert "Long prose." not in line + # The short layer is the app's own departure, and is shown whole. + assert f'App: "{LEAF}"' in line + + +def test_the_line_stays_a_line(): + """The index exists because the prose does not fit: resolved guidance + runs to thousands of characters. A line that grew back to that size + would be the prose again under another name.""" + assert len(pc._design_line("a/B.vue", _design())) < 800 + + +def test_a_long_layer_with_no_headings_is_elided_not_pasted(): + design = _design() + design["guidance"][0]["guidance"] = "Unheaded prose. " * 200 + line = pc._design_line("a/B.vue", design) + assert len(line) < 1200 + assert "House:" in line + + +# ── in the hint ─────────────────────────────────────────────────────────── + + +def _quiet(): + """Every other arm silent: nothing recorded, nothing similar.""" + return [ + patch.object(pc, "get_writepath_config", AsyncMock(return_value=writepath_cfg())), + patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), + patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), + patch.object(pc, "record_retrieval", MagicMock()), + patch.object(pc.projects_svc, "get_project", AsyncMock(return_value=_project())), + patch.object(pc.design_systems_svc, "design_context", AsyncMock(return_value=_design())), + patch.object(pc, "owner_names_for", AsyncMock(return_value={})), + ] + + +async def _hint(patches, **kw): + import contextlib + with contextlib.ExitStack() as stack: + for p in patches: + stack.enter_context(p) + rules = stack.enter_context( + patch.object(pc, "semantic_search_rules", AsyncMock(return_value=[])) + ) + out = await pc.build_write_path_hint(1, "frontend/src/B.vue", project_id=2, **kw) + return out, rules + + +@pytest.mark.asyncio +async def test_a_ui_write_with_no_prior_art_still_hears_the_design_system(): + out, _ = await _hint(_quiet()) + assert out["context"].startswith("> Design system binds `frontend/src/B.vue`") + assert out["derive_keys"] == [pc.design_key(9)] + assert out["note_ids"] == [] and out["rule_ids"] == [] + + +@pytest.mark.asyncio +async def test_a_design_only_write_does_not_switch_the_rule_arm_on(): + """The rule arm runs only where there is prior art. A design line that + joined that gate would start a semantic rule search on every UI write — + a new population of calls under a floor tuned without them.""" + _, rules = await _hint(_quiet()) + rules.assert_not_called() + + +@pytest.mark.asyncio +async def test_shown_once_per_session(): + out, _ = await _hint(_quiet(), exclude_derive=[pc.design_key(9)]) + assert out["context"] == "" + assert out["derive_keys"] == [] + + +@pytest.mark.asyncio +async def test_beside_prior_art_it_leads_and_rides_the_keyed_channel(): + patches = _quiet() + patches[1] = patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=( + [{"id": 5, "title": "fs-button", "user_id": 1, "note_type": "snippet"}], 1, + ))) + out, _ = await _hint(patches) + lines = out["context"].splitlines() + assert lines[0].startswith("> Design system binds") + assert any("fs-button" in ln for ln in lines[1:]) + assert pc.design_key(9) in out["derive_keys"] + # It takes no menu slot: the snippet is still reported as surfaced. + assert 5 in out["note_ids"]