fix(watcher): park through the brain root, keep a note edited mid-resolve, honour only real fences (#205)
CI / Lint / Test / Vet (pull_request) Successful in 1m7s
CI / Mirror to GitHub (pull_request) Skipped

From the second independent review of #207:
- parkForReview now skips the delete when the note changed since it was read
  (an LLM call sits in that window; Syncthing can deliver a newer version)
- parked copies are created via brainfs (real dirs only, O_EXCL, no symlink
  following), so a planted raw/needs-review symlink cannot redirect or hide a
  note; this also removes the need for the guard allowlist entry added in
  1e14c56, which is reverted
- a '----' line no longer closes the proposal block
Each has a test that failed first and a mutant that fails it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 06:21:25 +02:00
co-authored by Claude Sonnet 5.5
parent 1e14c56a49
commit bd33a0a4e8
4 changed files with 141 additions and 47 deletions
@@ -271,3 +271,61 @@ func linesStartingWith(s, prefix string) int {
}
return n
}
// Resolving costs an LLM call. If Syncthing delivers a newer version of the note
// meanwhile, deleting the original loses that version: the parked copy is stale.
func TestProcessDir_ANoteEditedWhileTheResolverRunsIsNotDeleted(t *testing.T) {
var llmCalls atomic.Int32
var notePath string
clf := &editingClassifier{}
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls)
notePath = filepath.Join(brainDir, "raw", "n.md")
clf.edit = func() { require.NoError(t, os.WriteFile(notePath, []byte("NEWER and longer version"), 0o644)) }
put(t, brainDir, "n.md", "ORIGINAL")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
got, err := os.ReadFile(notePath)
require.NoError(t, err, "the edited note must still be in raw/")
assert.Equal(t, "NEWER and longer version", string(got))
}
type editingClassifier struct{ edit func() }
func (c *editingClassifier) Classify(context.Context, string, []string) (wing.Suggestion, error) {
c.edit()
return wing.Suggestion{}, nil
}
// A planted symlink at raw/needs-review must not redirect a parked note out of
// the brain, nor lose it from the review queue.
func TestProcessDir_AParkedNoteIsNeverWrittenThroughASymlinkedNeedsReview(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls)
outside := t.TempDir()
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "needs-review")))
put(t, brainDir, "n.md", "Note.")
errs := processDir(context.Background(), cfg, "2026-10-20")
assert.NotEmpty(t, errs, "parking is refused, loudly")
entries, _ := os.ReadDir(outside)
assert.Empty(t, entries, "nothing was written through the symlink")
assert.True(t, has(brainDir, "raw/n.md"), "the note stays where it was")
}
func TestWithProposal_ANonFenceDashLineDoesNotCloseTheBlock(t *testing.T) {
out := withProposal("---\nfoo: bar\n----\nBody", wing.Decision{Wing: "radar", Confidence: 0.4, Reason: "r"})
lines := strings.Split(out, "\n")
require.Equal(t, "---", lines[0])
closing := -1
for i := 1; i < len(lines); i++ {
if lines[i] == "---" {
closing = i
break
}
}
require.Positive(t, closing, "the proposal block is closed by a real fence")
assert.Contains(t, strings.Join(lines[:closing], "\n"), "proposal_reason:")
assert.NotContains(t, strings.Join(lines[:closing], "\n"), "foo: bar", "the unfenced note is body, not part of the block")
}