Review round 1 on PR #168:
- ReadRegular/ReadRegularMax Lstat the path, then open it again by path;
os.Root follows an in-root final symlink even with O_NOFOLLOW, so a
symlink to another brain file swapped in between was read (8k-109k
raw/ leaks in RebuildIndex, LoadInventory, IndexDoc). The opened handle
must now be the same file as the Lstat (os.SameFile). This covers every
ReadRegular caller, slices 1-3 included.
- Every symlink test pointed outside the brain, which os.Root refuses on
its own. Each now has an in-brain case too: a static in-brain link per
reader, AssertNoLeakFrom with an in-brain target, and brainfs unit tests
for ReadDirRegular and the in-brain swap.
- brainfstest: swap links are relative (os.Root refuses every absolute
link, so an absolute in-brain target tested nothing), and each swapper
has its own temp name (two swappers on one path deadlocked on each
other's FIFO).
- IndexDoc indexes only a clean wiki/ or knowledge/ path (callers pass
caller-supplied paths).
- Rule 9: TestLoadInventoryReturnsErrNotRegularForASymlinkedTypeDir now
asserts the error; the backfill test says it aborts on a symlinked root.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
graphsync.IndexDoc/BackfillFromBrainDir and wiki.LoadInventory/
RebuildIndex read notes by path (os.ReadFile, os.Open, os.ReadDir +
open). A symlinked note put the target's title, aliases and wikilinks
into the knowledge graph, the wiki inventory (and the LLM prompts built
from it) and wiki/index.md; a FIFO hung each read.
- graphsync: IndexDoc and the backfill read through brainfs; the
backfill walks with WalkRegular, skips a missing root and aborts on
any other walk error, including a symlinked root.
- wiki: LoadInventory lists type dirs with the new
brainfs.ReadDirRegular (every component a real dir, regular files
only) and reads frontmatter through the root; a symlinked type dir is
an error. RebuildIndex reads first sentences through the root.
- brainfstest's outside file now carries the sentinel in frontmatter
title and aliases, a heading and a wikilink, so extractors that ignore
the body are tested too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Textual-diff conflict detection only (semantic contradiction detection via brain-gardener Judge deferred, confirmed by Mathias, comment 12594). Includes canonicalBlock whitespace-run normalization fix for the disagreement comparison.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
fix(wiki): give the preamble its own field instead of overloading heading=="" (#103)
Round 3 review: verified structurally that the preamble is no longer in the sections slice or the idx map, so it cannot collide with a degenerate `## ` heading. Flip-tested by reverting to the heading=="" model and confirming TestMerge_DegenerateEmptyHeadingDoesNotCollideWithPreamble goes red. Adversarial pass (multiple degenerate headings, frontmatter-only page, unicode around the ## boundary, 3x self-merge idempotency with preamble+degenerate) all held. CI green on 09aeaa9 (run 7820).
Co-authored-by: Mathias <mthbqv@gmail.com>
Four call sites did `s[:limit] + "…"`. Both halves are byte operations:
len() counts bytes and a slice cuts bytes. On Swedish text — å ä ö are
two bytes, an em dash three — the cut lands mid-character and leaves an
orphaned lead byte, producing invalid UTF-8.
It failed loudly in exactly one place: wiki/index.md is written raw to
disk, where every strict decoder then rejects the whole file. The other
three (brain_context excerpts x2, brain_query excerpts) feed MCP
responses, where encoding/json silently substitutes U+FFFD — so those had
been quietly eating the last character of truncated excerpts, with no
error anywhere and nothing to notice.
textutil.Truncate counts runes and appends the marker only when content
was actually dropped; an ellipsis on complete text is a lie about the
content. The test is a property, not an example: it walks the limit
across every offset of a multi-byte string and asserts utf8.Valid at each
one, so a future off-by-one cannot pass by picking a lucky boundary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m