Documentation
¶
Overview ¶
Package drift binds EARS rules to code spans and detects when either side moves out from under the other. Bindings live in the root-only, versioned .spectackle/anchors.tsv; each row records a content hash of the normalized code span (not its position), so pure line shifts are drift-free — the server silently refreshes file/span when only the position moved.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func NormHash ¶
NormHash hashes a normalized span: CRLF->LF, per-line trailing whitespace stripped, leading/trailing blank lines dropped. Indentation is semantic (Plan 9 asm, Makefiles) and is preserved.
func Save ¶
Save rewrites anchors.tsv under WithAnchorsLock, so two concurrent Save calls can never interleave into a torn file.
Save alone does not close the lost-update race for a caller that Loads, mutates, and then Saves as three separate steps (mcpserver's check and stampAnchors do exactly that): the Load already happened before this call's lock is even requested, so a sibling's Save landing in between is invisible to the mutation this Save is about to write over it. A caller in that shape must wrap its OWN Load-mutate-Save sequence in WithAnchorsLock (calling the unexported save below inside it, not this function, to avoid acquiring the same name twice) — see the doc on WithAnchorsLock.
func WithAnchorsLock ¶ added in v0.2.0
WithAnchorsLock runs fn while holding coord.db's "anchors" lock (see coord.DB.WithLock and workspace.Locker), releasing on every exit path including panic. ws.Lock is nil outside a swarm-aware caller (tests, migrate, a one-shot CLI run), where there is at most one writer already, so running unlocked is correct there, not merely tolerated.
anchors.tsv is root-only — one file for the whole workspace, unlike work.md/spec.md which are per context dir — so there is exactly one scope to serialize and the lock name carries no context.
A caller that Loads, mutates in memory, and Saves as separate steps MUST run all three inside one WithAnchorsLock call, not rely on Save's own locking: locking the write alone still lets two readers load the same stale rows first and race to overwrite each other, which is the same defect item.Upsert had before it locked its whole read-modify-write instead of just writeWork. Today's in-repo callers of this shape (mcpserver's check and stampAnchors) are unlocked and out of this task's scope (internal/mcpserver) — this export is what closes that gap for them, or for TestConcurrentAnchorsRewriteNoLostUpdate's shape, without this package needing to know what mcpserver does with it.
Types ¶
type Anchor ¶
type Anchor struct {
Rule string
Node graph.NodeID
File string // repo-relative; "-" = pending (node was unknown at link time)
Start int
End int
CHash string // 16 hex chars of sha256 over the normalized code span; "-" = pending
RHash string // same over the normalized rule sentence
}
Anchor is one rule<->node binding.
func Reconcile ¶
Reconcile drops every anchor row for rule whose Node is not in keep, leaving rows of other rules untouched and preserving order. Callers stamp a rule's current applies list through this first so an edit (or retire, with keep=nil) converges anchors.tsv to exactly that set instead of accumulating stale rows for nodes the rule no longer binds.
type Class ¶
type Class string
Class is the drift classification of one anchor.
const ( OK Class = "ok" Moved Class = "moved" // same content, new position — silently refreshed // Evolved/Tightened/Diverged replace the old single-axis "changed" class // with a direction-aware read of drift: which side moved, the code or // the rule sentence. Only Evolved is mechanically healable — the other // two mean a human has to look, so they are never auto-healed. Evolved Class = "evolved" // code changed, rule sentence identical — mechanically healable Tightened Class = "tightened" // code identical, rule sentence changed — never healed Diverged Class = "diverged" // both code and rule sentence changed — never healed // CrossFile: the anchor's node ID now resolves to a DIFFERENT file // whose content does not match the stored hash, and no hash-matching // candidate exists anywhere under the ID stem — an ID re-binding // (walk-order renumbering, B-01KYJB3SGK), not an evolution. Audited, // NEVER healed: healing would silently cross files, the exact // incident this class exists to prevent. CrossFile Class = "crossfile" Gone Class = "gone" // node no longer exists Stale Class = "stale" // rule no longer exists but anchor does Pending Class = "pending" // anchor written before the node was indexable )
type Result ¶
type Result struct {
Anchor Anchor
Class Class
NewHash string // current code-span hash; set whenever the span could be re-hashed (i.e. not Stale)
NewRHash string // current rule-sentence hash; set whenever the rule still exists (i.e. not Stale)
// NewNode is set when hash-first re-resolution re-bound the anchor to
// a different node ID (the stored ID renumbered or crossed files while
// a hash-matching candidate existed) — the caller's Moved refresh must
// write it back. Empty = the stored ID still names the right node.
NewNode graph.NodeID
// OtherFile is set on CrossFile: the file the stored ID resolves to
// now, named in the audit render beside the anchor's own file.
OtherFile string
}
Result is one classified anchor.