CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename. - src/scribe -> src/grimoire, and every import, patch target, module path, hypercorn entry point and `python -m grimoire.restore` with it; pyproject, uv.lock and the frontend package are named grimoire. - Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service runs as grimoire / grimoire_test. - Compose files default the DB user, database and (prod) volume to grimoire, each overridable from .env, so an install created as Scribe keeps its data by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME. - Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar. Archives named scribe-* with the scribe-backup-tar manifest still restore and are still counted at the destination (folder and S3), with tests. - Exports, the MCP auth realm, the ASGI scope key and the persistent logger carry the new name. Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1", because every stored secret was derived under it. Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
91 lines
4.3 KiB
Python
91 lines
4.3 KiB
Python
"""Project inception — the record's shape.
|
|
|
|
The WHY a project inherits what it does lives on projects.inception. Pure
|
|
validation is pinned here; the effects are the integration tests.
|
|
|
|
Rules are not an inception choice: a rule in a rulebook is global, so there is
|
|
nothing to choose.
|
|
"""
|
|
from grimoire.models.project import Project
|
|
from grimoire.services.inception import (
|
|
CHOICE_KEYS, INCEPTION_VIAS, is_decided, normalize_choices, validate_inception,
|
|
)
|
|
|
|
|
|
def test_project_carries_an_inception_record_and_to_dict_shows_it():
|
|
assert "inception" in Project.__table__.c
|
|
assert Project.__table__.c.inception.nullable # NULL = undecided
|
|
p = Project(user_id=1, title="x", inception=None)
|
|
assert p.to_dict()["inception"] is None and not is_decided(p)
|
|
p.inception = {"via": "mcp", "decided_at": "2026-08-22T00:00:00+00:00", "decided_by": 1,
|
|
"choices": normalize_choices({"seed_systems": True})}
|
|
assert is_decided(p) and p.to_dict()["inception"]["via"] == "mcp"
|
|
assert INCEPTION_VIAS == ("mcp", "ui", "legacy")
|
|
|
|
|
|
|
|
|
|
def test_validate_inception_pins_the_choice_vocabulary():
|
|
assert CHOICE_KEYS == ("design_system_id", "seed_systems", "platforms")
|
|
assert validate_inception({}) is None
|
|
assert validate_inception({"design_system_id": 3, "seed_systems": True}) is None
|
|
assert validate_inception({"design_system_id": None}) is None
|
|
assert "must be an object" in validate_inception([])
|
|
assert "unknown inception choice" in validate_inception({"repo": "x"})
|
|
# The retired exclusion key is now an UNKNOWN key rather than a typed one,
|
|
# which is the right error: a caller still passing it is asking for a
|
|
# choice that no longer exists, and silently ignoring it would let them
|
|
# believe a rulebook had been declined.
|
|
assert "unknown inception choice" in validate_inception(
|
|
{"exclude_always_on_rulebooks": [1]})
|
|
# Same for a rulebook subscription choice.
|
|
assert "unknown inception choice" in validate_inception({"subscribe_rulebooks": [2]})
|
|
assert "positive id or null" in validate_inception({"design_system_id": 0})
|
|
assert "positive id or null" in validate_inception({"design_system_id": True})
|
|
assert "true or false" in validate_inception({"seed_systems": "yes"})
|
|
|
|
|
|
def test_validate_inception_takes_platforms_as_slugs_or_unstated():
|
|
"""Slugs, not ids: an inception record outlives a restore, and a slug is
|
|
what both sides of a restore agree on. None is "not answered", which is
|
|
different from [] — "none of them"."""
|
|
assert validate_inception({"platforms": ["android-app", "go"]}) is None
|
|
assert validate_inception({"platforms": []}) is None
|
|
assert validate_inception({"platforms": None}) is None
|
|
assert "platform slugs" in validate_inception({"platforms": "android-app"})
|
|
assert "platform slugs" in validate_inception({"platforms": [3]})
|
|
assert "platform slugs" in validate_inception({"platforms": [" "]})
|
|
|
|
|
|
def test_normalize_choices_is_canonical_and_complete():
|
|
out = normalize_choices({"design_system_id": 4})
|
|
assert out == {"design_system_id": 4, "seed_systems": False, "platforms": None}
|
|
assert normalize_choices(None) == {
|
|
"design_system_id": None, "seed_systems": False, "platforms": None,
|
|
}
|
|
# Sorted and de-duplicated, so two equal answers record identically.
|
|
assert normalize_choices({"platforms": ["go", " android-app", "go"]})["platforms"] == [
|
|
"android-app", "go",
|
|
]
|
|
|
|
|
|
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
|
|
"""The vocabulary is the global catalog table, not a module constant: 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 grimoire.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"
|
|
)
|
|
|