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
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:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"name": "scribe",
|
||||||
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
||||||
"version": "2026.10.01.1242",
|
"version": "2026.10.01.1245",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ while IFS= read -r rel_path; do
|
|||||||
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12 || true)
|
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12 || true)
|
||||||
# What this write defined, for the end-of-turn question (milestone 439) —
|
# What this write defined, for the end-of-turn question (milestone 439) —
|
||||||
# recorded before the arms below, which skip a write that defines nothing.
|
# recorded before the arms below, which skip a write that defines nothing.
|
||||||
printf '%s\n' "$names" | scribe_written_append "$safe_sid" "$rel_path" "$fresh"
|
printf '%s\n' "$names" | scribe_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
|
||||||
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
|
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
|
||||||
# nothing to say; the arms are about shapes.
|
# nothing to say; the arms are about shapes.
|
||||||
[ -n "$names" ] || continue
|
[ -n "$names" ] || continue
|
||||||
|
|||||||
@@ -1163,22 +1163,32 @@ SCRIBE_LEDGER_DIRS="scribe-priorart scribe-autoinject"
|
|||||||
# end of the turn (scribe_shape_check.sh). Its own file — one file, one
|
# end of the turn (scribe_shape_check.sh). Its own file — one file, one
|
||||||
# question (lesson #4226) — under the prior-art directory, so the session
|
# question (lesson #4226) — under the prior-art directory, so the session
|
||||||
# clear above already sweeps it. Read `kind<TAB>name` lines on stdin (the
|
# clear above already sweeps it. Read `kind<TAB>name` lines on stdin (the
|
||||||
# scribe_defs format). A NEW file adds one `file` line named by its stem: a
|
# scribe_defs format).
|
||||||
# component, module or package file is a candidate in its own right, in any
|
#
|
||||||
# language, without a framework rule saying so.
|
# A NEW file that is a unit in its own right adds one `file` line named by its
|
||||||
|
# stem — the same test the server's sync applies (coverage.is_file_unit): it
|
||||||
|
# RENDERS (markup names classes, or it opens a <script>/<template>/<style>
|
||||||
|
# block) and nothing inside it is named after it. That is a component in any
|
||||||
|
# framework, without a list of frameworks.
|
||||||
# $1 session id (already made filename-safe) $2 repo-relative path
|
# $1 session id (already made filename-safe) $2 repo-relative path
|
||||||
# $3 "new" when the file did not exist before this write
|
# $3 "new" when the file did not exist before this write
|
||||||
|
# $4 the code written (to test whether the file renders)
|
||||||
scribe_written_append() {
|
scribe_written_append() {
|
||||||
local sid="$1" rel="$2" fresh="${3:-}" dir stem
|
local sid="$1" rel="$2" fresh="${3:-}" code="${4:-}" dir stem names
|
||||||
if [ -z "$sid" ] || [ -z "$rel" ]; then cat >/dev/null; return 0; fi
|
if [ -z "$sid" ] || [ -z "$rel" ]; then cat >/dev/null; return 0; fi
|
||||||
dir="${TMPDIR:-/tmp}/scribe-priorart"
|
dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||||
mkdir -p "$dir" 2>/dev/null || true
|
mkdir -p "$dir" 2>/dev/null || true
|
||||||
|
names=$(cat)
|
||||||
{
|
{
|
||||||
if [ "$fresh" = "new" ]; then
|
if [ "$fresh" = "new" ]; then
|
||||||
stem=${rel##*/}; stem=${stem%.*}
|
stem=${rel##*/}; stem=${stem%.*}
|
||||||
[ -n "$stem" ] && printf '%s\tfile\t%s\n' "$rel" "$stem"
|
if [ -n "$stem" ] \
|
||||||
|
&& ! printf '%s\n' "$names" | awk -F'\t' -v s="$stem" '$1 == "sym" && $2 == s { f = 1 } END { exit !f }' \
|
||||||
|
&& printf '%s\n' "$code" | grep -qE '^[[:space:]]*<(script|template|style)([[:space:]>]|$)|(class|className)[[:space:]]*='; then
|
||||||
|
printf '%s\tfile\t%s\n' "$rel" "$stem"
|
||||||
fi
|
fi
|
||||||
awk -F'\t' -v p="$rel" 'NF >= 2 && ($1 == "sym" || $1 == "css") && $2 != "" { printf "%s\t%s\t%s\n", p, $1, $2 }'
|
fi
|
||||||
|
printf '%s\n' "$names" | awk -F'\t' -v p="$rel" 'NF >= 2 && ($1 == "sym" || $1 == "css") && $2 != "" { printf "%s\t%s\t%s\n", p, $1, $2 }'
|
||||||
} >> "$dir/${sid}.written.ids" 2>/dev/null || true
|
} >> "$dir/${sid}.written.ids" 2>/dev/null || true
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,12 +202,13 @@ rule_exclude_q=""
|
|||||||
if [ -n "$session_id" ]; then
|
if [ -n "$session_id" ]; then
|
||||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||||
# What this write defined, for the end-of-turn question (milestone 439). A
|
# What this write defined, for the end-of-turn question (milestone 439). A
|
||||||
# Write onto a path that does not exist yet creates a file — a candidate in
|
# Write onto a path that does not exist yet creates a file, which may be a
|
||||||
# its own right. Written before any server call: the ask at the end of the
|
# unit in its own right (see scribe_written_append). Written before any
|
||||||
# turn must not depend on this write's hint having been answered.
|
# server call: the ask at the end of the turn must not depend on this
|
||||||
|
# write's hint having been answered.
|
||||||
fresh=""
|
fresh=""
|
||||||
[ -e "$file_path" ] || fresh="new"
|
[ -e "$file_path" ] || fresh="new"
|
||||||
printf '%s\n' "$shapes" | scribe_written_append "$safe_sid" "$rel_path" "$fresh"
|
printf '%s\n' "$shapes" | scribe_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
|
||||||
idfile="$state_dir/${safe_sid}.ids"
|
idfile="$state_dir/${safe_sid}.ids"
|
||||||
syncfile="$state_dir/${safe_sid}.sync.ids"
|
syncfile="$state_dir/${safe_sid}.sync.ids"
|
||||||
derivefile="$state_dir/${safe_sid}.derive.ids"
|
derivefile="$state_dir/${safe_sid}.derive.ids"
|
||||||
|
|||||||
@@ -597,6 +597,42 @@ def scannable(path: str) -> bool:
|
|||||||
return not path.lower().endswith(_SKIP_SUFFIXES)
|
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):
|
class ArchiveShape(NamedTuple):
|
||||||
"""A definition located in a repo archive — what the sync upserts and
|
"""A definition located in a repo archive — what the sync upserts and
|
||||||
the proposer matches. The leading (path, kind, name) triple is the
|
the proposer matches. The leading (path, kind, name) triple is the
|
||||||
@@ -658,6 +694,11 @@ def scan_archive(blob: bytes) -> ArchiveScan:
|
|||||||
continue
|
continue
|
||||||
defs = extract_definitions(text)
|
defs = extract_definitions(text)
|
||||||
scoped = scoped_definitions(path, text, defs)
|
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(
|
shapes.extend(
|
||||||
ArchiveShape(
|
ArchiveShape(
|
||||||
path, d.kind, d.name, d.signature, d.body_sha, d.body,
|
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
|
for d in defs
|
||||||
)
|
)
|
||||||
refs = class_references(path, text)
|
|
||||||
if refs:
|
if refs:
|
||||||
references[path] = refs
|
references[path] = refs
|
||||||
return ArchiveScan(shapes, references)
|
return ArchiveScan(shapes, references)
|
||||||
|
|||||||
@@ -408,6 +408,11 @@ async def mark_canonicals(
|
|||||||
fall back to unclassified. Agent judgments are never overwritten.
|
fall back to unclassified. Agent judgments are never overwritten.
|
||||||
"""
|
"""
|
||||||
usable = [(nid, p, s) for nid, p, s in recorded if (s or "").strip()]
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
rows = (
|
rows = (
|
||||||
@@ -426,6 +431,8 @@ async def mark_canonicals(
|
|||||||
),
|
),
|
||||||
None,
|
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:
|
if covering is not None and row.status in _MECHANICAL_TODO:
|
||||||
await _judge(session, row, status="canonical", snippet_id=covering,
|
await _judge(session, row, status="canonical", snippet_id=covering,
|
||||||
by="mechanical", reason=None, at=now)
|
by="mechanical", reason=None, at=now)
|
||||||
@@ -991,6 +998,10 @@ def shape_form(signature: str, kind: str = "sym") -> str:
|
|||||||
"""
|
"""
|
||||||
if kind == "css":
|
if kind == "css":
|
||||||
return "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()
|
sig = (signature or "").strip()
|
||||||
if not sig:
|
if not sig:
|
||||||
return FORM_UNKNOWN
|
return FORM_UNKNOWN
|
||||||
@@ -1085,6 +1096,8 @@ def shape_family(form: str) -> str:
|
|||||||
return "value"
|
return "value"
|
||||||
if form == "css":
|
if form == "css":
|
||||||
return "css"
|
return "css"
|
||||||
|
if form == "file":
|
||||||
|
return "file"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -2040,6 +2053,10 @@ async def apply_derive_groups(project_id: int) -> int:
|
|||||||
CodeShape.status.in_(_MECHANICAL_TODO),
|
CodeShape.status.in_(_MECHANICAL_TODO),
|
||||||
CodeShape.vanished_at.is_(None),
|
CodeShape.vanished_at.is_(None),
|
||||||
CodeShape.proposed_snippet_id.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()
|
).scalars().all()
|
||||||
|
|||||||
@@ -1231,3 +1231,31 @@ async def test_unjudged_is_what_nobody_read(seeded):
|
|||||||
assert got[1]["evidence"]["stamped"] == {"snippet_id": sid, "score": 0.7}
|
assert got[1]["evidence"]["stamped"] == {"snippet_id": sid, "score": 0.7}
|
||||||
assert got[2]["evidence"] == {}
|
assert got[2]["evidence"] == {}
|
||||||
assert await unjudged_shapes(pid, []) == []
|
assert await unjudged_shapes(pid, []) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_a_whole_file_snippet_makes_its_file_row_canonical(seeded):
|
||||||
|
"""Milestone 439: a snippet recorded at a component's path, with no
|
||||||
|
symbol, is the canon for that file row — the claim a whole-file record
|
||||||
|
makes and a definition row never could. It still covers no definition
|
||||||
|
inside the file."""
|
||||||
|
from scribe.services import snippets as snippets_svc
|
||||||
|
from scribe.services.coverage import _recorded_locations
|
||||||
|
from scribe.services.shape_ledger import mark_canonicals
|
||||||
|
|
||||||
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
|
await sync_repo_shapes(pid, REPO, SHAPES + [
|
||||||
|
("web/lib/StatusChip.svelte", "file", "StatusChip"),
|
||||||
|
("web/lib/StatusChip.svelte", "css", "chip"),
|
||||||
|
], seen_marker="c1")
|
||||||
|
chip = await snippets_svc.create_snippet(
|
||||||
|
owner, name="StatusChip — the one status pill",
|
||||||
|
code="<span class=\"chip\">{label}</span>", language="svelte",
|
||||||
|
repo="Widget", path="web/lib/StatusChip.svelte", project_id=pid,
|
||||||
|
)
|
||||||
|
await mark_canonicals(pid, await _recorded_locations(owner, pid))
|
||||||
|
rows, _ = await list_project_shapes(owner, pid, path="web/lib/StatusChip.svelte")
|
||||||
|
by = {(r.kind, r.symbol): r for r in rows}
|
||||||
|
assert by[("file", "StatusChip")].status == "canonical"
|
||||||
|
assert by[("file", "StatusChip")].snippet_id == int(chip.id)
|
||||||
|
assert by[("css", "chip")].status == "unclassified"
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ def test_scan_archive_returns_definitions_and_references_from_one_walk():
|
|||||||
scan = scan_archive(_tarball(tree))
|
scan = scan_archive(_tarball(tree))
|
||||||
assert isinstance(scan, ArchiveScan)
|
assert isinstance(scan, ArchiveScan)
|
||||||
assert [(d.path, d.kind, d.name) for d in scan.definitions] == TREE_SHAPES + [
|
assert [(d.path, d.kind, d.name) for d in scan.definitions] == TREE_SHAPES + [
|
||||||
|
("web/Card.vue", "file", "Card"), # a component is a unit (milestone 439)
|
||||||
("web/Card.vue", "css", "card"),
|
("web/Card.vue", "css", "card"),
|
||||||
]
|
]
|
||||||
# Only files whose markup names a class appear; the .py/.css files don't.
|
# Only files whose markup names a class appear; the .py/.css files don't.
|
||||||
@@ -886,3 +887,23 @@ async def test_binding_ref_is_the_branch_the_ledger_follows(seeded):
|
|||||||
coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
|
coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
|
||||||
assert coverage["repos"][0]["ref"] == "main"
|
assert coverage["repos"][0]["ref"] == "main"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path,text,unit", [
|
||||||
|
# A single-file component: nothing inside carries its name.
|
||||||
|
("web/lib/StatusChip.svelte", "<script>\nfunction tone() {\n return 1;\n}\n</script>\n<span class=\"chip\"></span>\n", True),
|
||||||
|
("web/views/Card.vue", "<template><div class=\"card\"/></template>\n", True),
|
||||||
|
# A TSX component already has its row: the function named after the file.
|
||||||
|
("web/Card.tsx", "export function Card() {\n return <div className=\"card\" />;\n}\n", False),
|
||||||
|
# A JSX file whose markup names classes but whose component is named otherwise.
|
||||||
|
("web/index.jsx", "function App() {\n return <div className=\"app\" />;\n}\n", True),
|
||||||
|
# Modules render nothing: their definitions account for them.
|
||||||
|
("internal/api/upnext.go", "func stageOf(x int) int {\n\treturn x\n}\n", False),
|
||||||
|
("src/app/util.py", "def helper():\n pass\n", False),
|
||||||
|
("web/button.css", ".btn {\n color: red;\n}\n", False),
|
||||||
|
])
|
||||||
|
def test_a_file_is_a_unit_when_it_renders_and_nothing_inside_carries_its_name(path, text, unit):
|
||||||
|
"""Milestone 439: one structural test, not a framework list."""
|
||||||
|
from scribe.services.coverage import class_references, extract_definitions, is_file_unit
|
||||||
|
|
||||||
|
assert is_file_unit(path, text, extract_definitions(text), class_references(path, text)) is unit
|
||||||
|
|||||||
Reference in New Issue
Block a user