drift

package
v0.9.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 8 Imported by: 0

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

func NormHash(span []byte) string

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

func Save(ws workspace.Root, anchors []Anchor) error

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 SpanHash

func SpanHash(ws workspace.Root, file string, start, end int) (string, error)

SpanHash reads and hashes lines [start,end] (1-based, inclusive) of a file.

func WithAnchorsLock added in v0.2.0

func WithAnchorsLock(ws workspace.Root, fn func() error) error

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 Load

func Load(ws workspace.Root) ([]Anchor, error)

Load reads anchors.tsv (missing file = empty).

func Reconcile

func Reconcile(anchors []Anchor, rule string, keep []graph.NodeID) []Anchor

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.

func Stamp

func Stamp(ws workspace.Root, g graph.Graph, rule, ruleText string, node graph.NodeID) Anchor

Stamp builds an anchor for a rule bound to a node, hashing the node's current definition span. A node that is not (yet) in the graph produces a pending anchor — the M1 indexer resolves it on the next check.

func Upsert

func Upsert(anchors []Anchor, a Anchor) []Anchor

Upsert replaces the anchor for (rule, node) or appends it.

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.

func Classify

func Classify(ws workspace.Root, g graph.Graph, anchors []Anchor, ruleText func(string) (string, bool), stale func(file string) bool) []Result

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL