feat(snippets): drift check — verify a snippet still matches its source
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Failing after 22s
CI & Build / Build & push image (push) Has been skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Failing after 22s
CI & Build / Build & push image (push) Has been skipped
A recorded snippet points at a repo · path · symbol that WILL rot: files move, symbols get renamed, implementations diverge from the copy stored here. Nothing detected any of it, so a record degraded silently from "canonical reference" to "confidently wrong" — worse than no record, since it is surfaced with the same authority either way. WHERE THE CHECK RUNS. Agent-side, which the task flagged as the design question to settle first. Scribe has no checkout of the operator's repos and must not acquire one: giving the server repo access would make every install a credential problem and break instance-agnosticism (rule #115). The agent already has the working tree, so it does the comparing; the server remembers the verdict, makes it queryable, and knows when it has expired. New MCP tool verify_snippet teaches the four-step procedure and records the result; a REST endpoint mirrors it so the UI can clear a marker after a manual fix. WHY THE VERDICT CARRIES A CODE HASH. A verdict describes the code it was checked against. Invalidating it on edit means deciding which edits count — a when_to_use tweak shouldn't void a code check, a rewrite must — which is fiddly and easy to get subtly wrong, and easy for a new write path to forget entirely. Stamping the verdict with a hash sidesteps all of it: one whose code_sha no longer matches is self-evidently expired, computed at read time, no invalidation branch to maintain. That makes "expired" the interesting filter case. It is not `drifted` (nothing was found wrong) and not `unverified` (a check did happen), yet it plainly needs looking at — so `verification=attention` covers both. To keep that one index-served predicate rather than a post-filter that would make the pagination total a lie, data now also mirrors the CURRENT code's fingerprint as data.code_sha, and a jsonpath compares the two fields within the row. The filter is implemented in both dialects, SQL and Python, for the same reason the location filter is: the semantic arm's candidates arrive already fetched. A merge deliberately carries no verdict forward — the survivor's code is a union of several sources, so no prior check describes it, and unverified is the honest answer. UI: a danger-toned drift badge on each card (an actively misleading record outranks a merely unused one), and a "Needs attention" filter. Its empty state says plainly that never-verified snippets don't appear there — otherwise `attention` would mean "everything" on day one and be useless as a worklist. Refs #2086 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -34,6 +34,15 @@ function toggleLocationFilter() {
|
||||
if (!showLocationFilter.value && locationActive.value) clearLocation();
|
||||
}
|
||||
|
||||
// Drift check (#2086) — "attention" is everything whose recorded location or
|
||||
// code no longer checks out, plus everything whose verdict expired because the
|
||||
// snippet was edited since it was checked.
|
||||
const needsAttentionOnly = ref(false);
|
||||
function toggleAttention() {
|
||||
needsAttentionOnly.value = !needsAttentionOnly.value;
|
||||
loadSnippets();
|
||||
}
|
||||
|
||||
// Multi-select → merge
|
||||
const selectMode = ref(false);
|
||||
const selectedIds = ref<Set<number>>(new Set());
|
||||
@@ -96,6 +105,7 @@ async function loadSnippets() {
|
||||
repo: locRepo.value.trim() || undefined,
|
||||
path: locPath.value.trim() || undefined,
|
||||
symbol: locSymbol.value.trim() || undefined,
|
||||
verification: needsAttentionOnly.value ? "attention" : undefined,
|
||||
});
|
||||
snippets.value = data.snippets;
|
||||
} catch {
|
||||
@@ -144,6 +154,39 @@ function usageBadge(s: SnippetListItem): string {
|
||||
return `${u.pull_count}/${u.surfaced_count} used`;
|
||||
}
|
||||
|
||||
/** Short label for the drift verdict, or "" when there's nothing to say.
|
||||
* An expired verdict is reported as "unchecked" whatever it used to say —
|
||||
* it was about code that is no longer in the record. */
|
||||
function driftBadge(s: SnippetListItem): string {
|
||||
const v = s.verification;
|
||||
if (!v) return "";
|
||||
if (!v.current) return "unchecked since edit";
|
||||
return { ok: "", missing: "path gone", moved: "symbol moved", changed: "code drifted" }[
|
||||
v.status
|
||||
] ?? "";
|
||||
}
|
||||
|
||||
function driftTitle(s: SnippetListItem): string {
|
||||
const v = s.verification;
|
||||
if (!v) return "";
|
||||
const when = v.checked_at
|
||||
? `Checked ${new Date(v.checked_at).toLocaleDateString()}`
|
||||
: "Checked";
|
||||
if (!v.current) {
|
||||
return (
|
||||
`${when}, but the snippet has been edited since — that verdict was about ` +
|
||||
`code this record no longer holds. Re-verify it.`
|
||||
);
|
||||
}
|
||||
const what =
|
||||
{
|
||||
missing: "the recorded path no longer exists",
|
||||
moved: "the file is there but the symbol isn't in it",
|
||||
changed: "the source no longer matches the recorded code",
|
||||
}[v.status] ?? "";
|
||||
return v.detail ? `${when}: ${what}. ${v.detail}` : `${when}: ${what}.`;
|
||||
}
|
||||
|
||||
function usageTitle(s: SnippetListItem): string {
|
||||
const u = s.usage;
|
||||
if (!u) return "";
|
||||
@@ -201,6 +244,15 @@ function usageTitle(s: SnippetListItem): string {
|
||||
a screen reader too. -->
|
||||
{{ locationActive ? "Location · filtering" : "Location" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost"
|
||||
:class="{ 'filter-on': needsAttentionOnly }"
|
||||
:aria-pressed="needsAttentionOnly"
|
||||
title="Show only snippets whose recorded location or code no longer checks out — including ones edited since they were last verified"
|
||||
@click="toggleAttention"
|
||||
>
|
||||
{{ needsAttentionOnly ? "Needs attention · filtering" : "Needs attention" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Reverse lookup: what's already kept in this repo / file / symbol. -->
|
||||
@@ -243,20 +295,27 @@ function usageTitle(s: SnippetListItem): string {
|
||||
<div v-else-if="snippets.length === 0" class="empty-state-rich">
|
||||
<div class="empty-icon">❭_</div>
|
||||
<p class="empty-title">
|
||||
{{ locationActive
|
||||
? "Nothing kept at that location yet"
|
||||
: search.trim()
|
||||
? "No snippets match your search"
|
||||
: "No snippets kept yet" }}
|
||||
{{ needsAttentionOnly
|
||||
? "Everything checks out"
|
||||
: locationActive
|
||||
? "Nothing kept at that location yet"
|
||||
: search.trim()
|
||||
? "No snippets match your search"
|
||||
: "No snippets kept yet" }}
|
||||
</p>
|
||||
<p class="empty-sub">
|
||||
{{ locationActive
|
||||
? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter."
|
||||
: search.trim()
|
||||
? "Try a different term, or clear the search."
|
||||
: "Record a reusable function or component and it will be offered back to you later." }}
|
||||
{{ needsAttentionOnly
|
||||
? "No snippet has drifted from its recorded location or code — as far as anything has been checked. Snippets nobody has verified yet don't appear here."
|
||||
: locationActive
|
||||
? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter."
|
||||
: search.trim()
|
||||
? "Try a different term, or clear the search."
|
||||
: "Record a reusable function or component and it will be offered back to you later." }}
|
||||
</p>
|
||||
<button v-if="locationActive" class="empty-action" @click="clearLocation">
|
||||
<button v-if="needsAttentionOnly" class="empty-action" @click="toggleAttention">
|
||||
Show all snippets
|
||||
</button>
|
||||
<button v-else-if="locationActive" class="empty-action" @click="clearLocation">
|
||||
Clear location filter
|
||||
</button>
|
||||
<button
|
||||
@@ -295,6 +354,9 @@ function usageTitle(s: SnippetListItem): string {
|
||||
</p>
|
||||
<div class="card-footer">
|
||||
<span class="meta-date">Updated {{ new Date(s.updated_at).toLocaleDateString() }}</span>
|
||||
<span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
|
||||
{{ driftBadge(s) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="usageBadge(s)"
|
||||
class="usage-tag"
|
||||
@@ -618,6 +680,17 @@ function usageTitle(s: SnippetListItem): string {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Drift is a stronger signal than dead weight: the record may be actively
|
||||
misleading, not merely unused. Danger tone, and it sits first in the footer. */
|
||||
.drift-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--color-danger, #b91c1c) 15%, transparent);
|
||||
color: var(--color-danger, #b91c1c);
|
||||
}
|
||||
|
||||
.usage-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
|
||||
Reference in New Issue
Block a user