Files
hyperguild/ingestion/internal/brain/linkcheck.go
mathiasandClaude Opus 5 6e5a9cfce3
CI / Mirror to GitHub (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 35s
fix(brain): unwrap wikilinks that name no entry at the write boundary
Every brain write accepted [[targets]] that resolve to nothing — skill
names, repo names, agent-local memory-file slugs — and each one became a
permanent [high] broken-link finding. The brain-audit digest grew 19 -> 63
high findings between 2026-08-26 and 2026-09-16; 53 of the 63 were exactly
this, and none are fixable by brain-gardener's BrokenLinkFixer, which only
repoints edit-distance-1 renames. There is no target to repoint to.

StripUnresolvedLinks resolves each link against knowledge/ + wiki/ using
brain-gardener's own resolver semantics (bare slug or wing/hall/slug path
form, code spans excluded) and unwraps the misses to plain text, reporting
them as Ref.Unlinked / the MCP `unlinked` field.

Unwrap rather than reject: a refused write on claudewatcher's unattended
ingest path is a lost learning, and the author's words are worth keeping
even when the link is not. An empty corpus is a no-op, so a misconfigured
brainDir cannot strip every link in a note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:53:42 +02:00

158 lines
5.0 KiB
Go

package brain
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// wikilinkRE matches [[target]] and [[target|Display Text]].
var wikilinkRE = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]*))?\]\]`)
// fencedCodeRE and inlineCodeRE mark the regions where a [[...]] is a
// syntax example rather than a link. Same exclusion brain-gardener's audit
// applies on the read side, so the write gate and the audit agree on what
// counts as a link at all.
var (
fencedCodeRE = regexp.MustCompile("(?s)```.*?```")
inlineCodeRE = regexp.MustCompile("`[^`\n]*`")
)
// StripUnresolvedLinks unwraps every [[wikilink]] in content whose target
// resolves to no entry under knowledge/ or wiki/, leaving the link's display
// text (or its target) as plain prose. It returns the rewritten content and
// the sorted, de-duplicated targets it unwrapped.
//
// WHY UNWRAP RATHER THAN REJECT THE WRITE
// Agents write [[...]] around anything that feels related — a skill name, a
// repo, an agent-local memory file — and none of those are brain entries.
// Every one of those becomes a permanent [high] broken-link finding that no
// deterministic Fixer can repair, because there is no target to repoint to:
// the brain-audit digest grew from 19 to 63 high findings between 2026-08-26
// and 2026-09-16, and 53 of the 63 were exactly this. Rejecting the write
// instead would push the failure onto claudewatcher's unattended ingest path,
// where a refused write is a lost learning. Unwrapping keeps the author's
// words, keeps the write, and keeps the graph honest.
//
// An empty corpus (no knowledge/ or wiki/ yet, or a misconfigured brainDir)
// returns content unchanged: nothing resolving is a bootstrap state, not
// evidence that every target was invented.
func StripUnresolvedLinks(brainDir, content string) (string, []string, error) {
index, err := entryIndex(brainDir)
if err != nil {
return "", nil, err
}
if len(index) == 0 {
return content, nil, nil
}
code := codeSpans(content)
stripped := map[string]struct{}{}
var b strings.Builder
last := 0
for _, m := range wikilinkRE.FindAllStringSubmatchIndex(content, -1) {
start, end := m[0], m[1]
if overlaps(start, end, code) {
continue
}
target := strings.TrimSpace(content[m[2]:m[3]])
if _, ok := index[linkKey(target)]; ok {
continue
}
display := target
if m[4] >= 0 {
if d := strings.TrimSpace(content[m[4]:m[5]]); d != "" {
display = d
}
}
b.WriteString(content[last:start])
b.WriteString(display)
last = end
stripped[target] = struct{}{}
}
if last == 0 {
return content, nil, nil
}
b.WriteString(content[last:])
targets := make([]string, 0, len(stripped))
for t := range stripped {
targets = append(targets, t)
}
sort.Strings(targets)
return b.String(), targets, nil
}
// linkedTrees are the two trees that hold linkable entries — the same pair
// package notes admits under ScopeNotes. Duplicated rather than imported
// because that list is unexported there and this is a read-only mirror.
var linkedTrees = []string{"knowledge", "wiki"}
// entryIndex maps every form a wikilink may name an entry by — its bare slug
// and its path form (wiki/<wing>/<hall>/<slug>.md -> wing/hall/slug) — to a
// present marker. Mirrors brain-gardener's resolver so an accepted link is
// one the audit will also resolve.
func entryIndex(brainDir string) (map[string]struct{}, error) {
index := map[string]struct{}{}
for _, dir := range linkedTrees {
root := filepath.Join(brainDir, dir)
if _, err := os.Stat(root); os.IsNotExist(err) {
continue
} else if err != nil {
return nil, fmt.Errorf("stat %s: %w", dir, err)
}
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".md") {
return nil
}
rel, err := filepath.Rel(brainDir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
index[strings.TrimSuffix(filepath.Base(rel), ".md")] = struct{}{}
index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{}
return nil
})
if err != nil {
return nil, fmt.Errorf("walk %s: %w", dir, err)
}
}
return index, nil
}
// linkKey normalises a link target for index lookup: an exact path form is
// tried as written, and anything else falls back to its final segment.
func linkKey(target string) string {
return strings.TrimSuffix(target, ".md")
}
// codeSpans returns the byte ranges of fenced blocks and inline code spans.
func codeSpans(content string) [][2]int {
var spans [][2]int
for _, m := range fencedCodeRE.FindAllStringIndex(content, -1) {
spans = append(spans, [2]int{m[0], m[1]})
}
for _, m := range inlineCodeRE.FindAllStringIndex(content, -1) {
spans = append(spans, [2]int{m[0], m[1]})
}
return spans
}
// overlaps reports whether [start,end) intersects any span.
func overlaps(start, end int, spans [][2]int) bool {
for _, s := range spans {
if start < s[1] && end > s[0] {
return true
}
}
return false
}