diff --git a/alembic/versions/0086_code_shape_consumers.py b/alembic/versions/0086_code_shape_consumers.py new file mode 100644 index 0000000..d6c017d --- /dev/null +++ b/alembic/versions/0086_code_shape_consumers.py @@ -0,0 +1,35 @@ +"""code_shape_consumers — the CSS consumer map (milestone 302, note 2917) + +Revision ID: 0086 +Revises: 0085 +Create Date: 2026-08-23 + +CSS is watched by name, by recipe, by token and by WHAT USES IT. This table +holds the fourth: CSS shape → the file whose markup names its class, with how +many times. Mechanical and recomputed by every coverage sync from the repo +archive; the analogue of code_shape_uses for styling. Cascades with the shape. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0086" +down_revision = "0085" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "code_shape_consumers", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False), + sa.Column("path", sa.Text(), nullable=False), + sa.Column("count", sa.Integer(), nullable=False, server_default="1"), + sa.Column("basis", sa.Text(), nullable=False, server_default="template"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.UniqueConstraint("shape_id", "path", name="uq_code_shape_consumers_shape_path"), + ) + + +def downgrade() -> None: + op.drop_table("code_shape_consumers") diff --git a/src/scribe/models/__init__.py b/src/scribe/models/__init__.py index f5e11b8..9b3d206 100644 --- a/src/scribe/models/__init__.py +++ b/src/scribe/models/__init__.py @@ -44,6 +44,6 @@ from scribe.models.rulebook import ( # noqa: E402, F401 ) from scribe.models.repo_binding import RepoBinding # noqa: E402, F401 from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401 -from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse # noqa: E402, F401 +from scribe.models.code_shape import CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse # noqa: E402, F401 from scribe.models.system import System, RecordSystem # noqa: E402, F401 from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401 diff --git a/src/scribe/models/code_shape.py b/src/scribe/models/code_shape.py index 4929086..0e63964 100644 --- a/src/scribe/models/code_shape.py +++ b/src/scribe/models/code_shape.py @@ -265,6 +265,52 @@ class CodeShapeUse(Base): } +# How a consumer edge was established (milestone 302). `template` is the +# sync's mechanical read of a file's markup (class= / :class= / className=); +# the vocabulary is a list so a later basis (a stylesheet `@apply`, a script's +# classList) has a name without a schema change. +CONSUMER_BASES = ("template",) + + +class CodeShapeConsumer(Base): + """One consumer edge: CSS shape → the file whose markup names its class + (milestone 302; note 2917 — CSS is watched by name, by recipe, by token + and by WHAT USES IT). The analogue of CodeShapeUse for styling: `uses` + says what a shape calls, this says who renders a class. Rows, not prose, + so "is this recipe shared or scoped?" is a count, not a guess. + + Mechanical and fully recomputable: every coverage sync rebuilds a repo's + edges from its archive, so the table is not backed up (see + services/backup._NOT_INCLUDED). Cascades with the shape. + """ + + __tablename__ = "code_shape_consumers" + __table_args__ = ( + UniqueConstraint("shape_id", "path", name="uq_code_shape_consumers_shape_path"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + shape_id: Mapped[int] = mapped_column( + Integer, ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False + ) + path: Mapped[str] = mapped_column(Text, nullable=False) + count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + basis: Mapped[str] = mapped_column(Text, nullable=False, default="template") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "shape_id": self.shape_id, + "path": self.path, + "count": self.count, + "basis": self.basis, + "created_at": iso(self.created_at), + } + + # What a shape's history records (#2793). Not "appeared" — first_seen and # created_at already say that on the row; history is for what CHANGED: SHAPE_EVENTS = ("classified", "vanished", "reappeared", "drifted") diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py index 2f8cbff..d93f47f 100644 --- a/src/scribe/services/backup.py +++ b/src/scribe/services/backup.py @@ -92,6 +92,10 @@ _NOT_INCLUDED = [ # deliberately not exported either, so restored projects fall back to # keyring-by-host resolution — the documented unpinned behavior (#2778). "forge_connections", + # Derived, like note_embeddings: the CSS consumer map (milestone 302) is + # rebuilt from the repo archive by every coverage sync, and carries no + # judgment — the first refresh after a restore recreates it exactly. + "code_shape_consumers", ] diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index 99009eb..f6da17f 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -555,7 +555,8 @@ async def compute_coverage( # The binding's own ref when it names one (#2873: a dev-first project # has its ledger follow dev), else the forge's default branch. ref = binding.ref or await forge.default_branch(api_repo) - definitions = definitions_from_archive(await forge.archive(api_repo, ref)) + scan = scan_archive(await forge.archive(api_repo, ref)) + definitions = scan.definitions # The head commit is provenance sugar on the ledger rows; failing to # learn it must not fail the sync — the ref names the point well # enough and the row timestamps carry the when. @@ -567,6 +568,13 @@ async def compute_coverage( project_id, key, definitions, seen_marker=marker ) served.append((key, ref)) + # The CSS consumer map (milestone 302) rides the same archive: which + # files' markup names each class. Mechanical and recomputable, so it + # must not be able to fail the refresh either. + try: + await shape_ledger.sync_repo_consumers(project_id, key, scan.references) + except Exception: + logger.warning("consumer map sync failed for %s", key, exc_info=True) # Propose while the bodies are in hand — the one moment they exist. # Canonical marking below only touches rows the proposer leaves # alone (a canon's own location never gets a proposal), so the order diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index b48d986..b403a0c 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -30,7 +30,9 @@ from typing import Iterable, NamedTuple from sqlalchemy import select from scribe.models import async_session -from scribe.models.code_shape import REASON_CODES, CodeShape, CodeShapeEvent, CodeShapeUse +from scribe.models.code_shape import ( + REASON_CODES, CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse, +) from scribe.models.base import iso logger = logging.getLogger(__name__) @@ -262,6 +264,96 @@ async def uses_of(shape_ids) -> dict[int, list[CodeShapeUse]]: return out +# --- the CSS consumer map (milestone 302) ------------------------------------ + + +def resolve_consumers( + css_rows: Iterable[tuple[int, str, str]], + references: dict[str, dict[str, int]], +) -> dict[tuple[int, str], int]: + """{(shape_id, consumer_path): count} — which CSS rows each file's markup + consumes. ``css_rows`` are (id, path, symbol) of the repo's live css rows; + ``references`` is scan_archive's path → class token → count. + + Resolution (note 2917): a class named in file F resolves to F's OWN row + of that name when F defines it (a scoped rule is consumed by its own + template); otherwise to every other file's row of that name — a shared + sheet, or, when several files define it, all of them: the map says + "ambiguous" by fanning out rather than guessing one.""" + by_symbol: dict[str, list[tuple[int, str]]] = {} + for sid, path, symbol in css_rows: + by_symbol.setdefault(symbol, []).append((sid, path)) + out: dict[tuple[int, str], int] = {} + for consumer, tokens in references.items(): + for token, count in tokens.items(): + rows = by_symbol.get(token) + if not rows: + continue + own = [sid for sid, path in rows if path == consumer] + targets = own or [sid for sid, _path in rows] + for sid in targets: + out[(sid, consumer)] = out.get((sid, consumer), 0) + int(count) + return out + + +async def sync_repo_consumers( + project_id: int, repo_key: str, references: dict[str, dict[str, int]] +) -> int: + """Rebuild one repo's consumer edges from its archive's class references: + insert the new, refresh changed counts, delete what the tree no longer + says (a template rewritten, a class renamed, a file gone). Edges hang on + live rows only; a vanished row's edges go with this pass. Returns how + many edges stand afterwards.""" + async with async_session() as session: + rows = ( + await session.execute( + select(CodeShape.id, CodeShape.path, CodeShape.symbol, CodeShape.vanished_at).where( + CodeShape.project_id == project_id, + CodeShape.repo_key == repo_key, + CodeShape.kind == "css", + ) + ) + ).all() + live = [(r[0], r[1], r[2]) for r in rows if r[3] is None] + all_ids = [r[0] for r in rows] + wanted = resolve_consumers(live, references) + existing = ( + await session.execute( + select(CodeShapeConsumer).where(CodeShapeConsumer.shape_id.in_(all_ids)) + ) + ).scalars().all() if all_ids else [] + have = {(e.shape_id, e.path): e for e in existing} + for key, edge in have.items(): + if key not in wanted: + await session.delete(edge) + elif edge.count != wanted[key]: + edge.count = wanted[key] + for (sid, path), count in wanted.items(): + if (sid, path) not in have: + session.add(CodeShapeConsumer(shape_id=sid, path=path, count=count, basis="template")) + await session.commit() + return len(wanted) + + +async def consumers_of(shape_ids) -> dict[int, list[CodeShapeConsumer]]: + """{shape_id: [edges]} for a set of rows — the read side of the map, + ordered by path so a readout is stable.""" + ids = [int(x) for x in shape_ids if x] + if not ids: + return {} + async with async_session() as session: + edges = ( + await session.execute( + select(CodeShapeConsumer).where(CodeShapeConsumer.shape_id.in_(ids)) + .order_by(CodeShapeConsumer.shape_id, CodeShapeConsumer.path) + ) + ).scalars().all() + out: dict[int, list[CodeShapeConsumer]] = {} + for e in edges: + out.setdefault(e.shape_id, []).append(e) + return out + + async def mark_canonicals( project_id: int, recorded: list[tuple[int, str, str]] ) -> None: diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index 4d24c43..798cbce 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -683,6 +683,49 @@ async def test_derive_new_names_the_copy_that_joined_a_family_since_the_stamp(se assert derive_new_summary(rows, since=None)["count"] == 0 +@pytest.mark.integration +async def test_consumer_map_syncs_edges_from_template_references(seeded): + """Milestone 302: the consumer edges follow the archive — own-file + resolution for a scoped class, fan-out to the shared sheet for a class a + template does not define, counts refreshed and stale edges removed on + the next sync, and a vanished row's edges gone with it.""" + from scribe.services.shape_ledger import consumers_of, live_rows, sync_repo_consumers + + pid = seeded["pid"] + defs = _defs( + ("v/A.vue", "css", "error-msg", ".error-msg {", ".error-msg { color: red }"), + ("v/B.vue", "css", "error-msg", ".error-msg {", ".error-msg { color: blue }"), + ("assets/components.css", "css", "btn-primary", ".btn-primary {", ".btn-primary { x: 1 }"), + ) + await sync_repo_shapes(pid, REPO, defs, seen_marker="m1") + refs = { + "v/A.vue": {"error-msg": 2, "btn-primary": 1}, + "v/B.vue": {"error-msg": 1}, + "v/C.vue": {"error-msg": 1, "btn-primary": 4}, + } + assert await sync_repo_consumers(pid, REPO, refs) == 5 + rows = {(r.path, r.symbol): r.id for r in await live_rows(pid) if r.kind == "css"} + edges = await consumers_of(rows.values()) + view = {(p, s): [(e.path, e.count) for e in edges.get(i, [])] for (p, s), i in rows.items()} + assert view[("v/A.vue", "error-msg")] == [("v/A.vue", 2)] + assert view[("v/B.vue", "error-msg")] == [("v/B.vue", 1), ("v/C.vue", 1)] + assert view[("assets/components.css", "btn-primary")] == [("v/A.vue", 1), ("v/C.vue", 4)] + assert view[("web/button.css", "btn")] == [] # the seeded row: no template names it + + # The next tree: C stops using error-msg, A uses btn-primary twice now. + refs2 = {"v/A.vue": {"error-msg": 2, "btn-primary": 2}, "v/B.vue": {"error-msg": 1}} + assert await sync_repo_consumers(pid, REPO, refs2) == 3 + edges = await consumers_of(rows.values()) + assert [(e.path, e.count) for e in edges[rows[("v/B.vue", "error-msg")]]] == [("v/B.vue", 1)] + assert [(e.path, e.count) for e in edges[rows[("assets/components.css", "btn-primary")]]] == [("v/A.vue", 2)] + + # B's rule vanishes from the tree → its edges go with the pass. + await sync_repo_shapes(pid, REPO, [d for d in defs if d[0] != "v/B.vue"], seen_marker="m2") + await sync_repo_consumers(pid, REPO, refs2) + edges = await consumers_of(rows.values()) + assert rows[("v/B.vue", "error-msg")] not in edges + + # --- #2793: the divergence readout against real rows ------------------------- diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index c56ac33..52c0967 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -433,6 +433,44 @@ def test_compact_row_carries_identity_standing_and_the_proposers_word_only(): assert noisy not in compact +def test_resolve_consumers_prefers_the_own_file_and_fans_out_for_shared_names(): + """Milestone 302: a class named in a template resolves to that file's + OWN row when it defines the class (a scoped rule, consumed by its own + markup); otherwise to every other definition of the name — one shared + sheet, or all of several (the map fans out rather than guessing).""" + from scribe.services.shape_ledger import resolve_consumers + css_rows = [ + (1, "v/A.vue", "error-msg"), # scoped, defined + used in A + (2, "v/B.vue", "error-msg"), # scoped, defined in B, used in B and C + (3, "assets/components.css", "btn-primary"), # the shared sheet + (4, "assets/a.css", "pill"), (5, "assets/b.css", "pill"), # two shared defs + (6, "assets/c.css", "unused"), + ] + refs = { + "v/A.vue": {"error-msg": 2, "btn-primary": 1, "nothing-defined": 1}, + "v/B.vue": {"error-msg": 1}, + "v/C.vue": {"error-msg": 1, "pill": 3}, + } + assert resolve_consumers(css_rows, refs) == { + (1, "v/A.vue"): 2, # own row, not B's + (3, "v/A.vue"): 1, # the shared sheet + (2, "v/B.vue"): 1, # own row + (2, "v/C.vue"): 1, # C defines nothing → the other file's row + (4, "v/C.vue"): 3, (5, "v/C.vue"): 3, # ambiguous: both, not a guess + } + # Unknown tokens and an unreferenced row leave no trace. + assert all(sid != 6 for sid, _ in resolve_consumers(css_rows, refs)) + + +def test_consumer_edges_table_cascades_with_the_shape(): + from scribe.models import Base + from scribe.models.code_shape import CONSUMER_BASES, CodeShapeConsumer + assert "code_shape_consumers" in Base.metadata.tables + cols = CodeShapeConsumer.__table__.c + assert next(iter(cols.shape_id.foreign_keys)).ondelete == "CASCADE" + assert CONSUMER_BASES == ("template",) + + def test_uses_edges_table_and_validation(): """#2870: consumption is its own relation — a table that cascades with both ends, and `uses` on a classification must be a list of ids."""