diff --git a/alembic/versions/0080_code_shape_proposals.py b/alembic/versions/0080_code_shape_proposals.py new file mode 100644 index 0000000..7a6e932 --- /dev/null +++ b/alembic/versions/0080_code_shape_proposals.py @@ -0,0 +1,54 @@ +"""Shape fingerprints + the mechanical proposer's columns (#2792, milestone 294) + +Revision ID: 0080 +Revises: 0079 +Create Date: 2026-08-21 + +Two additions to the ledger. `signature` / `body_sha` fingerprint each shape +(definition line + a whitespace/comment-insensitive hash of its block) so the +proposer can match on content and a later drift recheck can notice change, +without the ledger ever storing code. The proposal columns carry the +proposer's standing suggestion for an unclassified row — instance-of-#N with +a basis and score, or a derive-first group key — and `proposed_sha` +remembers the content it was judged at so a refresh re-examines only what +changed. Mechanical and recomputable: a restore that lacks them loses +nothing the next refresh does not rebuild. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0080" +down_revision = "0079" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("code_shapes", sa.Column("signature", sa.Text(), nullable=False, server_default="")) + op.add_column("code_shapes", sa.Column("body_sha", sa.Text(), nullable=False, server_default="")) + op.add_column( + "code_shapes", + sa.Column( + "proposed_snippet_id", + sa.BigInteger(), + sa.ForeignKey("notes.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.add_column("code_shapes", sa.Column("proposal_basis", sa.Text(), nullable=True)) + op.add_column("code_shapes", sa.Column("proposal_score", sa.Float(), nullable=True)) + op.add_column("code_shapes", sa.Column("proposal_group", sa.Text(), nullable=True)) + op.add_column("code_shapes", sa.Column("proposed_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("code_shapes", sa.Column("proposed_sha", sa.Text(), nullable=False, server_default="")) + op.create_index( + "ix_code_shapes_proposed", "code_shapes", ["project_id", "proposed_snippet_id"] + ) + + +def downgrade() -> None: + op.drop_index("ix_code_shapes_proposed", table_name="code_shapes") + for col in ( + "proposed_sha", "proposed_at", "proposal_group", "proposal_score", + "proposal_basis", "proposed_snippet_id", "body_sha", "signature", + ): + op.drop_column("code_shapes", col) diff --git a/alembic/versions/0081_shape_history_and_divergence.py b/alembic/versions/0081_shape_history_and_divergence.py new file mode 100644 index 0000000..052f883 --- /dev/null +++ b/alembic/versions/0081_shape_history_and_divergence.py @@ -0,0 +1,70 @@ +"""Shape history, recheck, and the divergence flag (#2793, milestone 294) + +Revision ID: 0081 +Revises: 0080 +Create Date: 2026-08-21 + +The payoff surface of the ledger. `classified_sha` remembers the fingerprint +a judgment was made at so a later body change under an instance/variant can +flag `recheck_at`; `diverges_from` is the button-B flag (a shape new since +the previous refresh, where one canon dominates its directory+kind, and not +proposed as that canon). `code_shape_events` is the what-was-used-when +record: every classification, vanish, reappearance, and drift as it +happened — history the row alone cannot keep once it moves on. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0081" +down_revision = "0080" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("code_shapes", sa.Column("classified_sha", sa.Text(), nullable=False, server_default="")) + op.add_column("code_shapes", sa.Column("recheck_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column( + "code_shapes", + sa.Column( + "diverges_from", + sa.BigInteger(), + sa.ForeignKey("notes.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.create_index("ix_code_shapes_diverges", "code_shapes", ["project_id", "diverges_from"]) + op.create_table( + "code_shape_events", + 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("project_id", sa.Integer(), nullable=False), + sa.Column("path", sa.Text(), nullable=False), + sa.Column("symbol", sa.Text(), nullable=False), + sa.Column("kind", sa.Text(), nullable=False), + sa.Column("event", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=True), + sa.Column("snippet_id", sa.BigInteger(), nullable=True), + sa.Column("classified_by", sa.Text(), nullable=True), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("commit", sa.Text(), nullable=False, server_default=""), + sa.Column("at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_code_shape_events_shape", "code_shape_events", ["shape_id", "at"]) + op.create_index( + "ix_code_shape_events_project_path", "code_shape_events", ["project_id", "path"] + ) + + +def downgrade() -> None: + op.drop_index("ix_code_shape_events_project_path", table_name="code_shape_events") + op.drop_index("ix_code_shape_events_shape", table_name="code_shape_events") + op.drop_table("code_shape_events") + op.drop_index("ix_code_shapes_diverges", table_name="code_shapes") + for col in ("diverges_from", "recheck_at", "classified_sha"): + op.drop_column("code_shapes", col) diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index 906ab7e..6b7d7df 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -427,6 +427,13 @@ interface CoverageGap { unclassified: number; total: number; } +interface DeriveGroup { + group: string; + kind: string; + label: string; + size: number; + paths: string[]; +} interface Coverage { total: number; accounted: number; @@ -436,6 +443,21 @@ interface Coverage { computed_at: string; repos: { repo: string; ref: string; total: number; accounted: number }[]; largest_gaps: CoverageGap[]; + // The mechanical proposer's standing (#2792): canon proposals awaiting an + // agent's confirm, and the biggest repeats-with-no-canon families. + proposed?: number; + derive_groups?: DeriveGroup[]; + // The divergence readout (#2793): button B where button A is canon, and + // judged shapes whose bodies moved since they were judged. + divergent?: number; + divergence?: Divergence[]; + recheck?: number; +} +interface Divergence { + path: string; + symbol: string; + kind: string; + canon_snippet_id: number; } const coverage = ref(null); @@ -746,6 +768,46 @@ async function confirmDelete() { {{ gap.dir }} {{ gap.unclassified }} +
+ Proposed: + + {{ coverage.proposed }} awaiting confirm + + + {{ g.label }} ×{{ g.size }} + +
+
+ Divergence: + + {{ d.kind === 'css' ? '.' : '' }}{{ d.symbol }} + → #{{ d.canon_snippet_id }} + + + +{{ (coverage.divergent || 0) - (coverage.divergence?.length || 0) }} more + + + {{ coverage.recheck }} to recheck + +

Not measured yet — Refresh reads the bound repo's definitions into @@ -1300,6 +1362,7 @@ async function confirmDelete() { font-size: 0.74rem; } .coverage-gap-count { opacity: 0.65; } +.coverage-divergent { border-color: var(--fs-warning); } .coverage-empty { margin: 0; font-size: 0.8rem; diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index f699407..b2ea759 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.33", + "version": "0.1.36", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_prior_art.sh b/plugin/hooks/scribe_prior_art.sh index 724580c..bc8276c 100755 --- a/plugin/hooks/scribe_prior_art.sh +++ b/plugin/hooks/scribe_prior_art.sh @@ -14,6 +14,12 @@ # on an instance with no forge connection (decision #2707). Everything else is # the REUSE menu. The two dedup separately (see the state files below). # +# It is also the shape ledger's write-path feed (#2791): it names the +# definitions being written (`shapes=`), and the server — only when the +# session has PULLED a snippet this code references or resembles — records +# them as instance rows, classified_by=hook. Evidence, not judgment; the +# context line says what landed so a wrong stamp is corrected in the moment. +# # NEVER BLOCKS. It returns `additionalContext` with no `permissionDecision`, so # the write proceeds untouched and Claude sees the note beside the tool result. # Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A @@ -96,10 +102,14 @@ fi # `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded # because several per type is normal Rust, not duplication. # --------------------------------------------------------------------------- -local_lines="" -if [ -n "$repo_root" ] && [ -n "$code" ]; then - # kindname for each thing this payload DEFINES. - names=$(printf '%s' "$code" | awk ' +# kindname for each thing a piece of code DEFINES, in source order. One +# program, two consumers: the local duplicate arm (every definition in the +# payload) and the ledger feed (#2791, below: the definitions being written, +# or the one enclosing an Edit). Rule-for-rule mirrored by the server's +# services/coverage.py extract_shapes — ledger rows are keyed by what THAT +# sees, so the two must agree on what counts as a definition. +scribe_defs() { + awk ' { # CSS class definition: .name { or .name, if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) { @@ -132,8 +142,16 @@ if [ -n "$repo_root" ] && [ -n "$code" ]; then if (t != "") print "sym\t" t; next } } - ' 2>/dev/null | sort -u | head -12) || names="" + ' 2>/dev/null +} +names="" +if [ -n "$code" ]; then + names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names="" +fi + +local_lines="" +if [ -n "$repo_root" ] && [ -n "$names" ]; then while IFS=$'\t' read -r kind name; do [ -n "${name:-}" ] || continue case "$kind" in @@ -156,6 +174,38 @@ if [ -n "$local_lines" ]; then local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}" fi +# --------------------------------------------------------------------------- +# THE LEDGER FEED (#2791). The server keeps a shape ledger — every definition +# in the bound repo, classified against recorded canon — and this hook is the +# one place that sees a shape AT THE MOMENT IT IS WRITTEN. So it names the +# shapes in play: every definition in the payload, or — for an Edit that +# changes the inside of a function rather than its signature — the definition +# enclosing the edit, found by walking the target file upward from the edited +# lines. The server decides whether evidence exists (the session pulled a +# snippet this code references or resembles) and stamps instance rows; with +# no pulled canon in play, nothing is recorded. Titles only still — this sends +# names, not bodies. +# --------------------------------------------------------------------------- +shapes="$names" +if [ -z "$shapes" ] && [ -f "$file_path" ] && command -v tac >/dev/null 2>&1; then + old_first=$(printf '%s' "$event" \ + | jq -r '.tool_input.old_string // .tool_input.old_str // empty' 2>/dev/null \ + | grep -m1 -v '^[[:space:]]*$') || old_first="" + if [ -n "$old_first" ]; then + ln=$(grep -nF -m1 -- "$old_first" "$file_path" 2>/dev/null | cut -d: -f1) || ln="" + if [ -n "$ln" ]; then + shapes=$(head -n "$ln" "$file_path" | tac | scribe_defs | head -1) || shapes="" + fi + fi +fi +shapes_q="" +if [ -n "$shapes" ]; then + enc=$(printf '%s\n' "$shapes" \ + | awk -F'\t' 'NF>=2 {printf "%s%s:%s", (n++?",":""), $1, $2}' \ + | jq -sRr '@uri' 2>/dev/null) || enc="" + [ -n "$enc" ] && shapes_q="&shapes=${enc}" +fi + url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} # Guard against an unexpanded ${...} placeholder arriving as a literal. @@ -230,7 +280,7 @@ fi # finding that needed no instance to produce. body=$(curl -fsS --max-time 5 \ -H "Authorization: Bearer ${token}" \ - "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}" 2>/dev/null) || body="" + "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${shapes_q}" 2>/dev/null) || body="" context="" if [ -n "$body" ]; then diff --git a/plugin/skills/reusing-code/SKILL.md b/plugin/skills/reusing-code/SKILL.md index bfc9921..2a00166 100644 --- a/plugin/skills/reusing-code/SKILL.md +++ b/plugin/skills/reusing-code/SKILL.md @@ -34,6 +34,9 @@ through recall/auto-inject; this skill is the active reflex around that. to ask both at once. - If a snippet fits, pull it in full with `get_snippet(id)` and reuse it — its `location` points at the reference implementation. Adapt, don't re-derive. + The pull also does the accounting: the code you then write that references + or resembles it is stamped an `instance` of that canon in the shape ledger + (classified_by=hook) — reuse from memory leaves no row. - If auto-inject already surfaced a snippet title that looks relevant, that's your cue to `get_snippet` it rather than start from scratch. - **Prior art offered beside a write is not noise — read it.** When Scribe notes diff --git a/plugin/skills/shape-accounting/SKILL.md b/plugin/skills/shape-accounting/SKILL.md index 9c4b2e5..2fb419e 100644 --- a/plugin/skills/shape-accounting/SKILL.md +++ b/plugin/skills/shape-accounting/SKILL.md @@ -34,6 +34,44 @@ row carries a status: nothing. Rows, never prose — a consumer list in a note or verification detail cannot be sorted, queried, or diffed. +## Rows that arrive on their own + +Two feeds keep the ledger current between your batches, so most shapes never +need a hand judgment: + +- **The sync** stamps a snippet's own reference location `canonical` + (`classified_by: mechanical`). +- **The write path** stamps instances as you work: when you `get_snippet` a + canon and then Write/Edit code that references or resembles it, the + definitions being written land as `instance` rows (`classified_by: hook`, + the evidence in `reason`), and the prior-art hook tells you what landed + ("Shape accounting: recorded at … → instance of #N"). Offered-but-unopened + snippets stamp nothing — so *pull the canon you are instantiating*; that + pull is what turns your reuse into accounting. A hook row is evidence, not + judgment: it never overrides a classification you made, and a + `classify_shapes` call overrides it. + +## The machine proposes, judgment classifies + +Every coverage refresh runs the **mechanical proposer** over the unclassified +rows: same symbol as a canon elsewhere → textual containment → body +references a canon → signature resemblance → semantic (capped per refresh). A hit +is a *proposal* on the row, never a classification. Work the queue in bulk: + +1. `list_shapes(project_id, proposal="canon", snippet_id=N)` or + `path="dir"` — read the page; `proposal` carries snippet_id, basis, score. +2. `confirm_shape_proposals(project_id, snippet_id=N)` (or `path=`, + `basis=`) for the ones that hold — hundreds at a time; `symbol` and + `reference` proposals are near-certain, `semantic` deserves a look. +3. `classify_shapes` the rest — variant, exempt, or instance of a different + snippet. Any judgment retires the proposal. + +`list_shapes(project_id, proposal="derive")` lists the **derive-first +candidates** — the same body in ≥2 places or the same name defined in ≥3 +files, with no canon at all (`proposal.group` names the family; the coverage +payload's `derive_groups` ranks the biggest). That is the consolidation +queue, not a classification queue: see below. + ## The derive-first rule N same-shaped occurrences matching **no** recorded canon is never N loose @@ -42,6 +80,25 @@ the dominant form, `create_snippet` it, migrate the outliers, then classify the rest as instances. Canon is determined from the code; consistency comes from the derivation, not from asking permission. +## The divergence readout — button B where button A is canon + +Three questions the ledger answers mechanically (#2793): + +- **Divergence** — `list_shapes(project_id, flag="divergence")` (and the + coverage line's "N DIVERGENT"): a shape new since the previous refresh, in + a directory where one canon dominates the judged siblings, that the + proposer did not match to that canon. `diverges_from` names the canon. + Judge it: `instance` if it should be built from the canon (and rebuild + it), `variant` with the why if the departure is deliberate. The write-path + hook asks the same question in-band the moment such a shape is written. +- **History** — `shape_history(project_id, path, symbol?)`: the current rows + plus every `classified` / `vanished` / `reappeared` / `drifted` event with + its commit — "instance of #N from , re-judged variant of #M because + R, vanished at C". Rows, not recollection. +- **Recheck** — `list_shapes(project_id, flag="recheck")`: judged + instances/variants whose body changed since judged. The judgment stands; + re-confirm it (classify again with the same status) or re-judge. + ## What this buys Divergence becomes mechanical: when button B appears where button A is canon, diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index f0ee82c..149d12c 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -112,7 +112,7 @@ _READ_ONLY_TOOLS = frozenset({ "list_repo_bindings", # The shape ledger's todo query (#2789). Reads only — classify_shapes is # the write, and it is deliberately NOT here. - "list_shapes", + "list_shapes", "shape_history", }) # Read-SHAPED tools that must NOT be reachable with a read key — a getter that diff --git a/src/scribe/mcp/tools/shapes.py b/src/scribe/mcp/tools/shapes.py index 19ed37c..6fbebdd 100644 --- a/src/scribe/mcp/tools/shapes.py +++ b/src/scribe/mcp/tools/shapes.py @@ -62,6 +62,8 @@ async def list_shapes( include_vanished: bool = False, limit: int = 100, offset: int = 0, + proposal: str = "", + flag: str = "", ) -> dict: """Read a project's shape ledger — `status="unclassified"` IS the todo. @@ -75,22 +77,108 @@ async def list_shapes( snippet_id: rows classified against this snippet — a consumer map. include_vanished: include shapes no longer in the tree (history). limit/offset: page through big ledgers (limit caps at 500). + proposal: the proposer's queue (#2792) — "any", "canon" (rows the + machine thinks are an instance of a snippet: `proposal` carries + snippet_id, basis, score), "derive" (rows that repeat with NO + canon: `proposal.group` names the family), or one basis + (symbol/text/reference/signature/semantic). + flag: the divergence readout (#2793) — "divergence": shapes new + since the previous refresh in a directory where one canon + dominates the judged siblings and NOT proposed as that canon + (`diverges_from` names it: button B where button A is canon — + classify it: instance if it should use the canon, variant with + the why if deliberate); "recheck": judged instances/variants + whose body changed since judged (the judgment stands; confirm + it again with classify_shapes, or re-judge). Returns {"shapes": [...], "total": N} — total counts every match, not - just this page. Classify what you can judge with classify_shapes; a - repeating shape with NO recorded canon is a derive-one-first moment - (consolidate onto a reference, create_snippet it, then classify the - rest against it), never N loose classifications. + just this page. Each row's `classified_by` says who judged: agent / + audit / import are judgments; `mechanical` is the canonical stamp the + sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled + a snippet and then wrote code referencing/resembling it, so the shape + was stamped an instance with the evidence in `reason`. A hook row is + overridable by any classify_shapes call; it never overrides yours. + + THE FAST PATH through a big todo is the proposer's queue: every coverage + refresh matches unclassified shapes against canon (strongest basis + first: same symbol elsewhere → textual containment → body references + the canon → signature resemblance → semantic) and attaches a + `proposal` to each row it can speak for. Review `proposal="canon"` by + snippet or directory, then confirm_shape_proposals the ones that hold — + hundreds at a time — and classify_shapes the rest (variant/exempt, or + instance of a different snippet). `proposal="derive"` lists the + derive-first candidates: a repeating shape with NO recorded canon is + never N loose classifications — consolidate onto a reference, + create_snippet it, then classify the group against it. """ uid = current_user_id() rows, total = await shape_ledger_svc.list_project_shapes( uid, project_id, status=status, path=path, snippet_id=snippet_id, include_vanished=include_vanished, limit=limit, offset=offset, + proposal=proposal, flag=flag, ) return {"shapes": [r.to_dict() for r in rows], "total": total} +async def shape_history( + project_id: int, path: str, symbol: str = "", limit: int = 200 +) -> dict: + """What was used here, when, and why — a shape's (or a directory's) + history from the ledger (#2793). + + `shapes` are the current rows at `path` (a file, or a directory and + everything beneath it; `symbol` narrows to one definition) with + first/last-seen commits, vanished_at, and the standing judgment; + `events` are the state changes, oldest first: `classified` (status, + snippet_id, who, why — one per judgment, so a shape that was an instance + of #N and later a variant of #M shows both), `vanished`, `reappeared`, + `drifted` (the body moved under a judgment; see list_shapes flag= + "recheck"). Each event carries the commit the tree was read at. + + Read it as a timeline: "instance of #N from , + re-judged variant of #M at because , vanished at ". + Read-only; requires read access to the project. + """ + uid = current_user_id() + return await shape_ledger_svc.shape_history( + uid, project_id, path, symbol=symbol, limit=limit + ) + + +async def confirm_shape_proposals( + project_id: int, + snippet_id: int = 0, + path: str = "", + basis: str = "", + min_score: float = 0.0, +) -> dict: + """Confirm the proposer's canon proposals you have reviewed, in batch. + + The machine proposes, judgment classifies (#2792): each matching row — + live, unclassified, carrying a `proposal` with a snippet_id — becomes + `instance` of that snippet, classified_by="agent", reason naming the + basis and score. Narrow to what you actually looked at: at least one of + snippet_id (confirm one canon's whole queue after reading its + `list_shapes(proposal="canon", ...)` page), path (a directory you + audited), or basis (e.g. "symbol" and "reference" are near-certain; + "semantic" deserves a look first) is required — a bare confirm-all is + not a judgment. min_score trims a basis's tail. + + Proposals you do NOT confirm are judged with classify_shapes (variant, + exempt, or instance of a different snippet) — any judgment retires the + proposal. Requires write access. Returns {"confirmed": N}. + """ + uid = current_user_id() + try: + return await shape_ledger_svc.confirm_proposals( + uid, project_id, snippet_id=snippet_id, path=path, basis=basis, + min_score=min_score, + ) + except ValueError as exc: + return {"error": str(exc)} + + async def refresh_pattern_coverage(project_id: int) -> dict: """Seed or refresh the project's shape ledger NOW, and return the readout. @@ -107,9 +195,17 @@ async def refresh_pattern_coverage(project_id: int) -> dict: owner adds one (Settings → Integrations → Git Forges); no served repo → bind_repo on a host a connection serves. + The refresh is also when the mechanical proposer runs (#2792): with the + repo bodies in hand it matches every changed unclassified shape against + canon and records proposals (see list_shapes proposal=), then regroups + the derive-first candidates. Semantic matching is capped per refresh, so + a large ledger's queue grows across refreshes rather than in one. + Returns the accounting payload — total, accounted, counts by status, - unclassified, repos, largest_gaps — plus `pattern_coverage`, the same - one-line summary enter_project carries. + unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting + confirmation), `derive_groups` (the biggest repeats-with-no-canon + families), `proposer` (what this refresh examined) — plus + `pattern_coverage`, the same one-line summary enter_project carries. """ uid = current_user_id() coverage = await coverage_svc.refresh_for_caller(uid, project_id) @@ -120,5 +216,8 @@ async def refresh_pattern_coverage(project_id: int) -> dict: def register(mcp) -> None: - for fn in (classify_shapes, list_shapes, refresh_pattern_coverage): + for fn in ( + classify_shapes, list_shapes, refresh_pattern_coverage, + confirm_shape_proposals, shape_history, + ): mcp.tool(name=fn.__name__)(fn) diff --git a/src/scribe/models/__init__.py b/src/scribe/models/__init__.py index ae7309c..56ec114 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 # noqa: E402, F401 +from scribe.models.code_shape import CodeShape, CodeShapeEvent # 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 7999618..7cb1967 100644 --- a/src/scribe/models/code_shape.py +++ b/src/scribe/models/code_shape.py @@ -3,6 +3,7 @@ from datetime import datetime from sqlalchemy import ( BigInteger, DateTime, + Float, ForeignKey, Index, Integer, @@ -19,6 +20,12 @@ from scribe.models.base import TimestampMixin SHAPE_STATUSES = ("canonical", "instance", "variant", "exempt", "unclassified") SHAPE_CLASSIFIERS = ("agent", "audit", "hook", "mechanical", "import") +# How the mechanical proposer (#2792) arrived at a proposal, strongest first. +# `derive` is the odd one out: not "this is an instance of #N" but "this +# shape repeats with NO canon — derive one first" (note 2786's derive-first +# rule), so it carries a group key instead of a snippet. +PROPOSAL_BASES = ("symbol", "text", "reference", "signature", "semantic", "derive") + class CodeShape(Base, TimestampMixin): """One extracted code shape and its classification against canon (#2787). @@ -42,6 +49,29 @@ class CodeShape(Base, TimestampMixin): snippet_id is SET NULL on snippet deletion: the classification's target is gone but the judgment happened; the sync pass (step 2) re-files such rows as unclassified so they rejoin the todo instead of dangling. + + `signature` / `body_sha` (#2792) are the shape's content fingerprint — + its definition line and a whitespace/comment-insensitive hash of its + block — refreshed by every sync. They are what the mechanical proposer + matches on and what a later drift recheck compares against; the ledger + still never stores code bodies. + + `classified_sha` remembers the fingerprint a judgment was made at; + when a later sync sees the body change under an instance/variant, the + row is flagged `recheck_at` (the judgment stands, it just asks to be + confirmed again) and a `drifted` event is written. `diverges_from` + (#2793) is the button-B flag: a shape new since the previous refresh, in + a directory+kind where one canon dominates the judged siblings, that the + proposer did not match to that canon — "button B appeared where button + A is canon: divergence or variant? classify it." Both clear on judgment. + + The proposal columns hold the proposer's standing suggestion for an + UNCLASSIFIED row: `proposed_snippet_id` + `proposal_basis` + score for + "looks like an instance of #N", or `proposal_basis="derive"` + + `proposal_group` for "repeats with no canon". `proposed_sha` is the + body_sha the row was last examined at, so a refresh re-examines only + what changed. A judgment clears the proposal — the machine proposes, + judgment classifies. """ __tablename__ = "code_shapes" @@ -52,6 +82,8 @@ class CodeShape(Base, TimestampMixin): ), Index("ix_code_shapes_project_status", "project_id", "status"), Index("ix_code_shapes_snippet", "snippet_id"), + Index("ix_code_shapes_proposed", "project_id", "proposed_snippet_id"), + Index("ix_code_shapes_diverges", "project_id", "diverges_from"), ) id: Mapped[int] = mapped_column(primary_key=True) @@ -76,6 +108,38 @@ class CodeShape(Base, TimestampMixin): vanished_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) + signature: Mapped[str] = mapped_column(Text, default="") + body_sha: Mapped[str] = mapped_column(Text, default="") + proposed_snippet_id: Mapped[int | None] = mapped_column( + BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True + ) + proposal_basis: Mapped[str | None] = mapped_column(Text, nullable=True) + proposal_score: Mapped[float | None] = mapped_column(Float, nullable=True) + proposal_group: Mapped[str | None] = mapped_column(Text, nullable=True) + proposed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + proposed_sha: Mapped[str] = mapped_column(Text, default="") + classified_sha: Mapped[str] = mapped_column(Text, default="") + recheck_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + diverges_from: Mapped[int | None] = mapped_column( + BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True + ) + + @property + def proposal(self) -> dict | None: + """The standing proposal as one object, or None when the proposer + has nothing to say about this row.""" + if self.proposed_snippet_id is None and not self.proposal_group: + return None + out: dict = {"basis": self.proposal_basis, "score": self.proposal_score} + if self.proposed_snippet_id is not None: + out["snippet_id"] = self.proposed_snippet_id + if self.proposal_group: + out["group"] = self.proposal_group + return out def to_dict(self) -> dict: return { @@ -93,6 +157,69 @@ class CodeShape(Base, TimestampMixin): "first_seen_commit": self.first_seen_commit, "last_seen_commit": self.last_seen_commit, "vanished_at": self.vanished_at.isoformat() if self.vanished_at else None, + "signature": self.signature, + "body_sha": self.body_sha, + "proposal": self.proposal, + "classified_sha": self.classified_sha, + "recheck_at": self.recheck_at.isoformat() if self.recheck_at else None, + "diverges_from": self.diverges_from, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } + + +# 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") + + +class CodeShapeEvent(Base): + """One state change in a shape's life — the what-was-used-when record. + + "We used #N here from , #M replaced it at commit C, reason R" is a + question the ledger row alone cannot answer once it has moved on; this + table keeps each judgment (status, snippet, who, why, at which commit) + and each presence change (vanished / reappeared / drifted) as it + happened. Denormalised path/symbol/kind so a directory's history reads + without joining; `snippet_id` is deliberately FK-free — history outlives + the snippet it names, which is the point. + """ + + __tablename__ = "code_shape_events" + __table_args__ = ( + Index("ix_code_shape_events_shape", "shape_id", "at"), + Index("ix_code_shape_events_project_path", "project_id", "path"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + shape_id: Mapped[int] = mapped_column( + Integer, ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False + ) + project_id: Mapped[int] = mapped_column(Integer, nullable=False) + path: Mapped[str] = mapped_column(Text, nullable=False) + symbol: Mapped[str] = mapped_column(Text, nullable=False) + kind: Mapped[str] = mapped_column(Text, nullable=False) + event: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str | None] = mapped_column(Text, nullable=True) + snippet_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + classified_by: Mapped[str | None] = mapped_column(Text, nullable=True) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + commit: Mapped[str] = mapped_column(Text, default="") + at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + def to_dict(self) -> dict: + return { + "id": self.id, + "shape_id": self.shape_id, + "project_id": self.project_id, + "path": self.path, + "symbol": self.symbol, + "kind": self.kind, + "event": self.event, + "status": self.status, + "snippet_id": self.snippet_id, + "classified_by": self.classified_by, + "reason": self.reason, + "commit": self.commit, + "at": self.at.isoformat(), + } diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py index 1ecdc13..c66d2e0 100644 --- a/src/scribe/routes/plugin.py +++ b/src/scribe/routes/plugin.py @@ -127,6 +127,15 @@ async def write_path_prior_art(): surfaced. A separate channel on purpose: a reuse hint shown early must not suppress the record-sync nudge when the recorded file is edited later. + shapes (opt) — comma-separated `kind:name` definitions the hook + found in (or enclosing) the payload, kind being + css|sym. The shape ledger's write-path feed + (#2791): when the session recently PULLED a + snippet this payload references or resembles, + these land as instance rows (classified_by=hook). + Honoured only for a caller allowed to write — a + read-scoped key still gets the hint, and never + changes accounting on a GET. """ path = (request.args.get("path") or "").strip() code = request.args.get("code") or "" @@ -149,14 +158,40 @@ async def write_path_prior_art(): int(p) for p in (request.args.get("exclude_sync_ids") or "").split(",") if p.strip().isdigit() ] + shapes = _parse_shapes(request.args.get("shapes") or "") + api_key = getattr(g, "api_key", None) + may_stamp = api_key is None or getattr(api_key, "scope", "") == "write" result = await plugin_ctx_svc.build_write_path_hint( g.user.id, path, code=code, project_id=project_id, exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids, + stamp_shapes=shapes if may_stamp else None, + repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "", ) return jsonify(result) +# The hook names at most a dozen definitions per write; anything past that is +# a generated file, not a shape being instantiated. +_SHAPES_CAP = 12 + + +def _parse_shapes(raw: str) -> list[tuple[str, str]]: + """`css:btn-primary,sym:onTrash` → [("css", "btn-primary"), ("sym", "onTrash")]. + Unknown kinds and empty names are dropped, duplicates collapse, and the + list is capped — the hook's own cap, re-applied so the contract holds + for any caller.""" + out: list[tuple[str, str]] = [] + for part in raw.split(","): + kind, _sep, name = part.strip().partition(":") + kind, name = kind.strip(), name.strip() + if kind in ("css", "sym") and name and (kind, name) not in out: + out.append((kind, name)) + if len(out) >= _SHAPES_CAP: + break + return out + + @plugin_bp.get("/processes") @login_required async def process_manifest(): diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py index 97edde3..5929c23 100644 --- a/src/scribe/services/backup.py +++ b/src/scribe/services/backup.py @@ -11,7 +11,7 @@ from scribe.models.note_supersession import NoteSupersession from scribe.models.note_version import NoteVersion from scribe.models.design_system import DesignSystem, DesignToken from scribe.models.note_usage import NoteUsageEvent -from scribe.models.code_shape import CodeShape +from scribe.models.code_shape import CodeShape, CodeShapeEvent from scribe.models.project import Project from scribe.models.repo_binding import RepoBinding from scribe.models.rulebook import ( @@ -40,8 +40,10 @@ logger = logging.getLogger(__name__) # v7 (2026-08) added code_shapes — the shape ledger (#2787). Classifications # are judgment data worth carrying; a restore keeps a judgment only when its # snippet target survives the id re-mapping, else the row rejoins the todo. +# v8 (2026-08) added code_shape_events — the ledger's history (#2793): what +# was used where, when, and why is not recomputable, so it travels. # Bump when the serialized schema changes. -BACKUP_VERSION = 7 +BACKUP_VERSION = 8 # Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED # below, these two lists must together account for the entire schema — which is @@ -59,8 +61,8 @@ _BACKED_UP = [ # v5 (2026-08): the five-year gap this list was written to stop. "systems", "record_systems", "design_systems", "design_tokens", "note_usage_events", "repo_bindings", "note_supersessions", - # v7 (2026-08): the shape ledger (#2787). - "code_shapes", + # v7 (2026-08): the shape ledger (#2787); v8: its history (#2793). + "code_shapes", "code_shape_events", ] # Tables intentionally NOT in the backup, surfaced in the payload so the gap is @@ -176,6 +178,10 @@ def _code_shape_rows(rows) -> list[dict]: return [r.to_dict() for r in rows] +def _code_shape_event_rows(rows) -> list[dict]: + return [r.to_dict() for r in rows] + + def _repo_binding_rows(rows) -> list[dict]: return [ {"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key} @@ -216,6 +222,9 @@ async def export_full_backup() -> dict: usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all() repo_bindings = (await session.execute(select(RepoBinding))).scalars().all() code_shapes = (await session.execute(select(CodeShape))).scalars().all() + code_shape_events = (await session.execute( + select(CodeShapeEvent).order_by(CodeShapeEvent.at, CodeShapeEvent.id) + )).scalars().all() rulebooks = (await session.execute(select(Rulebook))).scalars().all() topics = (await session.execute(select(RulebookTopic))).scalars().all() rules = (await session.execute(select(Rule))).scalars().all() @@ -391,6 +400,7 @@ async def export_full_backup() -> dict: "repo_bindings": _repo_binding_rows(repo_bindings), "note_supersessions": _note_supersession_rows(supersessions), "code_shapes": _code_shape_rows(code_shapes), + "code_shape_events": _code_shape_event_rows(code_shape_events), } @@ -463,6 +473,10 @@ async def export_user_backup(user_id: int) -> dict: code_shapes = (await session.execute( select(CodeShape).where(CodeShape.project_id.in_(project_ids)) )).scalars().all() if project_ids else [] + code_shape_events = (await session.execute( + select(CodeShapeEvent).where(CodeShapeEvent.project_id.in_(project_ids)) + .order_by(CodeShapeEvent.at, CodeShapeEvent.id) + )).scalars().all() if project_ids else [] rulebooks = (await session.execute( select(Rulebook).where(Rulebook.owner_user_id == user_id) )).scalars().all() @@ -652,6 +666,7 @@ async def export_user_backup(user_id: int) -> dict: "repo_bindings": _repo_binding_rows(repo_bindings), "note_supersessions": _note_supersession_rows(supersessions), "code_shapes": _code_shape_rows(code_shapes), + "code_shape_events": _code_shape_event_rows(code_shape_events), } @@ -755,7 +770,7 @@ async def _restore_v2(data: dict) -> dict: "topic_suppressions": 0, "systems": 0, "record_systems": 0, "design_systems": 0, "design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0, - "note_supersessions": 0, "code_shapes": 0, + "note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0, } async with async_session() as session: @@ -1137,6 +1152,7 @@ async def _restore_v2(data: dict) -> dict: # survive the re-mapping (canonical/instance/variant with a gone # snippet) is downgraded to unclassified so it rejoins the todo # honestly instead of dangling; exempt needs no target and keeps. + shape_id_map: dict[int, int] = {} for cs_data in data.get("code_shapes", []): mapped_pid = project_id_map.get(cs_data.get("project_id", 0)) if mapped_pid is None: @@ -1149,7 +1165,7 @@ async def _restore_v2(data: dict) -> dict: status = "unclassified" classified_by = None classified_at = None - session.add(CodeShape( + shape = CodeShape( project_id=mapped_pid, repo_key=cs_data.get("repo_key", ""), path=cs_data.get("path", ""), @@ -1163,11 +1179,46 @@ async def _restore_v2(data: dict) -> dict: first_seen_commit=cs_data.get("first_seen_commit", ""), last_seen_commit=cs_data.get("last_seen_commit", ""), vanished_at=_dt(cs_data["vanished_at"]) if cs_data.get("vanished_at") else None, + # Fingerprints restore; proposals (#2792) deliberately do not — + # they are mechanical, and the next refresh recomputes them + # against the restored snippet ids. + signature=cs_data.get("signature", ""), + body_sha=cs_data.get("body_sha", ""), + classified_sha=cs_data.get("classified_sha", ""), created_at=_dt(cs_data.get("created_at")), updated_at=_dt(cs_data.get("updated_at")), - )) + ) + session.add(shape) + await session.flush() + if cs_data.get("id"): + shape_id_map[int(cs_data["id"])] = shape.id stats["code_shapes"] += 1 + # v8: the ledger's history rides its shapes. snippet_id is kept as + # the history's own claim (FK-free by design) but re-mapped when the + # snippet survived, so a restored timeline points at restored records. + for ev in data.get("code_shape_events", []): + new_shape_id = shape_id_map.get(ev.get("shape_id") or 0) + mapped_pid = project_id_map.get(ev.get("project_id", 0)) + if new_shape_id is None or mapped_pid is None: + continue + old_sid = ev.get("snippet_id") + session.add(CodeShapeEvent( + shape_id=new_shape_id, + project_id=mapped_pid, + path=ev.get("path", ""), + symbol=ev.get("symbol", ""), + kind=ev.get("kind", "sym"), + event=ev.get("event", "classified"), + status=ev.get("status"), + snippet_id=note_id_map.get(old_sid, old_sid) if old_sid else None, + classified_by=ev.get("classified_by"), + reason=ev.get("reason"), + commit=ev.get("commit", ""), + at=_dt(ev.get("at")), + )) + stats["code_shape_events"] += 1 + await session.commit() logger.info("Restored v2/v3 backup: %s", stats) diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index 04b13c0..2b9217a 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -25,12 +25,14 @@ only ever reads the cache. """ from __future__ import annotations +import hashlib import io import json import logging import posixpath import re import tarfile +from typing import NamedTuple from datetime import datetime, timedelta, timezone from scribe.services.forge import ForgeSelector, get_forges @@ -91,6 +93,104 @@ _ARROW_RE = re.compile( ) +class Definition(NamedTuple): + """One extracted definition with its content fingerprint (#2792). + + `signature` is the definition line itself; `body_sha` hashes the block + whitespace- and comment-insensitively; `body` is the block's text, held + only for the duration of a refresh (the proposer matches on it) and + never stored. + """ + + kind: str + name: str + signature: str + body_sha: str + body: str + + +def _definition_on(raw: str) -> tuple[str, str] | None: + """The (kind, name) this one line defines, or None. First match wins — + the same order the hook's awk program tries.""" + m = _CSS_RE.match(raw) + if m: + return ("css", m.group(1)) + line = _MODIFIERS_RE.sub("", raw.lstrip()) + if m := _GO_METHOD_RE.match(line): + return ("sym", m.group(1)) + if m := _KEYWORD_RE.match(line): + name = m.group(1) + if name.startswith("__") and name.endswith("__"): + return None + return ("sym", name) + if m := _ARROW_RE.match(line): + return ("sym", m.group(1)) + return None + + +# A definition's block runs from its line until the next non-blank line at +# its own indentation or shallower that is not a closer — so a Python def ends +# at the next top-level statement, a braces block keeps its `}`, a CSS rule +# keeps its `}`. Capped so a generated monolith can't make one shape's +# fingerprint cover the file. +_BLOCK_CAP = 120 +_CLOSERS = ("}", ")", "]", "end", "};", "});", ");", "})", "]);") +# Lines that don't change what a shape IS: comments and decorators. Dropped +# from the fingerprint so touching a comment above the next function doesn't +# read as this one's body changing. +_NOISE_PREFIXES = ("#", "//", "/*", "*", "*/", "@", "") +_SIGNATURE_CAP = 300 + + +def _indent(line: str) -> int: + return len(line) - len(line.lstrip()) + + +def _block_sha(lines: list[str]) -> str: + kept = [ + " ".join(ln.split()) + for ln in lines + if ln.strip() and not ln.lstrip().startswith(_NOISE_PREFIXES) + ] + return hashlib.sha1("\n".join(kept).encode("utf-8")).hexdigest()[:16] + + +def extract_definitions(text: str) -> list[Definition]: + """Every definition this text makes, with signature + fingerprint. + + Duplicate (kind, name) within one text collapse to the first — the + ledger's identity is per file, so a second definition of the same name + (an overload, a re-declaration) is the same shape to it. + """ + lines = text.splitlines() + starts: list[tuple[int, str, str]] = [] + for i, raw in enumerate(lines): + hit = _definition_on(raw) + if hit: + starts.append((i, hit[0], hit[1])) + seen: set[tuple[str, str]] = set() + out: list[Definition] = [] + for i, kind, name in starts: + if (kind, name) in seen: + continue + seen.add((kind, name)) + base = _indent(lines[i]) + end = min(len(lines), i + _BLOCK_CAP) + for j in range(i + 1, min(len(lines), i + _BLOCK_CAP)): + ln = lines[j] + if not ln.strip(): + continue + if _indent(ln) <= base and ln.strip() not in _CLOSERS: + end = j + break + block = lines[i:end] + out.append(Definition( + kind, name, lines[i].strip()[:_SIGNATURE_CAP], _block_sha(block), + "\n".join(block), + )) + return out + + def extract_shapes(text: str) -> list[tuple[str, str]]: """Every (kind, name) this text DEFINES — kind is "css" or "sym". @@ -98,29 +198,7 @@ def extract_shapes(text: str) -> list[tuple[str, str]]: line, dunders are skipped (every class defines __init__ — guaranteed noise), duplicates within one text count once. """ - seen: set[tuple[str, str]] = set() - out: list[tuple[str, str]] = [] - for raw in text.splitlines(): - m = _CSS_RE.match(raw) - if m: - shape = ("css", m.group(1)) - else: - line = _MODIFIERS_RE.sub("", raw.lstrip()) - if m := _GO_METHOD_RE.match(line): - shape = ("sym", m.group(1)) - elif m := _KEYWORD_RE.match(line): - name = m.group(1) - if name.startswith("__") and name.endswith("__"): - continue - shape = ("sym", name) - elif m := _ARROW_RE.match(line): - shape = ("sym", m.group(1)) - else: - continue - if shape not in seen: - seen.add(shape) - out.append(shape) - return out + return [(d.kind, d.name) for d in extract_definitions(text)] def scannable(path: str) -> bool: @@ -131,14 +209,33 @@ def scannable(path: str) -> bool: return not path.lower().endswith(_SKIP_SUFFIXES) +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 + ledger identity; the rest is the fingerprint and the transient body.""" + + path: str + kind: str + name: str + signature: str + body_sha: str + body: str + + def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]: - """(path, kind, name) for every definition in a repo tarball. + """(path, kind, name) for every definition in a repo tarball — the + identity view of definitions_from_archive.""" + return [(d.path, d.kind, d.name) for d in definitions_from_archive(blob)] + + +def definitions_from_archive(blob: bytes) -> list[ArchiveShape]: + """Every definition in a repo tarball, with its fingerprint and body. Forge archives wrap content in a single top-level directory (repo-ref/); that component is stripped so paths match recorded snippet locations, which are repo-relative. Non-UTF-8 files are binaries and skipped. """ - shapes: list[tuple[str, str, str]] = [] + shapes: list[ArchiveShape] = [] with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar: for member in tar: if not member.isfile() or "/" not in member.name: @@ -153,7 +250,10 @@ def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]: text = handle.read().decode("utf-8") except UnicodeDecodeError: continue - shapes.extend((path, kind, name) for kind, name in extract_shapes(text)) + shapes.extend( + ArchiveShape(path, d.kind, d.name, d.signature, d.body_sha, d.body) + for d in extract_definitions(text) + ) return shapes @@ -253,13 +353,17 @@ async def compute_coverage( served: list[tuple[str, str]] = [] recorded = await _recorded_locations(user_id, project_id) + # The proposer's canon catalog, read once per refresh and shared across + # the project's repos (#2792). + canons = None + proposer_stats = {"examined": 0, "proposed": 0, "semantic_checked": 0} for key in await keys_for_project(user_id, project_id): hit = selector.resolve(key) if hit is None: continue # bound to a host no connection serves forge, api_repo = hit ref = await forge.default_branch(api_repo) - shapes = shapes_from_archive(await forge.archive(api_repo, ref)) + definitions = definitions_from_archive(await forge.archive(api_repo, ref)) # 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. @@ -268,13 +372,43 @@ async def compute_coverage( except ForgeError: marker = ref await shape_ledger.sync_repo_shapes( - project_id, key, shapes, seen_marker=marker + project_id, key, definitions, seen_marker=marker ) served.append((key, ref)) + # 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 + # is immaterial; the proposer must not be able to fail the refresh. + try: + if canons is None: + canons = await shape_ledger.canon_catalog(user_id) + stats = await shape_ledger.propose_for_repo( + user_id, project_id, key, definitions, canons=canons + ) + for k in proposer_stats: + proposer_stats[k] += stats.get(k, 0) + except Exception: + logger.warning("shape proposer failed for %s", key, exc_info=True) if not served: return None await shape_ledger.mark_canonicals(project_id, recorded) + try: + await shape_ledger.apply_derive_groups(project_id) + except Exception: + logger.warning("derive-first grouping failed", exc_info=True) + # The button-B pass (#2793): shapes new since the PREVIOUS computation, + # where a canon dominates. The previous computation's stamp is the cache; + # a first seed has none, so it flags nothing (everything is new then). + try: + previous = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}") + since = None + if previous: + stamp = (json.loads(previous) or {}).get("computed_at") + since = datetime.fromisoformat(stamp) if stamp else None + await shape_ledger.flag_divergence(project_id, since=since) + except Exception: + logger.warning("divergence pass failed", exc_info=True) # Project-wide readout, deliberately wider than this walk: a second bound # repo that was unreachable today still has live rows, and they count. @@ -290,11 +424,23 @@ async def compute_coverage( agg["accounted"] += row.status != "unclassified" unclassified = counts.pop("unclassified") + proposals = shape_ledger.proposal_summary(rows) + divergence = shape_ledger.divergence_summary(rows) return { "total": len(rows), "accounted": len(rows) - unclassified, "unclassified": unclassified, "counts": counts, + # The proposer's standing (#2792): canon proposals awaiting a + # confirm, the largest derive-first groups, and what this refresh did. + "proposed": proposals["proposed"], + "derive_groups": proposals["derive_groups"], + "proposer": proposer_stats, + # The divergence readout (#2793): button B where button A is canon, + # and judged shapes whose bodies moved since they were judged. + "divergent": divergence["divergent"], + "divergence": divergence["divergence"], + "recheck": divergence["recheck"], # Honesty flag, not decoration: every surface that shows the number # is expected to carry it through. "estimate": True, @@ -438,7 +584,19 @@ def coverage_line(coverage: dict) -> str: unclassified = coverage.get("unclassified", 0) if unclassified: line += f"; {unclassified} unclassified" + standing = [] + if coverage.get("proposed"): + standing.append(f"{coverage['proposed']} proposed") + n_groups = len(coverage.get("derive_groups") or []) + if n_groups: + standing.append(f"{n_groups} derive group{'s' if n_groups != 1 else ''}") + if coverage.get("divergent"): + standing.append(f"{coverage['divergent']} DIVERGENT") + if standing: + line += f" ({', '.join(standing)})" gaps = [g["dir"] for g in coverage.get("largest_gaps") or []] if gaps: line += ", largest: " + ", ".join(gaps) + if coverage.get("recheck"): + line += f"; {coverage['recheck']} judged shape{'s' if coverage['recheck'] != 1 else ''} changed since judged — recheck" return line diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index 0cb08e9..5629e02 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -27,6 +27,7 @@ from scribe.services import knowledge as knowledge_svc from scribe.services import notes as notes_svc from scribe.services import projects as projects_svc from scribe.services import rulebooks as rulebooks_svc +from scribe.services import shape_ledger as shape_ledger_svc from scribe.services import snippets as snippets_svc from scribe.services.access import label_shared_items, owner_names_for from scribe.services.embeddings import semantic_search_notes @@ -703,6 +704,8 @@ async def build_write_path_hint( project_id: int = 0, exclude_ids: list[int] | None = None, exclude_sync_ids: list[int] | None = None, + stamp_shapes: list[tuple[str, str]] | None = None, + repo_key: str = "", ) -> dict: """Prior-art hint for the plugin's PreToolUse hook on Write/Edit. @@ -749,9 +752,20 @@ async def build_write_path_hint( un-scored surfacing now has its own home: every arm emits note_usage_events, tagged 'write_path_sync' vs 'write_path_place' vs 'write_path_semantic', so each claim's pull-through rate is measurable on its own. + + ``stamp_shapes`` turns the same request into the ledger's write-path feed + (#2791): the (kind, name) definitions the hook saw in — or enclosing — + the payload. When the session has PULLED a snippet recently and this + payload references or resembles it, those shapes land as `instance` rows + (classified_by=hook, see shape_ledger.stamp_write_path_instances) and + the result's ``stamped`` lists them. The route passes it only for a + caller allowed to write — a read-scoped key gets the hint, never the + stamp. ``repo_key`` (the hook's remote, normalised) homes a provisional + row for a shape the ledger has not synced yet. """ cfg = await get_writepath_config(user_id) - empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg} + empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg, + "stamped": [], "divergence": []} path = (path or "").strip() if not cfg["enabled"] or not path: return empty @@ -800,6 +814,15 @@ async def build_write_path_hint( seen.add(nid) placed.append(("nearby", item)) + # The stamping feed's "actually pulled it" half (#2791). Read once, before + # the semantic arm, because the arm's query doubles as the resemblance + # test: a pulled snippet this session already saw (so it sits in `seen`) + # must still be SCORED for this payload — it just isn't re-listed. + pulled: dict = {} + if stamp_shapes: + pulled = await shape_ledger_svc.recent_pulls(user_id) + resembles: dict[int, float] = {} + # --- arm 2: by meaning --- scored: list[tuple[str, dict]] = [] remaining = top_k - len(synced) - len(placed) @@ -821,12 +844,15 @@ async def build_write_path_hint( query = concept_query(query) or query if remaining > 0 and query: t0 = time.perf_counter() + # Pulled-and-seen ids stay in the query (as evidence) but never in + # the menu — the dedup contract holds, the resemblance still lands. + pulled_seen = seen & set(pulled) hits = await semantic_search_notes( user_id, query, - limit=remaining, + limit=remaining + len(pulled_seen), threshold=cfg["threshold"], project_id=scope_project, - exclude_ids=seen, + exclude_ids=seen - pulled_seen, # Snippets AND recorded experience (#2246). This arm was # snippets-only, which is auto-inject's mistake inverted: an issue # saying "we tried this and it deadlocked", or a dev-log recording @@ -845,6 +871,11 @@ async def build_write_path_hint( # the browse scope and never surfaces a one-to-one direct share. scope="browse", ) + resembles = { + int(note.id): float(score) for score, note in hits + if int(note.id) in pulled + } + hits = [(s, n) for s, n in hits if int(n.id) not in seen][:remaining] record_retrieval( user_id=user_id, source="write_path", query=query, threshold=cfg["threshold"], limit=remaining, @@ -879,7 +910,32 @@ async def build_write_path_hint( )) menu = (placed + scored)[:max(0, top_k - len(synced))] - if not synced and not menu: + + # The stamp runs whether or not anything is rendered — after dedup, the + # common case is a silent hint and a pulled canon being instantiated. + stamped: list[dict] = [] + if stamp_shapes and pulled: + try: + stamped = await shape_ledger_svc.stamp_write_path_instances( + user_id, project_id, path=path, shapes=stamp_shapes, + code=code or "", pulled=pulled, resembles=resembles, + repo_key=repo_key, + ) + except Exception: + logger.warning("Write-path ledger stamping failed", exc_info=True) + # The in-band button-B check (#2793): the hook named the shapes being + # written; if this directory+kind is canon-dense and a named shape isn't + # (about to be) an instance of that canon, say so NOW — at the write, + # not at the next audit. + divergence: list[dict] = [] + if stamp_shapes and project_id: + try: + divergence = await shape_ledger_svc.write_time_divergence( + project_id, path, stamp_shapes, stamped + ) + except Exception: + logger.warning("write-time divergence check failed", exc_info=True) + if not synced and not menu and not stamped and not divergence: return empty owners = await owner_names_for({ @@ -943,6 +999,11 @@ async def build_write_path_hint( note_ids.append(int(item["id"])) lines.append(_prior_art_line(item, marker, owner, foreign_lang)) + if stamped: + lines.append(_stamp_line(path, stamped)) + if divergence: + lines.append(_divergence_line(path, divergence)) + # Split by arm, which is the whole reason this table exists. The place arm # carries no score and so has no home in retrieval_logs; before #2085 a # snippet surfaced BY PLACE left no trace anywhere, making the arm that @@ -964,9 +1025,46 @@ async def build_write_path_hint( "note_ids": note_ids, "sync_note_ids": sync_note_ids, "config": cfg, + "stamped": stamped, + "divergence": divergence, } +def _divergence_line(path: str, divergence: list[dict]) -> str: + """Button B where button A is canon — named at the write (#2793).""" + parts = [ + f"`{('.' if d['kind'] == 'css' else '') + d['symbol']}` → #{d['canon_snippet_id']} " + f"({d['instances']} of {d['judged']} judged siblings are its instances)" + for d in divergence + ] + return ( + f"> Divergence check at `{path}`: a canon dominates this directory — " + f"{'; '.join(parts)}. If this is a new instance, pull that snippet " + "and build from it; if it is a deliberate departure, " + "`classify_shapes(..., status=\"variant\", reason=…)` records the why; " + "otherwise it reads as unintended divergence." + ) + + +def _stamp_line(path: str, stamped: list[dict]) -> str: + """One line saying what the ledger just recorded, so the session can + correct a wrong stamp in the moment rather than an audit finding it.""" + by_snippet: dict[int, list[str]] = {} + for row in stamped: + label = f".{row['symbol']}" if row["kind"] == "css" else row["symbol"] + by_snippet.setdefault(int(row["snippet_id"]), []).append(f"`{label}`") + parts = [ + f"{', '.join(names)} → instance of #{sid}" + for sid, names in by_snippet.items() + ] + return ( + f"> Shape accounting: recorded at `{path}` — {'; '.join(parts)} " + "(classified_by=hook: you pulled that snippet this session and this " + "code references/resembles it). Not an instance? `classify_shapes` " + "overrides a hook stamp." + ) + + async def _topic_titles(topic_ids: set[int]) -> dict[int, str]: """Map topic_id -> title for the given ids (live topics only).""" if not topic_ids: diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index d1fcbea..f0ccdcf 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -21,12 +21,18 @@ an extracted shape. """ from __future__ import annotations -from datetime import datetime, timezone +import difflib +import logging +import re +from datetime import datetime, timedelta, timezone +from typing import Iterable, NamedTuple from sqlalchemy import select from scribe.models import async_session -from scribe.models.code_shape import CodeShape +from scribe.models.code_shape import CodeShape, CodeShapeEvent + +logger = logging.getLogger(__name__) # Statuses whose meaning requires a snippet target. _NEEDS_TARGET = ("canonical", "instance", "variant") @@ -61,15 +67,17 @@ def location_covers(loc_path: str, loc_symbol: str, path: str, name: str) -> boo async def sync_repo_shapes( project_id: int, repo_key: str, - shapes: list[tuple[str, str, str]], + shapes: list, *, seen_marker: str, ) -> None: - """Upsert one repo's extracted (path, kind, name) shapes into the ledger. + """Upsert one repo's extracted shapes into the ledger. - ``seen_marker`` is the commit the archive was read at when the forge can - say, else the ref name — provenance sugar; the row timestamps carry the - when. + ``shapes`` are (path, kind, name) triples, or the richer ArchiveShape + records (#2792) whose 4th/5th fields — signature, body_sha — refresh the + row's content fingerprint. ``seen_marker`` is the commit the archive was + read at when the forge can say, else the ref name — provenance sugar; + the row timestamps carry the when. """ now = datetime.now(timezone.utc) async with async_session() as session: @@ -83,7 +91,10 @@ async def sync_repo_shapes( ).scalars().all() by_key = {(r.path, r.symbol, r.kind): r for r in rows} seen: set[tuple[str, str, str]] = set() - for path, kind, name in shapes: + for shape in shapes: + path, kind, name = shape[0], shape[1], shape[2] + signature = shape[3] if len(shape) > 3 else "" + body_sha = shape[4] if len(shape) > 4 else "" key = (path, name, kind) if key in seen: continue @@ -94,12 +105,31 @@ async def sync_repo_shapes( project_id=project_id, repo_key=repo_key, path=path, symbol=name, kind=kind, first_seen_commit=seen_marker, last_seen_commit=seen_marker, + signature=signature, body_sha=body_sha, )) continue row.last_seen_commit = seen_marker + if signature: + row.signature = signature + if body_sha and body_sha != row.body_sha: + judged = row.status in ("instance", "variant") + if judged and not row.classified_sha: + # Judged before fingerprints existed: the first sync + # that sees a body adopts it as the judged content. + row.classified_sha = body_sha + elif judged and row.classified_sha != body_sha and row.recheck_at is None: + # The body moved under a standing judgment: the judgment + # stands, but asks to be confirmed again (#2793). + row.recheck_at = now + session.add(_event(row, "drifted", now, commit=seen_marker)) + row.body_sha = body_sha + elif row.status in ("instance", "variant") and not row.classified_sha: + row.classified_sha = row.body_sha # A shape that vanished and came back is live again — the vanish # stays visible in history via updated_at, not as a dead flag. - row.vanished_at = None + if row.vanished_at is not None: + row.vanished_at = None + session.add(_event(row, "reappeared", now, commit=seen_marker)) if row.status in _NEEDS_TARGET and row.snippet_id is None: row.status = "unclassified" row.classified_by = None @@ -108,9 +138,50 @@ async def sync_repo_shapes( for key, row in by_key.items(): if key not in seen and row.vanished_at is None: row.vanished_at = now + session.add(_event(row, "vanished", now, commit=row.last_seen_commit)) await session.commit() +def _event(row: CodeShape, event: str, at: datetime, *, commit: str = "") -> CodeShapeEvent: + """A history row for a state change on ``row`` — status/snippet/by/reason + are the row's CURRENT values, which for `classified` is the judgment + just made and for presence events is the standing one.""" + return CodeShapeEvent( + shape_id=row.id, project_id=row.project_id, + path=row.path, symbol=row.symbol, kind=row.kind, + event=event, status=row.status, snippet_id=row.snippet_id, + classified_by=row.classified_by, reason=row.reason, + commit=commit or row.last_seen_commit or "", at=at, + ) + + +async def _judge( + session, row: CodeShape, *, status: str, snippet_id: int | None, + by: str | None, reason: str | None, at: datetime, +) -> None: + """Apply a judgment to a row — the ONE place a status is set — and write + its history. Clears what a judgment settles: the standing proposal, the + recheck ask, the divergence flag; remembers the fingerprint judged. + `unclassified` is the withdrawal: fields clear, the examination is + forgotten so the proposer looks again, and history records the + withdrawal too.""" + row.status = status + row.snippet_id = snippet_id if status in _NEEDS_TARGET else None + row.reason = (reason or "").strip() or None + row.classified_by = by if status != "unclassified" else None + row.classified_at = at if status != "unclassified" else None + row.classified_sha = row.body_sha if status != "unclassified" else "" + row.recheck_at = None + row.diverges_from = None + _clear_proposal(row, reexamine=(status == "unclassified")) + if row.id is None: + # A provisional row (hook stamp on a shape not yet synced): flush so + # the event can point at it. + session.add(row) + await session.flush() + session.add(_event(row, "classified", at)) + + async def mark_canonicals( project_id: int, recorded: list[tuple[int, str, str]] ) -> None: @@ -143,19 +214,15 @@ async def mark_canonicals( None, ) if covering is not None and row.status == "unclassified": - row.status = "canonical" - row.snippet_id = covering - row.classified_by = "mechanical" - row.classified_at = now + await _judge(session, row, status="canonical", snippet_id=covering, + by="mechanical", reason=None, at=now) elif ( covering is None and row.status == "canonical" and row.classified_by == "mechanical" ): - row.status = "unclassified" - row.snippet_id = None - row.classified_by = None - row.classified_at = None + await _judge(session, row, status="unclassified", snippet_id=None, + by=None, reason=None, at=now) await session.commit() @@ -292,19 +359,11 @@ async def classify_shapes( continue status = item["status"] for row in matches: - row.status = status - if status == "unclassified": - row.snippet_id = None - row.reason = None - row.classified_by = None - row.classified_at = None - else: - row.snippet_id = ( - int(item["snippet_id"]) if status in _NEEDS_TARGET else None - ) - row.reason = (item.get("reason") or "").strip() or None - row.classified_by = via - row.classified_at = now + await _judge( + session, row, status=status, + snippet_id=int(item["snippet_id"]) if status in _NEEDS_TARGET else None, + by=via, reason=item.get("reason"), at=now, + ) classified += 1 await session.commit() return {"classified": classified, "unmatched": unmatched} @@ -320,12 +379,19 @@ async def list_project_shapes( include_vanished: bool = False, limit: int = 100, offset: int = 0, + proposal: str = "", + flag: str = "", ) -> tuple[list[CodeShape], int]: """A filtered page of a project's ledger, with the unfiltered-match total. ([], 0) when the caller can't read the project — the same silence every other project list gives. ``path`` matches the exact file or anything - beneath it, mirroring recorded-location semantics. + beneath it, mirroring recorded-location semantics. ``proposal`` narrows + to rows the proposer has spoken about: "any", "canon" (an instance-of-#N + suggestion), "derive" (a repeats-with-no-canon group), or one basis + name (symbol/reference/text/signature/semantic). ``flag`` narrows to + the readout's asks (#2793): "divergence" (new where a canon dominates, + `diverges_from` names it) or "recheck" (a judged shape whose body moved). """ from sqlalchemy import func, or_ @@ -345,6 +411,21 @@ async def list_project_shapes( )) if snippet_id: conds.append(CodeShape.snippet_id == snippet_id) + if proposal == "any": + conds.append(or_( + CodeShape.proposed_snippet_id.isnot(None), + CodeShape.proposal_group.isnot(None), + )) + elif proposal == "canon": + conds.append(CodeShape.proposed_snippet_id.isnot(None)) + elif proposal == "derive": + conds.append(CodeShape.proposal_group.isnot(None)) + elif proposal: + conds.append(CodeShape.proposal_basis == proposal) + if flag == "divergence": + conds.append(CodeShape.diverges_from.isnot(None)) + elif flag == "recheck": + conds.append(CodeShape.recheck_at.isnot(None)) async with async_session() as session: total = ( await session.execute( @@ -408,3 +489,828 @@ async def snippet_consumers(user_id: int, note_id: int) -> dict: _consumer_dict(row) ) return out + + +# --- write-path stamping (#2791): hook evidence lands as rows ---------------- +# +# The write-path hook (plugin/hooks/scribe_prior_art.sh) fires on every +# Write/Edit and already carries the two halves of a consumer-map row: the +# file being written and the definitions in (or enclosing) the payload. What +# it could not say on its own is WHICH canon the session is instantiating. +# The pull stream answers that: a snippet the session opened in full +# (get_snippet) and is now writing code that references or resembles is being +# reused — and a reused canon's call site is an `instance` (note 2786). +# +# The rule, deliberately two-sided so it cannot fire on noise: +# PULLED — a PULLED usage event by this user inside PULL_WINDOW. Offered- +# but-ignored (surfaced, never opened) stamps nothing. +# IN PLAY — the payload references the snippet's symbol by name, or the +# semantic arm scored it above the write-path threshold for this +# very payload. Either is evidence; the pull alone is not. +# Both hold → every shape the hook named at that path, of the snippet's kind, +# becomes instance-of-N with classified_by="hook" and the evidence as reason. +# +# A hook row is EVIDENCE, not judgment: it only ever lands on rows nobody has +# judged (unclassified) or rows an earlier hook stamped, never on a canonical +# row or an agent/audit/import judgment. Re-judge with classify_shapes. + +# "The write path actually pulled it": a working session's reach. The +# precision comes from the in-play test above, not from this window. +PULL_WINDOW = timedelta(hours=6) + +def snippet_kind(symbol: str, language: str) -> str: + """The ledger kind a snippet's reference belongs to — "css" when its + symbol is a class selector (or it is a stylesheet with no symbol), + else "sym".""" + sym = (symbol or "").strip() + if sym.startswith("."): + return "css" + if not sym and (language or "").strip().lower() in ("css", "scss", "sass", "less"): + return "css" + return "sym" + + +def references_symbol(code: str, symbol: str, kind: str) -> bool: + """Does this payload name the snippet's symbol? Word-bounded so `confirm` + never claims `confirmed`; a CSS class matches as `.btn` or inside a class + attribute (`btn btn-primary`), dashes counting as part of the name.""" + sym = _norm_symbol(symbol or "") + if not sym or not code: + return False + if kind == "css": + pattern = rf"(? dict[int, datetime]: + """{note_id: last pulled at} for every note this user opened in full + inside ``window`` — the "actually pulled it" half of the stamping rule. + Reads the usage telemetry table; an unreadable table means no evidence.""" + from sqlalchemy import func + + from scribe.models.note_usage import PULLED, NoteUsageEvent + + since = datetime.now(timezone.utc) - window + try: + async with async_session() as session: + rows = await session.execute( + select(NoteUsageEvent.note_id, func.max(NoteUsageEvent.created_at)) + .where( + NoteUsageEvent.user_id == user_id, + NoteUsageEvent.event == PULLED, + NoteUsageEvent.created_at >= since, + ) + .group_by(NoteUsageEvent.note_id) + ) + return {int(nid): ts for nid, ts in rows.all()} + except Exception: + logger.warning("recent_pulls read failed — no hook stamping this write", exc_info=True) + return {} + + +async def stamp_write_path_instances( + user_id: int, + project_id: int, + *, + path: str, + shapes: list[tuple[str, str]], + code: str, + pulled: dict[int, datetime], + resembles: dict[int, float] | None = None, + repo_key: str = "", +) -> list[dict]: + """Land hook evidence as `instance` rows for the shapes being written. + + ``shapes`` is the hook's (kind, name) list for ``path``; ``pulled`` is + recent_pulls(); ``resembles`` maps snippet ids the semantic arm scored + for this payload to their score. Returns the rows stamped, each + {path, symbol, kind, snippet_id, reason} — empty in the common case. + + A shape the ledger has no live row for yet (it is being written right + now) gets a PROVISIONAL row under ``repo_key`` — first/last-seen empty — + so the stamp is not lost to the next sync, which either confirms the + shape (sets its seen marker) or stamps it vanished. No repo key → only + existing rows are stamped. + + When more than one pulled snippet is in play for a shape, a by-name + reference beats resemblance and the most recent pull breaks ties: a row + holds one canon (the known model limit logged on #2790). + """ + from scribe.services import access + from scribe.services import snippets as snippets_svc + from scribe.services.snippets import snippet_fields + + resembles = resembles or {} + path = (path or "").strip() + wanted = [(k, n.strip()) for k, n in shapes if k in ("css", "sym") and n.strip()] + if not project_id or not path or not wanted or not pulled: + return [] + if not await access.can_write_project(user_id, project_id): + return [] + + # Which pulled canons are in play for this payload, by kind, ranked. + in_play: dict[str, list[tuple[int, datetime, int, str]]] = {} + for sid, pulled_at in pulled.items(): + note = await snippets_svc.get_snippet(user_id, sid) + if note is None: + continue + fields = snippet_fields(note) + symbol = fields.get("symbol") or "" + kind = snippet_kind(symbol, fields.get("language") or "") + if references_symbol(code, symbol, kind): + rank, why = 2, f"hook: pulled #{sid}; payload references `{_norm_symbol(symbol)}`" + elif sid in resembles: + rank, why = 1, f"hook: pulled #{sid}; payload resembles it ({resembles[sid]:.2f})" + else: + continue + in_play.setdefault(kind, []).append((rank, pulled_at, sid, why)) + if not in_play: + return [] + for bucket in in_play.values(): + bucket.sort(key=lambda t: (t[0], t[1]), reverse=True) + + now = datetime.now(timezone.utc) + stamped: list[dict] = [] + async with async_session() as session: + rows = ( + await session.execute( + select(CodeShape).where( + CodeShape.project_id == project_id, + CodeShape.path == path, + CodeShape.vanished_at.is_(None), + ) + ) + ).scalars().all() + by_key = {(r.symbol, r.kind): r for r in rows} + for kind, name in wanted: + bucket = in_play.get(kind) + if not bucket: + continue + _rank, _at, sid, why = bucket[0] + row = by_key.get((name, kind)) + if row is None: + if not repo_key: + continue + row = CodeShape( + project_id=project_id, repo_key=repo_key, + path=path, symbol=name, kind=kind, + ) + session.add(row) + by_key[(name, kind)] = row + elif not (row.status == "unclassified" or row.classified_by == "hook"): + continue # a judgment — or the canon itself — stands + await _judge(session, row, status="instance", snippet_id=sid, by="hook", + reason=why, at=now) + stamped.append({ + "path": path, "symbol": name, "kind": kind, + "snippet_id": sid, "reason": why, + }) + if stamped: + await session.commit() + return stamped + + +# --- the mechanical proposer (#2792): the machine proposes, judgment classifies +# +# Runs inside the coverage refresh — the one moment the shape BODIES exist +# (the archive is in memory; the ledger stores fingerprints, never code). +# Over every live unclassified row whose content changed since it was last +# examined, it tries, strongest first: +# +# symbol the shape bears a recorded canon's symbol at another location +# (a second definition of the canon's name — an instance or a +# duplicate to consolidate; either way, it answers to #N); +# text whitespace-insensitive containment either way between the +# body and the canon's recorded code (git-grep, in effect — +# a verbatim copy, or the canon's own call-site example); +# reference the body calls/uses a canon's symbol — the call-site shape +# the P7 backfill classified by hand (#2790); +# signature a sym whose definition line, name blanked, resembles the +# canon's (the move_*/resequence family shape); +# semantic the widest net: the body (concept-queried, as the write-path +# arm does) scores above the write-path threshold against a +# canon — capped per refresh, so the cost is bounded and a big +# ledger is worked through across refreshes. +# +# A hit is a PROPOSAL on the row (proposed_snippet_id/basis/score), never a +# classification: an agent confirms in batches (confirm_proposals) or judges +# otherwise (classify_shapes clears it). Rows with no canon hit are grouped +# by the derive-first rule (note 2786): the same body fingerprint in ≥2 +# places, or the same name defined in ≥3 files, is "a repeating shape with +# no canon — derive one first", carried as proposal_basis="derive" + a group +# key so sessions see the consolidation candidates as one thing. + +# Derive-first floors. Identical bodies twice is already a copy; a bare name +# needs more repetition before it reads as a family (setup/handler/register +# recur by convention, not by duplication). +_DERIVE_MIN_DUP = 2 +_DERIVE_MIN_NAME = 3 +# Semantic checks per repo per refresh — an embedding each (local fastembed), +# bounded so a 4,000-row ledger is worked through over refreshes, not in one. +_SEMANTIC_CAP = 150 +# Signature resemblance floor, name blanked (difflib ratio) — and a length +# floor, because `def NAME():` resembles `def NAME(x):` at 0.95 while saying +# nothing; a family shape has parameters to resemble. +_SIGNATURE_FLOOR = 0.8 +_SIGNATURE_MIN_LEN = 30 +# Textual containment needs enough substance to mean anything. +_TEXT_FLOOR = 40 +_BASIS_ORDER = ("symbol", "text", "reference", "signature") + + +class Canon(NamedTuple): + snippet_id: int + kind: str + symbol: str + locations: tuple[tuple[str, str], ...] + signature: str + code_norm: str + project_id: int = 0 + + +def _norm_text(text: str) -> str: + return " ".join((text or "").split()) + + +def signature_similarity(sig_a: str, name_a: str, sig_b: str, name_b: str) -> float: + """How alike two definition lines are once their own names are blanked — + `def move_event(project_id, event_id, after_id)` against + `def move_beat(project_id, beat_id, after_id)` reads high.""" + a = _norm_text(sig_a) + b = _norm_text(sig_b) + if name_a: + a = a.replace(name_a, "NAME") + if name_b: + b = b.replace(name_b, "NAME") + if len(a) < _SIGNATURE_MIN_LEN or len(b) < _SIGNATURE_MIN_LEN: + return 0.0 + return difflib.SequenceMatcher(None, a, b).ratio() + + +def text_contains(body: str, code: str) -> bool: + """Whitespace-insensitive containment, either way, above a substance + floor — the proposer's git-grep.""" + a = _norm_text(body) + b = _norm_text(code) + if len(a) < _TEXT_FLOOR or len(b) < _TEXT_FLOOR: + return False + return a in b or b in a + + +def match_canon( + kind: str, path: str, symbol: str, signature: str, body: str, + canons: Iterable[Canon], *, project_id: int = 0, +) -> tuple[int, str, float] | None: + """The strongest (snippet_id, basis, score) a shape earns against the + canon catalog, by the non-semantic bases — or None. Strongest by + basis order, then by score within the basis; on a tie, a canon recorded + in the shape's own project beats family canon from another (the same + helper recorded in two projects is the shape's own first).""" + best: dict[str, tuple[float, bool, int]] = {} + + def offer(basis: str, score: float, canon: Canon) -> None: + cur = best.get(basis) + same = bool(project_id) and canon.project_id == project_id + if cur is None or (score, same) > (cur[0], cur[1]): + best[basis] = (score, same, canon.snippet_id) + + norm_sym = _norm_symbol(symbol) + for c in canons: + if c.kind != kind: + continue + if c.symbol and _norm_symbol(c.symbol) == norm_sym: + if not any(location_covers(lp, ls, path, symbol) for lp, ls in c.locations): + offer("symbol", 1.0, c) + continue # its own location is canonical territory, not a proposal + if c.symbol and references_symbol(body, c.symbol, kind): + offer("reference", 0.9, c) + if c.code_norm and text_contains(body, c.code_norm): + offer("text", 0.95, c) + if kind == "sym" and c.signature: + ratio = signature_similarity(signature, symbol, c.signature, c.symbol) + if ratio >= _SIGNATURE_FLOOR: + offer("signature", round(ratio, 3), c) + for basis in _BASIS_ORDER: + if basis in best: + score, _same, sid = best[basis] + return (sid, basis, score) + return None + + +async def canon_catalog(user_id: int) -> list[Canon]: + """Every snippet this user can browse, as matchable canon — own projects + and shared ones alike, because family canon counts (note 2786).""" + from scribe.models.note import Note + from scribe.services.access import browsable_notes_clause + from scribe.services.coverage import extract_definitions + from scribe.services.snippets import SNIPPET_NOTE_TYPE, snippet_fields + + async with async_session() as session: + notes = ( + await session.execute( + select(Note).where( + browsable_notes_clause(user_id), + Note.note_type == SNIPPET_NOTE_TYPE, + Note.deleted_at.is_(None), + ) + ) + ).scalars().all() + out: list[Canon] = [] + for note in notes: + fields = snippet_fields(note) + symbol = (fields.get("symbol") or "").strip() + kind = snippet_kind(symbol, fields.get("language") or "") + code = fields.get("code") or "" + defs = extract_definitions(code) + signature = "" + if defs: + own = next( + (d for d in defs if _norm_symbol(d.name) == _norm_symbol(symbol)), defs[0] + ) + signature = own.signature + out.append(Canon( + int(note.id), kind, symbol, + tuple( + ((loc.get("path") or ""), (loc.get("symbol") or "")) + for loc in fields.get("locations") or [] + ), + signature, _norm_text(code), int(note.project_id or 0), + )) + return out + + +def _clear_proposal(row: CodeShape, *, reexamine: bool = False) -> None: + row.proposed_snippet_id = None + row.proposal_basis = None + row.proposal_score = None + row.proposal_group = None + if reexamine: + row.proposed_at = None + row.proposed_sha = "" + + +def _substance(text: str) -> int: + return len("".join((text or "").split())) + + +async def _semantic_canon( + user_id: int, body: str, allowed: set[int] +) -> tuple[int, float] | None: + from scribe.services.embeddings import semantic_search_notes + from scribe.services.plugin_context import ( + WRITEPATH_DEFAULT_THRESHOLD, WRITEPATH_MIN_CODE_CHARS, concept_query, + ) + + if _substance(body) < WRITEPATH_MIN_CODE_CHARS or not allowed: + return None + query = concept_query(body) or body + hits = await semantic_search_notes( + user_id, query, limit=3, threshold=WRITEPATH_DEFAULT_THRESHOLD, + note_type="snippet", scope="browse", + ) + for score, note in hits: + if int(note.id) in allowed: + return int(note.id), round(float(score), 3) + return None + + +async def propose_for_repo( + user_id: int, + project_id: int, + repo_key: str, + definitions: list, + *, + canons: list[Canon] | None = None, + semantic_cap: int = _SEMANTIC_CAP, +) -> dict: + """Examine one repo's live unclassified rows against canon and record + proposals. ``definitions`` are the ArchiveShape records the sync just + upserted (their bodies are the matching material). Rows whose fingerprint + is unchanged since their last examination are skipped; rows that only + the capped semantic pass could not reach stay unexamined, so the next + refresh reaches the next slice. Returns counts.""" + if canons is None: + canons = await canon_catalog(user_id) + by_key = {(d[0], d[1], d[2]): d for d in definitions} + sym_canon_ids = {c.snippet_id for c in canons if c.kind == "sym"} + now = datetime.now(timezone.utc) + examined = proposed = checked = 0 + async with async_session() as session: + rows = ( + await session.execute( + select(CodeShape).where( + CodeShape.project_id == project_id, + CodeShape.repo_key == repo_key, + CodeShape.status == "unclassified", + CodeShape.vanished_at.is_(None), + ) + ) + ).scalars().all() + semantic_todo: list[tuple[CodeShape, object]] = [] + for row in rows: + d = by_key.get((row.path, row.kind, row.symbol)) + if d is None: + continue + signature, body_sha, body = d[3], d[4], d[5] + if row.proposed_at is not None and row.proposed_sha == body_sha: + continue + examined += 1 + group = row.proposal_group # derive grouping is reassigned below + hit = match_canon( + row.kind, row.path, row.symbol, signature, body, canons, + project_id=project_id, + ) + _clear_proposal(row) + row.proposal_group = group + row.proposed_at = now + row.proposed_sha = body_sha + if hit: + row.proposed_snippet_id, row.proposal_basis, row.proposal_score = hit + row.proposal_group = None + proposed += 1 + elif row.kind == "sym": + semantic_todo.append((row, d)) + for i, (row, d) in enumerate(semantic_todo): + if i >= semantic_cap: + # Not reached this refresh: leave it unexamined so the next + # refresh picks it up, rather than stamping a false "nothing". + row.proposed_at = None + row.proposed_sha = "" + continue + checked += 1 + try: + found = await _semantic_canon(user_id, d[5], sym_canon_ids) + except Exception: + logger.warning("semantic proposal failed", exc_info=True) + found = None + if found: + row.proposed_snippet_id, row.proposal_score = found + row.proposal_basis = "semantic" + row.proposal_group = None + proposed += 1 + await session.commit() + return {"examined": examined, "proposed": proposed, "semantic_checked": checked} + + +def derive_groups( + rows: Iterable[tuple[str, str, str, str]] +) -> dict[tuple[str, str, str], str]: + """The derive-first grouping over (path, kind, symbol, body_sha) rows + that matched no canon: {(path, kind, symbol): group_key}. Identical + bodies in ≥2 places group as `dup:`; the same name defined in ≥3 + files groups as `name::`; a row joins at most one group, + the copy before the name.""" + by_sha: dict[str, list[tuple[str, str, str]]] = {} + by_name: dict[tuple[str, str], list[tuple[str, str, str]]] = {} + for path, kind, symbol, sha in rows: + key = (path, kind, symbol) + if sha: + by_sha.setdefault(sha, []).append(key) + by_name.setdefault((kind, _norm_symbol(symbol)), []).append(key) + out: dict[tuple[str, str, str], str] = {} + for sha, keys in by_sha.items(): + if len(set(keys)) >= _DERIVE_MIN_DUP: + for key in keys: + out.setdefault(key, f"dup:{sha}") + for (kind, symbol), keys in by_name.items(): + if len({k[0] for k in keys}) >= _DERIVE_MIN_NAME: + for key in keys: + out.setdefault(key, f"name:{kind}:{symbol}") + return out + + +async def apply_derive_groups(project_id: int) -> int: + """Recompute derive-first groups over the project's live unclassified + rows that carry no canon proposal; returns how many rows are grouped.""" + now = datetime.now(timezone.utc) + grouped = 0 + async with async_session() as session: + rows = ( + await session.execute( + select(CodeShape).where( + CodeShape.project_id == project_id, + CodeShape.status == "unclassified", + CodeShape.vanished_at.is_(None), + CodeShape.proposed_snippet_id.is_(None), + ) + ) + ).scalars().all() + groups = derive_groups( + (r.path, r.kind, r.symbol, r.body_sha or "") for r in rows + ) + sizes: dict[str, int] = {} + for g in groups.values(): + sizes[g] = sizes.get(g, 0) + 1 + for row in rows: + key = groups.get((row.path, row.kind, row.symbol)) + if key: + row.proposal_basis = "derive" + row.proposal_group = key + row.proposal_score = float(sizes[key]) + if row.proposed_at is None: + row.proposed_at = now + grouped += 1 + elif row.proposal_group: + row.proposal_basis = None + row.proposal_group = None + row.proposal_score = None + await session.commit() + return grouped + + +def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict: + """The readout's view of the proposer's standing: how many canon + proposals await confirmation, and the largest derive-first groups.""" + proposed = 0 + groups: dict[str, dict] = {} + for row in rows: + if row.status != "unclassified": + continue + if row.proposed_snippet_id is not None: + proposed += 1 + elif row.proposal_group: + g = groups.setdefault(row.proposal_group, { + "group": row.proposal_group, "kind": row.kind, + "label": ( + ("." if row.kind == "css" else "") + row.symbol + if row.proposal_group.startswith("name:") + else f"{row.symbol} (identical body)" + ), + "size": 0, "paths": [], + }) + g["size"] += 1 + if len(g["paths"]) < 3: + g["paths"].append(row.path) + ranked = sorted(groups.values(), key=lambda g: (-g["size"], g["group"])) + return {"proposed": proposed, "derive_groups": ranked[:top]} + + +async def confirm_proposals( + user_id: int, + project_id: int, + *, + snippet_id: int = 0, + path: str = "", + basis: str = "", + min_score: float = 0.0, +) -> dict: + """Turn reviewed canon proposals into `instance` rows, in one batch. + + At least one of snippet_id / path / basis must narrow the batch — "confirm + everything proposed" without having looked is not a judgment. Each row + becomes instance-of-its-proposed-snippet, classified_by="agent", reason + naming the basis and score; the proposal is retired. Returns + {"confirmed": N}.""" + from scribe.services import access + + if not (snippet_id or path.strip() or basis.strip()): + raise ValueError( + "name what you reviewed: confirm by snippet_id, path, and/or basis" + ) + if not await access.can_write_project(user_id, project_id): + raise ValueError(f"project {project_id} not found or no write access") + from sqlalchemy import or_ + + conds = [ + CodeShape.project_id == project_id, + CodeShape.status == "unclassified", + CodeShape.vanished_at.is_(None), + CodeShape.proposed_snippet_id.isnot(None), + ] + if snippet_id: + conds.append(CodeShape.proposed_snippet_id == snippet_id) + if path.strip(): + clean = path.strip().strip("/") + conds.append(or_(CodeShape.path == clean, CodeShape.path.like(clean + "/%"))) + if basis.strip(): + conds.append(CodeShape.proposal_basis == basis.strip()) + now = datetime.now(timezone.utc) + confirmed = 0 + async with async_session() as session: + rows = (await session.execute(select(CodeShape).where(*conds))).scalars().all() + for row in rows: + if (row.proposal_score or 0.0) < min_score: + continue + await _judge( + session, row, status="instance", snippet_id=row.proposed_snippet_id, + by="agent", at=now, + reason=( + f"confirmed {row.proposal_basis} proposal" + f" ({(row.proposal_score or 0.0):.2f})" + ), + ) + confirmed += 1 + await session.commit() + return {"confirmed": confirmed} + + +# --- the divergence readout (#2793): button B where button A is canon ------- +# +# Three answers the ledger can now give mechanically: +# DIVERGENCE a shape NEW since the previous refresh, in a directory+kind +# where one canon dominates the judged siblings, that the +# proposer did not match to that canon → `diverges_from=#N`. +# Read: "button B appeared where button A is canon — divergence +# or variant? classify it." Surfaced in the coverage readout and +# in-band at write time (the prior-art hook names the shapes). +# HISTORY every judgment / vanish / reappearance / drift is an event; +# shape_history answers "what was used here, when, and why". +# RECHECK an instance/variant whose body moved since it was judged is +# flagged recheck_at (sync) — the judgment stands, re-confirm it. + +# A canon dominates a directory+kind when at least this many siblings are +# judged (canonical/instance) and this share of them answer to one snippet. +_DENSITY_MIN_JUDGED = 3 +_DENSITY_SHARE = 0.6 + + +def dominant_canon(rows: Iterable[CodeShape]) -> tuple[int, int, int] | None: + """(snippet_id, its_count, judged_count) when one canon dominates these + sibling rows (same directory + kind), else None.""" + counts: dict[int, int] = {} + judged = 0 + for r in rows: + if r.status in ("canonical", "instance") and r.snippet_id is not None: + judged += 1 + counts[r.snippet_id] = counts.get(r.snippet_id, 0) + 1 + if judged < _DENSITY_MIN_JUDGED or not counts: + return None + sid, n = max(counts.items(), key=lambda kv: (kv[1], -kv[0])) + if n / judged < _DENSITY_SHARE: + return None + return sid, n, judged + + +def _dir_of(path: str) -> str: + return path.rsplit("/", 1)[0] if "/" in path else "" + + +async def canon_density(project_id: int, path: str, kind: str) -> tuple[int, int, int] | None: + """The dominant canon for the directory ``path`` sits in, for ``kind`` — + the write-time question "is this a canon-dense place?".""" + directory = _dir_of(path) + async with async_session() as session: + rows = ( + await session.execute( + select(CodeShape).where( + CodeShape.project_id == project_id, + CodeShape.kind == kind, + CodeShape.vanished_at.is_(None), + CodeShape.path.like(directory + "/%") if directory + else CodeShape.path.notlike("%/%"), + ) + ) + ).scalars().all() + siblings = [r for r in rows if _dir_of(r.path) == directory] + return dominant_canon(siblings) + + +async def write_time_divergence( + project_id: int, path: str, shapes: list[tuple[str, str]], stamped: list[dict], +) -> list[dict]: + """The in-band check for the shapes the hook named at ``path``: for each + kind whose directory has a dominant canon, the named shapes that are + not (already or just now) that canon's instance/canonical — new or + unclassified rows only; a judged shape is not re-litigated at every + edit. Returns [{symbol, kind, canon_snippet_id, instances, judged}].""" + just_stamped = {(s["symbol"], s["kind"]): s["snippet_id"] for s in stamped} + out: list[dict] = [] + kinds = {k for k, _n in shapes} + density = {k: await canon_density(project_id, path, k) for k in kinds} + if not any(density.values()): + return out + async with async_session() as session: + rows = ( + await session.execute( + select(CodeShape).where( + CodeShape.project_id == project_id, + CodeShape.path == path, + CodeShape.vanished_at.is_(None), + ) + ) + ).scalars().all() + by_key = {(r.symbol, r.kind): r for r in rows} + for kind, name in shapes: + dom = density.get(kind) + if not dom: + continue + sid, n, judged = dom + if just_stamped.get((name, kind)) == sid: + continue + row = by_key.get((name, kind)) + if row is not None and ( + row.status != "unclassified" or row.proposed_snippet_id == sid + ): + continue + out.append({"symbol": name, "kind": kind, "canon_snippet_id": sid, + "instances": n, "judged": judged}) + return out + + +async def flag_divergence(project_id: int, *, since: datetime | None) -> int: + """Flag shapes created after ``since`` (the previous refresh) that sit + where a canon dominates and were not proposed as that canon. With no + previous refresh (first seed) nothing is new, nothing is flagged. + Standing flags persist until judged. Returns how many are flagged.""" + if since is None: + return 0 + async with async_session() as session: + rows = ( + await session.execute( + select(CodeShape).where( + CodeShape.project_id == project_id, + CodeShape.vanished_at.is_(None), + ) + ) + ).scalars().all() + by_dir: dict[tuple[str, str], list[CodeShape]] = {} + for r in rows: + by_dir.setdefault((_dir_of(r.path), r.kind), []).append(r) + flagged = 0 + for siblings in by_dir.values(): + dom = dominant_canon(siblings) + for r in siblings: + if r.status != "unclassified": + continue + if r.diverges_from is not None: + flagged += 1 + continue + if dom is None or r.created_at is None or r.created_at <= since: + continue + if r.proposed_snippet_id == dom[0]: + continue # the proposer already says "instance of the canon" + r.diverges_from = dom[0] + flagged += 1 + await session.commit() + return flagged + + +def divergence_summary(rows: Iterable[CodeShape], *, top: int = 10) -> dict: + """Readout view: flagged shapes (newest first) and the recheck count.""" + flagged = [r for r in rows if r.diverges_from is not None and r.status == "unclassified"] + flagged.sort(key=lambda r: (r.created_at or datetime.min.replace(tzinfo=timezone.utc)), reverse=True) + recheck = sum(1 for r in rows if r.recheck_at is not None and r.vanished_at is None) + return { + "divergent": len(flagged), + "divergence": [ + {"path": r.path, "symbol": r.symbol, "kind": r.kind, + "canon_snippet_id": r.diverges_from} + for r in flagged[:top] + ], + "recheck": recheck, + } + + +async def shape_history( + user_id: int, project_id: int, path: str, *, symbol: str = "", limit: int = 200 +) -> dict: + """What was used at ``path`` (a file or directory), when, and why: the + current rows plus their events, oldest first. Read-gated like every + other ledger read; {} when the caller cannot read the project.""" + from sqlalchemy import or_ + + from scribe.services import access + + if not await access.can_read_project(user_id, project_id): + return {} + clean = (path or "").strip().strip("/") + conds = [CodeShape.project_id == project_id] + if clean: + conds.append(or_(CodeShape.path == clean, CodeShape.path.like(clean + "/%"))) + if symbol.strip(): + conds.append(CodeShape.symbol == symbol.strip()) + async with async_session() as session: + rows = ( + await session.execute( + select(CodeShape).where(*conds) + .order_by(CodeShape.path, CodeShape.symbol, CodeShape.kind) + .limit(500) + ) + ).scalars().all() + ids = [r.id for r in rows] + events = ( + await session.execute( + select(CodeShapeEvent).where(CodeShapeEvent.shape_id.in_(ids)) + .order_by(CodeShapeEvent.at.asc(), CodeShapeEvent.id.asc()) + .limit(max(1, min(limit, 1000))) + ) + ).scalars().all() if ids else [] + return { + "shapes": [ + { + "path": r.path, "symbol": r.symbol, "kind": r.kind, + "status": r.status, "snippet_id": r.snippet_id, + "classified_by": r.classified_by, "reason": r.reason, + "first_seen_commit": r.first_seen_commit, + "last_seen_commit": r.last_seen_commit, + "first_seen_at": r.created_at.isoformat() if r.created_at else None, + "vanished_at": r.vanished_at.isoformat() if r.vanished_at else None, + "recheck_at": r.recheck_at.isoformat() if r.recheck_at else None, + "diverges_from": r.diverges_from, + } + for r in rows + ], + "events": [e.to_dict() for e in events], + } diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index b8d84a8..0667697 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -219,3 +219,404 @@ async def test_sync_refiles_rows_whose_snippet_was_purged(seeded): ))).scalar_one() assert row.status == "unclassified" assert row.snippet_id is None + + +# --- #2791: the write-path feed lands hook evidence as rows ------------------- + + +@pytest.mark.integration +async def test_write_path_stamp_is_evidence_that_yields_to_judgment(seeded): + """Pulled + referenced → every named shape of the snippet's kind becomes + an instance row, classified_by=hook, carrying the evidence as reason. A + later agent judgment on one of them stands against a re-stamp; the hook + may only overwrite nobody's judgment or its own. The outsider stamps + nothing (write-gated like every other ledger write).""" + from datetime import datetime, timezone + + from scribe.services.shape_ledger import stamp_write_path_instances + + owner, other, pid, sid = ( + seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"] + ) + pulled = {sid: datetime.now(timezone.utc)} + code = "app = factory()\nreturn app\n" # references the snippet's symbol + + assert await stamp_write_path_instances( + other, pid, path="src/app.py", shapes=[("sym", "make_app")], + code=code, pulled=pulled, + ) == [] + + stamped = await stamp_write_path_instances( + owner, pid, path="src/app.py", + shapes=[("sym", "make_app"), ("sym", "Config"), ("css", "nope")], + code=code, pulled=pulled, + ) + assert {s["symbol"] for s in stamped} == {"make_app", "Config"} # css skipped: no css canon + rows, _ = await list_project_shapes(owner, pid, snippet_id=sid) + by_symbol = {r.symbol: r for r in rows} + assert by_symbol["make_app"].status == "instance" + assert by_symbol["make_app"].classified_by == "hook" + assert by_symbol["make_app"].reason == f"hook: pulled #{sid}; payload references `factory`" + + # A judgment lands; the next stamp must leave it alone but may re-stamp + # its own earlier row. + await classify_shapes(owner, pid, [ + {"path": "src/app.py", "symbol": "make_app", "status": "exempt", + "reason": "the app factory is its own thing"}, + ]) + again = await stamp_write_path_instances( + owner, pid, path="src/app.py", + shapes=[("sym", "make_app"), ("sym", "Config")], code=code, pulled=pulled, + ) + assert {s["symbol"] for s in again} == {"Config"} + rows, _ = await list_project_shapes(owner, pid, path="src/app.py") + by_symbol = {r.symbol: r for r in rows} + assert by_symbol["make_app"].status == "exempt" + assert by_symbol["Config"].status == "instance" + + # Neither pulled nor in play → nothing, even with shapes named. + assert await stamp_write_path_instances( + owner, pid, path="src/util.py", shapes=[("sym", "helper")], + code="print('unrelated')", pulled=pulled, + ) == [] + + +@pytest.mark.integration +async def test_a_brand_new_shape_gets_a_provisional_row_the_sync_settles(seeded): + """The shape being written right now has no ledger row yet. With the + hook's repo key it gets a provisional one — seen markers empty — so the + stamp survives until the next sync, which confirms it (sets the marker) + or stamps it vanished. Without a repo key only existing rows are touched.""" + from datetime import datetime, timezone + + from scribe.services.shape_ledger import stamp_write_path_instances + + owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] + pulled = {sid: datetime.now(timezone.utc)} + code = "def build():\n return factory()\n" + + assert await stamp_write_path_instances( + owner, pid, path="src/new.py", shapes=[("sym", "build")], + code=code, pulled=pulled, # no repo_key + ) == [] + stamped = await stamp_write_path_instances( + owner, pid, path="src/new.py", shapes=[("sym", "build")], + code=code, pulled=pulled, repo_key=REPO, + ) + assert [s["symbol"] for s in stamped] == ["build"] + async with async_session() as s: + row = (await s.execute(select(CodeShape).where( + CodeShape.project_id == pid, CodeShape.path == "src/new.py", + ))).scalar_one() + assert row.status == "instance" and row.classified_by == "hook" + # "Unset" is the column's empty default — the markers are non-null + # Text, and the sync is what first fills them. + assert row.first_seen_commit == "" and row.last_seen_commit == "" + + # The sync sees the shape in the tree → confirmed, stamp intact. + await sync_repo_shapes( + pid, REPO, SHAPES + [("src/new.py", "sym", "build")], seen_marker="abc123", + ) + rows, _ = await list_project_shapes(owner, pid, path="src/new.py") + assert rows[0].status == "instance" and rows[0].last_seen_commit == "abc123" + + # The sync no longer sees it → vanished, out of the live accounting. + await sync_repo_shapes(pid, REPO, SHAPES, seen_marker="def456") + rows, _ = await list_project_shapes(owner, pid, path="src/new.py") + assert rows == [] + rows, _ = await list_project_shapes(owner, pid, path="src/new.py", include_vanished=True) + assert rows[0].vanished_at is not None + + +@pytest.mark.integration +async def test_recent_pulls_reads_the_usage_stream(seeded): + """The "actually pulled it" half is the PULLED usage event, inside the + window; a surfacing alone is not a pull.""" + from datetime import datetime, timedelta, timezone + + from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent + from scribe.services.shape_ledger import recent_pulls + + owner, sid = seeded["owner"], seeded["snippet"] + now = datetime.now(timezone.utc) + async with async_session() as s: + s.add_all([ + NoteUsageEvent(user_id=owner, note_id=sid, event=PULLED, source="mcp_get_snippet"), + NoteUsageEvent(user_id=owner, note_id=sid + 1000, event=SURFACED, source="auto_inject"), + NoteUsageEvent(user_id=owner, note_id=sid + 2000, event=PULLED, + source="mcp_get_snippet", created_at=now - timedelta(days=2)), + ]) + await s.commit() + pulls = await recent_pulls(owner) + assert sid in pulls + assert sid + 1000 not in pulls + assert sid + 2000 not in pulls + + +# --- #2792: the mechanical proposer against real rows ------------------------- + + +def _quiet_semantic(): + """The semantic basis needs the embedder; these tests prove the other + bases and the bookkeeping, so it answers "nothing" here.""" + from unittest.mock import AsyncMock, patch + + from scribe.services import shape_ledger + return patch.object(shape_ledger, "_semantic_canon", AsyncMock(return_value=None)) + + +def _defs(*items): + """ArchiveShape-like records: (path, kind, name, signature, body_sha, body).""" + import hashlib + out = [] + for path, kind, name, signature, body in items: + sha = hashlib.sha1(" ".join(body.split()).encode()).hexdigest()[:16] + out.append((path, kind, name, signature, sha, body)) + return out + + +@pytest.mark.integration +async def test_proposer_proposes_and_confirm_classifies(seeded): + """Bodies in hand, the proposer records proposals on unclassified rows — + symbol (a second `factory` elsewhere), reference (a call site), and + nothing for the unrelated — skips rows whose content it already judged, + and a scoped confirm turns proposals into agent instances while a + classify on another retires its proposal.""" + from scribe.services.shape_ledger import ( + confirm_proposals, propose_for_repo, + ) + + owner, other, pid, sid = ( + seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"] + ) + defs = _defs( + ("src/app.py", "sym", "make_app", "def make_app():", "def make_app():\n app = factory()\n return app"), + ("src/app.py", "sym", "Config", "class Config:", "class Config:\n debug = False"), + ("src/util.py", "sym", "helper", "def helper(x):", "def helper(x):\n return x"), + ("src/dup.py", "sym", "factory", "def factory():", "def factory():\n return 1"), + ("web/button.css", "css", "btn", ".btn {", ".btn {\n color: red;\n}"), + ) + await sync_repo_shapes(pid, REPO, defs, seen_marker="main") + + with _quiet_semantic(): + stats = await propose_for_repo(owner, pid, REPO, defs) + assert stats == {"examined": 5, "proposed": 2, "semantic_checked": 2} + rows, total = await list_project_shapes(owner, pid, proposal="canon") + by_symbol = {r.symbol: r for r in rows} + assert total == 2 + assert by_symbol["factory"].proposal == {"basis": "symbol", "score": 1.0, "snippet_id": sid} + assert by_symbol["make_app"].proposal == {"basis": "reference", "score": 0.9, "snippet_id": sid} + rows, _ = await list_project_shapes(owner, pid, proposal="reference") + assert [r.symbol for r in rows] == ["make_app"] + + # Same content again → nothing re-examined (the semantic cap would + # otherwise be spent on the same rows every refresh). A cap that leaves + # rows unreached leaves them UNexamined, so the next refresh gets them. + with _quiet_semantic(): + assert (await propose_for_repo(owner, pid, REPO, defs))["examined"] == 0 + await classify_shapes(owner, pid, [ + {"path": "src/util.py", "symbol": "helper", "status": "unclassified"}, + ]) + assert (await propose_for_repo(owner, pid, REPO, defs, semantic_cap=0))["semantic_checked"] == 0 + assert (await propose_for_repo(owner, pid, REPO, defs))["examined"] == 1 + + # Outsider can't confirm; the owner confirms by snippet, scoped. + with pytest.raises(ValueError): + await confirm_proposals(other, pid, snippet_id=sid) + assert await confirm_proposals(owner, pid, basis="symbol") == {"confirmed": 1} + rows, _ = await list_project_shapes(owner, pid, snippet_id=sid) + factory = next(r for r in rows if r.symbol == "factory") + assert factory.status == "instance" and factory.classified_by == "agent" + assert factory.reason == "confirmed symbol proposal (1.00)" + assert factory.proposal is None + + # A judgment on a proposed row retires the proposal; withdrawing a + # judgment forgets the examination so the next pass proposes afresh. + await classify_shapes(owner, pid, [ + {"path": "src/app.py", "symbol": "make_app", "status": "exempt", "reason": "bootstrap"}, + ]) + rows, _ = await list_project_shapes(owner, pid, path="src/app.py") + make_app = next(r for r in rows if r.symbol == "make_app") + assert make_app.status == "exempt" and make_app.proposal is None + await classify_shapes(owner, pid, [ + {"path": "src/app.py", "symbol": "make_app", "status": "unclassified"}, + ]) + with _quiet_semantic(): + assert (await propose_for_repo(owner, pid, REPO, defs))["proposed"] == 1 + rows, _ = await list_project_shapes(owner, pid, proposal="canon") + assert [r.symbol for r in rows] == ["make_app"] + + +@pytest.mark.integration +async def test_derive_groups_land_on_rows_and_in_the_summary(seeded): + """Shapes with no canon hit that repeat — identical bodies in two files, + the same name in three — carry a derive proposal, and the readout ranks + the families. A canon proposal keeps a row out of any derive group.""" + from scribe.services.shape_ledger import ( + apply_derive_groups, live_rows, propose_for_repo, proposal_summary, + ) + + owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] + defs = _defs( + ("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"), + ("a/two.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"), + ("b/x.css", "css", "card", ".card {", ".card { padding: 1px }"), + ("b/y.css", "css", "card", ".card {", ".card { padding: 2px }"), + ("b/z.css", "css", "card", ".card {", ".card { padding: 3px }"), + ("c/only.py", "sym", "alone", "def alone():", "def alone():\n return 0"), + ("c/use.py", "sym", "boot", "def boot():", "def boot():\n return factory()"), + ) + await sync_repo_shapes(pid, REPO, defs, seen_marker="main") + with _quiet_semantic(): + await propose_for_repo(owner, pid, REPO, defs) + assert await apply_derive_groups(pid) == 5 + + rows, total = await list_project_shapes(owner, pid, proposal="derive") + assert total == 5 + groups = {(r.path, r.symbol): r.proposal for r in rows} + assert groups[("a/one.py", "slug")]["group"] == groups[("a/two.py", "slug")]["group"] + assert groups[("a/one.py", "slug")]["group"].startswith("dup:") + assert groups[("b/x.css", "card")] == {"basis": "derive", "score": 3.0, "group": "name:css:card"} + rows, _ = await list_project_shapes(owner, pid, proposal="any") + assert {r.symbol for r in rows} == {"slug", "card", "boot"} # boot: reference proposal + + summary = proposal_summary(await live_rows(pid)) + assert summary["proposed"] == 1 + assert [g["group"] for g in summary["derive_groups"]][0] == "name:css:card" + assert summary["derive_groups"][0]["label"] == ".card" + assert summary["derive_groups"][0]["size"] == 3 + + # One of the css copies gets judged → the group shrinks on the next pass. + await classify_shapes(owner, pid, [ + {"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"}, + ]) + await apply_derive_groups(pid) + rows, _ = await list_project_shapes(owner, pid, proposal="derive") + assert {r.symbol for r in rows} == {"slug"} # 2 files < the name floor + + +# --- #2793: the divergence readout against real rows ------------------------- + + +@pytest.mark.integration +async def test_a_second_confirm_dialog_is_detected_and_named(seeded): + """The milestone's acceptance case. A directory where one canon dominates + the judged siblings (a confirm helper with four instance call sites); + after a previous refresh, a new shape lands there that the proposer does + not match to the canon — it is flagged `diverges_from` the canon, the + readout names it, and the in-band check names it at write time. A + judgment clears the flag; a shape proposed AS the canon is not flagged.""" + from datetime import datetime, timedelta, timezone + + from scribe.services.shape_ledger import ( + divergence_summary, flag_divergence, live_rows, propose_for_repo, + write_time_divergence, + ) + + owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] + comp = "frontend/src/components" + base = _defs( + *[(f"{comp}/{n}.vue", "sym", f"on{n}", f"async function on{n}() {{", + f"async function on{n}() {{\n const ok = await factory();\n if (!ok) return;\n}}") + for n in ("Trash", "Delete", "Remove", "Restore")], + ) + await sync_repo_shapes(pid, REPO, base, seen_marker="aaa111") + await classify_shapes(owner, pid, [ + {"path": f"{comp}/{n}.vue", "symbol": f"on{n}", "status": "instance", "snippet_id": sid} + for n in ("Trash", "Delete", "Remove", "Restore") + ], via="audit") + previous = datetime.now(timezone.utc) + + # Button B: a hand-rolled confirm that never touches the canon, plus a + # proper new instance (references the canon → the proposer claims it). + later = base + _defs( + (f"{comp}/Danger.vue", "sym", "confirmDanger", "function confirmDanger() {", + "function confirmDanger() {\n return window.confirm('Really?');\n}"), + (f"{comp}/Proper.vue", "sym", "onPurge", "async function onPurge() {", + "async function onPurge() {\n const ok = await factory();\n if (!ok) return;\n}"), + ) + await sync_repo_shapes(pid, REPO, later, seen_marker="bbb222") + with _quiet_semantic(): + await propose_for_repo(owner, pid, REPO, later) + assert await flag_divergence(pid, since=None) == 0 # a first seed flags nothing + assert await flag_divergence(pid, since=previous - timedelta(seconds=1)) == 1 + + rows, total = await list_project_shapes(owner, pid, flag="divergence") + assert total == 1 + assert rows[0].symbol == "confirmDanger" and rows[0].diverges_from == sid + summary = divergence_summary(await live_rows(pid)) + assert summary["divergent"] == 1 + assert summary["divergence"][0]["symbol"] == "confirmDanger" + assert summary["divergence"][0]["canon_snippet_id"] == sid + + # In-band: the hook names the shape at write time → the check names the canon. + named = await write_time_divergence( + pid, f"{comp}/Danger.vue", [("sym", "confirmDanger")], stamped=[] + ) + assert named == [{"symbol": "confirmDanger", "kind": "sym", "canon_snippet_id": sid, + "instances": 4, "judged": 4}] + # ...but an already-judged shape, or one just stamped as the canon's + # instance, is not re-litigated. + assert await write_time_divergence(pid, f"{comp}/Trash.vue", [("sym", "onTrash")], stamped=[]) == [] + assert await write_time_divergence( + pid, f"{comp}/New.vue", [("sym", "onNew")], + stamped=[{"symbol": "onNew", "kind": "sym", "snippet_id": sid}], + ) == [] + # A directory with no dominant canon is silent. + assert await write_time_divergence(pid, "src/other.py", [("sym", "thing")], stamped=[]) == [] + + # The judgment answers the question and clears the flag. + await classify_shapes(owner, pid, [ + {"path": f"{comp}/Danger.vue", "symbol": "confirmDanger", "status": "variant", + "snippet_id": sid, "reason": "native confirm is fine in the dev-only panel"}, + ]) + rows, total = await list_project_shapes(owner, pid, flag="divergence") + assert total == 0 + + +@pytest.mark.integration +async def test_history_records_what_was_used_when_and_drift_asks_for_a_recheck(seeded): + from scribe.services.shape_ledger import shape_history + + owner, other, pid, sid = ( + seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"] + ) + v1 = _defs(("src/app.py", "sym", "make_app", "def make_app():", "def make_app():\n return factory()")) + await sync_repo_shapes(pid, REPO, v1, seen_marker="c1") + await classify_shapes(owner, pid, [ + {"path": "src/app.py", "symbol": "make_app", "status": "instance", "snippet_id": sid}, + ]) + # The body moves under the judgment → drifted + recheck; re-judging clears it. + v2 = _defs(("src/app.py", "sym", "make_app", "def make_app():", "def make_app():\n return factory(debug=True)")) + await sync_repo_shapes(pid, REPO, v2, seen_marker="c2") + rows, total = await list_project_shapes(owner, pid, flag="recheck") + assert total == 1 and rows[0].symbol == "make_app" and rows[0].status == "instance" + await classify_shapes(owner, pid, [ + {"path": "src/app.py", "symbol": "make_app", "status": "variant", "snippet_id": sid, + "reason": "debug flag is deliberate here"}, + ]) + rows, total = await list_project_shapes(owner, pid, flag="recheck") + assert total == 0 + # Then it vanishes from the tree. + await sync_repo_shapes(pid, REPO, [], seen_marker="c3") + + history = await shape_history(owner, pid, "src/app.py", symbol="make_app") + shape = history["shapes"][0] + assert shape["status"] == "variant" and shape["vanished_at"] is not None + # The seeded fixture synced this row first (marker "main"); v1/v2 are + # later sightings — first_seen keeps the first. + assert shape["first_seen_commit"] == "main" and shape["last_seen_commit"] == "c2" + timeline = [(e["event"], e["status"], e["snippet_id"], e["commit"]) for e in history["events"]] + assert timeline == [ + ("classified", "instance", sid, "c1"), + ("drifted", "instance", sid, "c2"), + ("classified", "variant", sid, "c2"), + ("vanished", "variant", sid, "c2"), + ] + assert history["events"][2]["reason"] == "debug flag is deliberate here" + assert history["events"][0]["classified_by"] == "agent" + # Directory-wide read works (the empty sync also vanished the seeded + # Config and helper rows under src/ — two more events); an outsider + # reads nothing. + assert len((await shape_history(owner, pid, "src"))["events"]) == 6 + assert await shape_history(other, pid, "src/app.py") == {} diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py index f5822a1..3e40f4c 100644 --- a/tests/test_pattern_coverage.py +++ b/tests/test_pattern_coverage.py @@ -456,3 +456,70 @@ async def test_unservable_binding_measures_nothing(seeded): await set_binding(uid, "https://github.com/somebody/else.git", other_pid) assert await compute_coverage(uid, other_pid, selector=_selector(_tarball(TREE))) is None + + +# --- #2792: fingerprints and the proposer's readout -------------------------- + + +def test_extract_definitions_fingerprints_each_block(): + """The block rule across the language families the extractor knows: a + Python def ends at the next top-level statement, a braces/CSS block keeps + its closer, and comments/decorators don't move the hash.""" + from scribe.services.coverage import extract_definitions + + text = ( + "import os\n\n" + "def a(x):\n # comment\n return x + 1\n\n\n" + "class B:\n def m(self):\n return 2\n\n" + ".btn {\n color: red;\n}\n" + "export const f = (x) => {\n return x;\n};\n" + ) + defs = {d.name: d for d in extract_definitions(text)} + assert set(defs) == {"a", "B", "m", "btn", "f"} + assert defs["a"].signature == "def a(x):" + assert defs["a"].body.startswith("def a(x):\n # comment\n return x + 1") + assert "class B" not in defs["a"].body + assert defs["B"].body.rstrip().endswith("return 2") + assert defs["btn"].body == ".btn {\n color: red;\n}" + assert defs["f"].body == "export const f = (x) => {\n return x;\n};" + assert all(len(d.body_sha) == 16 for d in defs.values()) + # Comment changes don't change what the shape IS; code changes do. + again = {d.name: d for d in extract_definitions(text.replace("# comment", "# other"))} + assert again["a"].body_sha == defs["a"].body_sha + changed = {d.name: d for d in extract_definitions(text.replace("x + 1", "x + 2"))} + assert changed["a"].body_sha != defs["a"].body_sha + # And the identity view is unchanged for the hook mirror. + from scribe.services.coverage import extract_shapes + assert extract_shapes(text) == [(d.kind, d.name) for d in extract_definitions(text)] + + +def test_coverage_line_names_the_proposers_standing(): + from scribe.services.coverage import coverage_line + + base = { + "total": 100, "accounted": 10, "unclassified": 90, + "counts": {"canonical": 10, "instance": 0, "variant": 0, "exempt": 0}, + "computed_at": "2026-08-21T00:00:00+00:00", + "largest_gaps": [{"dir": "src", "unclassified": 90, "total": 90}], + } + assert coverage_line(base).endswith("; 90 unclassified, largest: src") + line = coverage_line({**base, "proposed": 40, "derive_groups": [{"group": "a"}, {"group": "b"}]}) + assert "; 90 unclassified (40 proposed, 2 derive groups), largest: src" in line + line = coverage_line({**base, "proposed": 0, "derive_groups": [{"group": "a"}]}) + assert "(1 derive group)" in line + + +def test_coverage_line_names_divergence_and_recheck(): + from scribe.services.coverage import coverage_line + + base = { + "total": 100, "accounted": 40, "unclassified": 60, + "counts": {"canonical": 10, "instance": 30, "variant": 0, "exempt": 0}, + "computed_at": "2026-08-21T00:00:00+00:00", + "largest_gaps": [{"dir": "src", "unclassified": 60, "total": 60}], + } + line = coverage_line({**base, "divergent": 2, "recheck": 1, "proposed": 5}) + assert "; 60 unclassified (5 proposed, 2 DIVERGENT), largest: src" in line + assert line.endswith("; 1 judged shape changed since judged — recheck") + assert "DIVERGENT" not in coverage_line(base) + assert "recheck" not in coverage_line(base) diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py index 6978d87..a1e19e7 100644 --- a/tests/test_services_backup.py +++ b/tests/test_services_backup.py @@ -13,11 +13,12 @@ import pytest from scribe.services import backup -def test_backup_version_is_v7(): - """v7 added code_shapes (#2787). The bump is the point of the test — - a payload section added without moving the version produces backups that - are structurally different and indistinguishable by inspection.""" - assert backup.BACKUP_VERSION == 7 +def test_backup_version_is_v8(): + """v7 added code_shapes (#2787), v8 its history (#2793). The bump is the + point of the test — a payload section added without moving the version + produces backups that are structurally different and indistinguishable + by inspection.""" + assert backup.BACKUP_VERSION == 8 def test_not_included_lists_the_known_gaps(): @@ -114,7 +115,7 @@ async def test_export_full_backup_contains_every_declared_section(): "topic_suppressions", "systems", "record_systems", "design_systems", "design_tokens", "note_usage_events", "repo_bindings", - "note_supersessions", "code_shapes"): + "note_supersessions", "code_shapes", "code_shape_events"): assert key in out, f"missing export section: {key}" assert out[key] == [] diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index e3b95b2..6e062d9 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -5,6 +5,8 @@ token-free serialisation. The sync pass (step 2) and the classification surface (step 3) grow their tests here; DB-backed behavior lands in the integration lane once there is behavior to exercise. """ +import pytest + from scribe.models import Base from scribe.models.code_shape import SHAPE_CLASSIFIERS, SHAPE_STATUSES, CodeShape @@ -82,3 +84,261 @@ def test_classify_and_list_are_mounted_as_mcp_tools(): mcp = build_mcp_server() for name in ("classify_shapes", "list_shapes", "refresh_pattern_coverage"): assert mcp._tool_manager.get_tool(name) is not None + + +# --- step 5: the write-path feed's evidence tests (pure) --------------------- + + +def test_symbol_reference_is_word_bounded_and_kind_aware(): + from scribe.services.shape_ledger import references_symbol as ref + + code = "const ok = await confirmed({ title: 'x' });\nif (!ok) return;" + assert ref(code, "confirmed", "sym") + assert not ref(code, "confirm", "sym") # prefix never claims the call + assert not ref("", "confirmed", "sym") + assert not ref(code, "", "sym") + # CSS: the class as a selector or inside a class attribute; dashes are part + # of the name, so `btn` must not claim `btn-primary`. + html = '' + assert ref(html, ".btn-primary", "css") + assert ref(html, "btn-primary", "css") + assert ref(".btn-primary { color: red }", ".btn-primary", "css") + assert not ref('