feat(systems): the catalog reaches the moment a name is minted, and gets a face (#3028, milestone 307 step 2)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Failing after 57s
CI & Build / Build & push image (push) Skipped

Step 1 found the reason the standard names never held, and it is sharper than
"prose doesn't fire": the list WAS real and it WAS seeded — but only on the
inception path, for a project with zero Systems. Ad-hoc create_system never
consulted it, which is how Forge minted "CI and Release" and Portal minted
"CI & release" after the constant already existed. This wires the vocabulary to
the moment that mints a name.

- services/systems.assess_system_name: the local duplicate gate AND the
  catalog lookup, in ONE service function both doors call. The gate lived only
  in the MCP tool, which is exactly how the web UI shipped without a check the
  agent surface enforced (#2482). REST now answers 409 with the System that
  already covers the area.
- An `exact` catalog hit is APPLIED (mechanical — the names differ only in
  spelling). An `overlap` is only OFFERED, on both doors: applying a judgment
  call silently is how a cross-project rule surfaces in the wrong project.
- canonical_systems.best_overlap is the ONE scorer behind the create-time
  offer and the review sweep, so the two surfaces can never name different
  areas for one System. It also takes the catalog the caller already holds,
  so the review is not an N+1.

UI (folded in from step 1 — rule 27, that step shipped with no human surface):
- SystemsSection: a Shared area picker on create and edit, the area on each
  card, and a collapsed review of proposals that appears only when there is
  something to decide. `exact` and `overlap` never share a style — one is
  mechanical, the other is the reviewer's judgment, and presenting them alike
  is how a wrong mapping gets waved through.
- Settings → Admin → Areas: the catalog itself, showing each entry's slug,
  because the slug is what decides whether two names are the same area and a
  rename moves it.
- A picker rather than a live matcher: reproducing the slug rule in TypeScript
  would give this feature two matchers to keep in step — the exact drift the
  catalog exists to end. The server stays authoritative.

tests/helpers.fake_system gains canonical_id=None: an unnamed attribute is an
auto-MagicMock and therefore truthy, which is the trap that helper exists for
(note 2109) and a nullable FK walks straight into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 12:58:30 -04:00
co-authored by Claude Opus 5
parent 879ef3053e
commit c58529718b
12 changed files with 855 additions and 49 deletions
+3 -1
View File
@@ -132,7 +132,9 @@ def fake_milestone(**attrs) -> MagicMock:
def fake_system(**attrs) -> MagicMock:
return _with_defaults({"id": 1, "name": "Reader", "project_id": 5}, attrs)
return _with_defaults(
{"id": 1, "name": "Reader", "project_id": 5, "canonical_id": None}, attrs,
)
def fake_rulebook(**attrs) -> MagicMock:
+60
View File
@@ -5,26 +5,86 @@ import pytest
from tests.helpers import fake_note, fake_system
# The name assessment both doors run before minting (milestone 307). A test
# that patches systems_svc wholesale must stub it, or the awaited MagicMock
# raises — this shape is the "nothing matched" answer.
_NO_MATCH = {"duplicate": None, "canonical": None}
@pytest.mark.asyncio
async def test_create_system_returns_dict():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
svc.create_system = AsyncMock(return_value=fake_system(name="Reader"))
from scribe.mcp.tools.systems import create_system
result = await create_system(project_id=5, name="Reader", description="pdf reader")
assert result["name"] == "Reader"
# An unmatched name is a project-specific area: created, no offer, no fuss.
assert "canonical_suggestion" not in result and "canonical_note" not in result
@pytest.mark.asyncio
async def test_create_system_no_access_raises():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
svc.create_system = AsyncMock(return_value=None)
from scribe.mcp.tools.systems import create_system
with pytest.raises(ValueError):
await create_system(project_id=5, name="Reader")
@pytest.mark.asyncio
async def test_create_system_applies_an_exact_area_and_offers_a_similar_one():
"""The two bases must behave differently, and this is where it is decided.
`exact` differs from the catalog name only in spelling, so it is APPLIED —
that is the mechanical case the catalog exists to collapse. `overlap` is a
judgment call, so it is only OFFERED: applying it silently is how a
cross-project rule ends up surfacing in the wrong project.
"""
from scribe.mcp.tools.systems import create_system
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value={
"duplicate": None,
"canonical": {"id": 3, "name": "CI & Release", "basis": "exact"},
})
svc.create_system = AsyncMock(return_value=fake_system(name="CI and Release"))
exact = await create_system(project_id=5, name="CI and Release")
assert svc.create_system.await_args.kwargs["canonical_id"] == 3
assert "canonical_note" in exact and "canonical_suggestion" not in exact
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value={
"duplicate": None,
"canonical": {"id": 3, "name": "CI & Release", "basis": "overlap", "score": 0.33},
})
svc.create_system = AsyncMock(return_value=fake_system(id=9, name="CI & runners"))
similar = await create_system(project_id=5, name="CI & runners")
assert svc.create_system.await_args.kwargs["canonical_id"] is None
assert similar["canonical_suggestion"]["id"] == 3
assert "map_system_to_canonical(9, 3)" in similar["canonical_suggestion"]["message"]
@pytest.mark.asyncio
async def test_create_system_duplicate_names_the_existing_one_and_creates_nothing():
from scribe.mcp.tools.systems import create_system
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value={
"duplicate": {"id": 4, "name": "Reader"}, "canonical": None,
})
svc.create_system = AsyncMock()
result = await create_system(project_id=5, name="reader")
assert result["duplicate"] is True and result["existing_id"] == 4
assert "Reader" in result["message"]
svc.create_system.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_system_splits_records_by_kind():
issue = MagicMock(); issue.to_dict.return_value = {"id": 10}; issue.task_kind = "issue"; issue.status = "todo"