feat(write-path): a UI write is told which design system binds it (#4256)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m43s
CI & Build / Build & push image (push) Successful in 27s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m43s
CI & Build / Build & push image (push) Successful in 27s
A design system binds like a rule but reached a session only through the session-start block: complete for a session that asks, silent for one writing a component. The write-path hint now carries a design arm. A trigger, not a search: a project has one design system, so the question is answered by the file being UI (.vue, .css, .tsx, ...) in a project that has one. No vectors, no score, no slot from the ranked menu. An index, not the prose: resolved guidance runs to ~8,000 chars (the house style alone), near the hook's whole additionalContext cap. The line names each inherited layer's section headings and inlines a layer short enough to be a line - in practice the leaf's departure. The other arms do not move. A design-only write returns on its own rather than joining the prior-art guard, so the standing-rule arm still runs only where it ran before. Once per session per system, on the hook's existing token-keyed channel (exclude_derive, design:<id>) - no plugin change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -2298,7 +2298,17 @@ async def build_write_path_hint(
|
|||||||
# of this guard: that one runs a SEMANTIC search, and moving it here would
|
# 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
|
# run an embedding query on every write in the session. Its gating is a
|
||||||
# separate question from this one (see the note on #3244).
|
# 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 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
|
return empty
|
||||||
|
|
||||||
owners = await owner_names_for({
|
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
|
# Seeded with the staleness line, which is decided above the early
|
||||||
# return and so cannot wait for this list to exist.
|
# return and so cannot wait for this list to exist.
|
||||||
lines: list[str] = list(staleness)
|
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] = []
|
sync_note_ids: list[int] = []
|
||||||
if synced:
|
if synced:
|
||||||
# The sync framing (#2708). Deliberately imperative about the record —
|
# The sync framing (#2708). Deliberately imperative about the record —
|
||||||
@@ -2552,7 +2565,9 @@ async def build_write_path_hint(
|
|||||||
"stamped": stamped,
|
"stamped": stamped,
|
||||||
"divergence": divergence,
|
"divergence": divergence,
|
||||||
"derive": derive,
|
"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,
|
"rule_ids": rule_ids,
|
||||||
"checkpoint": checkpoint,
|
"checkpoint": checkpoint,
|
||||||
}
|
}
|
||||||
@@ -2703,6 +2718,106 @@ async def build_tool_rule_hint(
|
|||||||
return out
|
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:<id>`). 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:
|
def _derive_line(path: str, derive: list[dict]) -> str:
|
||||||
"""The ledger's word on the names being written (#2900): a duplicate
|
"""The ledger's word on the names being written (#2900): a duplicate
|
||||||
family to derive, or a canon to reuse — said at the write."""
|
family to derive, or a canon to reuse — said at the write."""
|
||||||
|
|||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user