feat(shapes): a component file is a candidate shape in its own right (milestone 439 step 5)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 54s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / Python tests (push) Canceled after 1m42s

A single-file component defines `.card`, `.title` and a `Props`, never
anything named after itself — so the ledger could not hold "StatusChip is
canon" and could not say a new card was built where one already existed.

- coverage.is_file_unit: a file that RENDERS (its markup names classes, or it
  opens a <script>/<template>/<style> block) and defines nothing named after
  its stem gets one `file` row. Structural, not a framework list: Svelte and
  Vue components qualify, a TSX component already has its function's row, a
  module is accounted for by its definitions. Emitting every file would have
  put every module of every project in the todo at once.
- No body fingerprint on file rows, so an edit never re-asks a judgment.
- `file` is its own form and family; derive grouping skips it (`index`,
  `+page` repeat by convention). Divergence buckets it on its own, so a new
  component where a component canon dominates is a fair question.
- mark_canonicals: a snippet recorded at a path with no symbol makes that
  file row canonical; it still covers no definition inside.
- The write hooks apply the same test before noting a new file for the
  end-of-turn question.

Plugin version minted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 08:45:14 -04:00
co-authored by Claude Opus 5.5
parent 4cf1c6f042
commit 8b1567cf2c
8 changed files with 130 additions and 13 deletions
+41 -1
View File
@@ -597,6 +597,42 @@ def scannable(path: str) -> bool:
return not path.lower().endswith(_SKIP_SUFFIXES)
# --- the file as a unit (milestone 439) ---------------------------------------
#
# A component is the thing people reuse in a UI, and in a single-file
# component nothing inside is named after it: CatalogueBookCard.svelte defines
# `.card`, `.title` and a `Props`, never `CatalogueBookCard`. A ledger of
# definitions alone therefore could never say "a new card was built where
# CatalogueBookCard is canon". So such a file gets a row of its own.
#
# WHICH FILES, decided by structure rather than by a framework list: a file
# that RENDERS — its markup names classes, or it opens with a <script>,
# <template> or <style> block — and that DEFINES NOTHING NAMED AFTER ITSELF.
# A TSX `function CatalogueBookCard` already has its row; a module of helpers
# is accounted for by its helpers. Emitting a row for every file would put
# every module in every project into the todo at once, which is a flood, not
# a question. No body fingerprint either: every edit would otherwise ask for
# the file's judgment to be re-confirmed.
_RENDER_BLOCK_RE = re.compile(r"^\s*<(?:script|template|style)\b", re.IGNORECASE | re.MULTILINE)
def file_stem(path: str) -> str:
"""`web/src/lib/StatusChip.svelte` → `StatusChip`."""
return path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
def is_file_unit(
path: str, text: str, defs: list[Definition], refs: dict[str, int] | None = None,
) -> bool:
"""Is this file a candidate shape in its own right? See the block above."""
stem = file_stem(path)
if not stem:
return False
if any(d.name == stem for d in defs if d.kind == "sym"):
return False
return bool(refs) or _RENDER_BLOCK_RE.search(text) is not None
class ArchiveShape(NamedTuple):
"""A definition located in a repo archive — what the sync upserts and
the proposer matches. The leading (path, kind, name) triple is the
@@ -658,6 +694,11 @@ def scan_archive(blob: bytes) -> ArchiveScan:
continue
defs = extract_definitions(text)
scoped = scoped_definitions(path, text, defs)
refs = class_references(path, text)
if is_file_unit(path, text, defs, refs):
shapes.append(ArchiveShape(
path, "file", file_stem(path), f"file {path}", "", "",
))
shapes.extend(
ArchiveShape(
path, d.kind, d.name, d.signature, d.body_sha, d.body,
@@ -665,7 +706,6 @@ def scan_archive(blob: bytes) -> ArchiveScan:
)
for d in defs
)
refs = class_references(path, text)
if refs:
references[path] = refs
return ArchiveScan(shapes, references)
+17
View File
@@ -408,6 +408,11 @@ async def mark_canonicals(
fall back to unclassified. Agent judgments are never overwritten.
"""
usable = [(nid, p, s) for nid, p, s in recorded if (s or "").strip()]
# A location naming a FILE and no symbol is a whole-file record: it makes
# no claim about a definition inside (`location_covers`), but it is
# exactly the claim a `file` row asks about (milestone 439).
whole_files = {(p or "").strip(): nid for nid, p, s in recorded
if (p or "").strip() and not (s or "").strip()}
now = datetime.now(timezone.utc)
async with async_session() as session:
rows = (
@@ -426,6 +431,8 @@ async def mark_canonicals(
),
None,
)
if covering is None and row.kind == "file":
covering = whole_files.get(row.path)
if covering is not None and row.status in _MECHANICAL_TODO:
await _judge(session, row, status="canonical", snippet_id=covering,
by="mechanical", reason=None, at=now)
@@ -991,6 +998,10 @@ def shape_form(signature: str, kind: str = "sym") -> str:
"""
if kind == "css":
return "css"
if kind == "file":
# A whole file (milestone 439): its own family, comparable only with
# other files — a component is never told to build from a helper.
return "file"
sig = (signature or "").strip()
if not sig:
return FORM_UNKNOWN
@@ -1085,6 +1096,8 @@ def shape_family(form: str) -> str:
return "value"
if form == "css":
return "css"
if form == "file":
return "file"
return ""
@@ -2040,6 +2053,10 @@ async def apply_derive_groups(project_id: int) -> int:
CodeShape.status.in_(_MECHANICAL_TODO),
CodeShape.vanished_at.is_(None),
CodeShape.proposed_snippet_id.is_(None),
# A file row is named by its stem, and `index`, `+page`
# and `__init__` repeat by convention, not by copying —
# a derive family of them would be pure noise.
CodeShape.kind != "file",
)
)
).scalars().all()