feat(ledger): the CSS consumer map — code_shape_consumers edges (shape → file whose markup names the class, count), migration 0086, resolve_consumers (own-file row when the template defines the class, else every other definition), sync_repo_consumers rebuilt from the archive on every refresh, consumers_of; derived, so not backed up (milestone 302 step 2, #2935)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Failing after 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 52s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Failing after 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 52s
CI & Build / Build & push image (push) Skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user