feat(systems): the area vocabulary becomes a global table so a rule can point at one (#3027, milestone 307 step 1)
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 56s
CI & Build / Build & push image (push) Skipped
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 56s
CI & Build / Build & push image (push) Skipped
The eight standard area names already existed — as STANDARD_SYSTEMS, a tuple in
services/systems.py that milestone 297 seeds at inception. A constant cannot be
a foreign key, so nothing outside a project could reference an area: systems.
project_id is NOT NULL, and a rule that spans projects would have to chain
itself to one project's row. And because the list only ever applied on the
inception-seed path, three spellings of one area reached this instance anyway
(CI & runners / CI and Release / CI & release).
- canonical_systems: global, no user_id — a shared project inherits the
vocabulary instead of re-earning it. Migration 0087 seeds the same eight.
- systems.canonical_id: nullable, SET NULL. Association only — no System is
renamed and record_systems is untouched, so no record's tags move.
- canonical_slug folds &/and, case and punctuation, so spelling variants map
mechanically and a real difference ("CI & runners") becomes a proposal a
human confirms. propose_mappings reports; set_system_canonical is the only
writer.
- seed_standard_systems now reads the catalog and maps as it mints, so a
project born standard never needs a reconciliation pass.
- Catalog writes are admin-only; reads are open — a global list anyone can
extend stops being shared.
- backup: carried by SLUG, not id (ids are per-install). Restore reuses the
target's own rows and only creates entries an admin added on the source; an
unknown slug restores unmapped rather than failing.
Rule 22: STANDARD_SYSTEMS is removed, not deprecated. Rule 115: nothing seeded
names an app, repo or house convention. Design in note 3026.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+18
-5
@@ -57,9 +57,22 @@ def test_normalize_choices_is_canonical_and_complete():
|
||||
"design_system_id": None, "seed_systems": False}
|
||||
|
||||
|
||||
def test_standard_systems_vocabulary_is_one_list_for_ask_and_seed():
|
||||
from scribe.mcp.tools.systems import _STANDARD_SYSTEMS
|
||||
from scribe.services.systems import STANDARD_SYSTEMS
|
||||
assert _STANDARD_SYSTEMS == tuple(n for n, _ in STANDARD_SYSTEMS)
|
||||
assert len(STANDARD_SYSTEMS) == 8 and all(charter for _, charter in STANDARD_SYSTEMS)
|
||||
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
|
||||
"""The vocabulary moved from a module constant to the global catalog table
|
||||
(milestone 307): a constant cannot be a foreign key, so nothing outside a
|
||||
project could reference an area. The seed and the bootstrap ask must both
|
||||
read the table, or the list they show and the list they mint diverge."""
|
||||
import inspect
|
||||
|
||||
from scribe.services import systems as systems_svc
|
||||
|
||||
assert not hasattr(systems_svc, "STANDARD_SYSTEMS"), (
|
||||
"the constant is gone — the catalog table is the single source"
|
||||
)
|
||||
source = inspect.getsource(systems_svc.seed_standard_systems)
|
||||
assert "list_canonical_systems" in source
|
||||
assert "canonical_id=entry.id" in source, (
|
||||
"a seeded System must be mapped as it is created, or a project born "
|
||||
"from the standard set still needs a reconciliation pass"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from scribe.models.project import Project
|
||||
from scribe.models.rulebook import Rulebook
|
||||
from scribe.services import inception as inception_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import canonical_systems as canonical_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
@@ -62,7 +63,11 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||
})
|
||||
assert out["effects"]["excluded"] == [seeded["always"]]
|
||||
assert out["effects"]["subscribed"] == [seeded["other"]]
|
||||
assert len(out["effects"]["systems_seeded"]) == len(systems_svc.STANDARD_SYSTEMS)
|
||||
catalog = await canonical_svc.list_canonical_systems()
|
||||
assert len(out["effects"]["systems_seeded"]) == len(catalog)
|
||||
# Seeded Systems come out mapped, not needing a later reconciliation.
|
||||
seeded_systems = await systems_svc.list_systems(owner, pid)
|
||||
assert all(s.canonical_id is not None for s in seeded_systems)
|
||||
|
||||
# The exclusion is total: the project's always-on set is empty, the
|
||||
# departure is named, the subscription binds.
|
||||
@@ -81,7 +86,7 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
|
||||
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
||||
assert again["effects"]["systems_seeded"] == []
|
||||
assert len(await systems_svc.list_systems(owner, pid)) == len(systems_svc.STANDARD_SYSTEMS)
|
||||
assert len(await systems_svc.list_systems(owner, pid)) == len(catalog)
|
||||
await rulebooks_svc.include_always_on_rulebook_for_project(pid, seeded["always"], owner)
|
||||
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""The canonical-slug matcher — the line between what maps mechanically and
|
||||
what a human is asked to confirm (milestone 307, note 3026).
|
||||
|
||||
These cases are the real drift found across the author's own instance: one area
|
||||
carrying three spellings, and two areas that LOOK alike and are not the same.
|
||||
Getting the boundary wrong in either direction is a silent failure — a missed
|
||||
mapping nobody thinks to make again, or a cross-project record surfacing in the
|
||||
wrong project.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from scribe.services.canonical_systems import canonical_slug
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["CI & Release", "CI and Release", "CI & release",
|
||||
" ci and release "])
|
||||
def test_spelling_variants_of_one_area_collapse_to_one_key(name):
|
||||
"""Case, spacing, punctuation and "&" vs "and" are not real differences.
|
||||
All three of the first spellings were live in different projects at once."""
|
||||
assert canonical_slug(name) == "ci-and-release"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,expected", [
|
||||
("Auth & Access", "auth-and-access"),
|
||||
("Data Model & Storage", "data-model-and-storage"),
|
||||
("UI & Design", "ui-and-design"),
|
||||
("Background Jobs", "background-jobs"),
|
||||
("Observability", "observability"),
|
||||
])
|
||||
def test_the_seeded_vocabulary_slugs_match_the_migration(name, expected):
|
||||
"""Migration 0087 writes these slugs literally. If the function and the
|
||||
migration disagree, every seeded entry becomes unreachable by exact match
|
||||
and every mapping silently degrades to a proposal."""
|
||||
assert canonical_slug(name) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("a,b", [
|
||||
("CI & Release", "CI & runners"),
|
||||
("Auth & Access", "Auth & Accounts"),
|
||||
("UI & Design", "Frontend (Vue app)"),
|
||||
("UI & Design", "Web Shell and Theme"),
|
||||
])
|
||||
def test_genuinely_different_names_do_not_collapse(a, b):
|
||||
"""These pairs may or may not be the same area — that is a judgment call,
|
||||
so they must NOT map automatically. They reach the operator as proposals."""
|
||||
assert canonical_slug(a) != canonical_slug(b)
|
||||
|
||||
|
||||
def test_a_nameless_system_yields_no_key():
|
||||
"""An empty slug is the one value callers must special-case: two unnameable
|
||||
Systems must not map onto each other. Both propose_mappings and find_by_name
|
||||
bail on a falsy slug for this reason."""
|
||||
assert canonical_slug("") == ""
|
||||
assert canonical_slug(" ") == ""
|
||||
assert canonical_slug("---") == ""
|
||||
|
||||
|
||||
def test_a_punctuation_separator_is_not_read_as_the_word_and():
|
||||
""""CI/Release" is a real spelling and it does NOT collapse onto
|
||||
"CI & Release" — only "&" carries that meaning. It still reaches the
|
||||
operator through the overlap path, where both words match; what it must not
|
||||
do is map itself silently."""
|
||||
assert canonical_slug("CI/Release") == "ci-release"
|
||||
assert canonical_slug("CI/Release") != canonical_slug("CI & Release")
|
||||
Reference in New Issue
Block a user