fix(watcher): never overwrite a parked note; a proposal cannot be forged or pre-empted; CRLF notes; wing dirs must be plain (#205)
CI / Lint / Test / Vet (pull_request) Failing after 1m2s
CI / Mirror to GitHub (pull_request) Skipped

From the independent review of #207:
- parked notes are created O_EXCL with -2,-3 suffixes (same-named notes from
  different subfolders were overwritten and their originals deleted)
- proposed_wing is emitted only if a plain wing name; a note's own proposal_*
  keys are dropped so they cannot outrank the resolver's (first-wins reads)
- CRLF notes: wing: key honoured, one valid frontmatter block on parking
- KnownWings skips wiki dirs that are not canonical lower-case names
- adds the wingmigrate dry-run/apply command
Each fix 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:16:48 +02:00
co-authored by Claude Sonnet 5.5
parent dd4a825f0a
commit 006a891423
5 changed files with 261 additions and 12 deletions
+68 -11
View File
@@ -3,8 +3,10 @@ package watcher
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
@@ -13,6 +15,7 @@ import (
"time"
"unicode"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
@@ -334,6 +337,7 @@ func topDir(rel string) string {
// frontmatterWing reads `wing:` from a note's leading frontmatter, "" if none.
func frontmatterWing(content string) string {
content = strings.ReplaceAll(content, "\r\n", "\n")
if !strings.HasPrefix(content, "---\n") {
return ""
}
@@ -355,17 +359,18 @@ func parkForReview(cfg Config, root *os.Root, rawDir, rel, path, filename, conte
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir needs-review: %w", err)
}
dest := filepath.Join(dir, filename)
if _, err := os.Stat(dest); err == nil {
ext := filepath.Ext(filename)
dest = filepath.Join(dir, strings.TrimSuffix(filename, ext)+"-"+time.Now().UTC().Format("150405")+ext)
dest, err := reserveParkedName(dir, filename)
if err != nil {
return fmt.Errorf("park note: %w", err)
}
if strings.ToLower(filepath.Ext(filename)) == ".pdf" {
// Binary: park the bytes untouched; the proposal is in log.md only.
if err := copyFromRoot(root, rel, dest); err != nil {
_ = os.Remove(dest)
return fmt.Errorf("park pdf: %w", err)
}
} else if err := os.WriteFile(dest, []byte(withProposal(content, d)), 0o644); err != nil {
_ = os.Remove(dest)
return fmt.Errorf("park note: %w", err)
}
if err := appendWatcherLog(cfg.BrainDir, filename, fmt.Errorf("needs review (proposed wing %q): %s", d.Wing, d.Reason), date); err != nil {
@@ -376,12 +381,64 @@ func parkForReview(cfg Config, root *os.Root, rawDir, rel, path, filename, conte
return nil
}
// withProposal adds the resolver's proposal to a note's frontmatter, creating
// the block when the note has none.
func withProposal(content string, d wing.Decision) string {
kv := fmt.Sprintf("proposed_wing: %s\nproposal_confidence: %.2f\nproposal_reason: %q\n", d.Wing, d.Confidence, d.Reason)
if strings.HasPrefix(content, "---\n") {
return "---\n" + kv + content[4:]
// reserveParkedName claims a free name in dir for filename, never reusing one:
// the parked copy is the only copy once the original is retired, so a note
// that shares a name with an earlier parked note must not replace it. The name
// is created with O_EXCL; -2, -3, ... is appended until one is free.
func reserveParkedName(dir, filename string) (string, error) {
ext := filepath.Ext(filename)
stem := strings.TrimSuffix(filename, ext)
for n := 1; n < 10000; n++ {
name := filename
if n > 1 {
name = fmt.Sprintf("%s-%d%s", stem, n, ext)
}
dest := filepath.Join(dir, name)
f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err == nil {
_ = f.Close()
return dest, nil
}
if !errors.Is(err, fs.ErrExist) {
return "", err
}
}
return "---\n" + kv + "---\n" + content
return "", fmt.Errorf("no free name for %q in %s", filename, dir)
}
// proposalKeys are the frontmatter keys the resolver owns in a parked note.
var proposalKeys = map[string]bool{"proposed_wing": true, "proposal_confidence": true, "proposal_reason": true}
// withProposal adds the resolver's proposal to a note's frontmatter, creating
// the block when the note has none. Every value is on one line: the wing only
// if it is a plain wing name (else it appears in the reason alone), strings
// Go-quoted. Any proposal keys the note already carried are dropped first, so a
// note cannot pre-empt the resolver's (frontmatter reads are first-wins).
// Line endings are normalised to LF so a CRLF note keeps one valid block.
func withProposal(content string, d wing.Decision) string {
content = strings.ReplaceAll(content, "\r\n", "\n")
reason := d.Reason
var kv strings.Builder
if d.Wing != "" && brain.Sanitise(d.Wing) == d.Wing {
fmt.Fprintf(&kv, "proposed_wing: %s\n", d.Wing)
} else if d.Wing != "" {
reason = fmt.Sprintf("proposed wing %q is not a plain wing name; %s", d.Wing, reason)
}
fmt.Fprintf(&kv, "proposal_confidence: %.2f\nproposal_reason: %q\n", d.Confidence, reason)
if !strings.HasPrefix(content, "---\n") {
return "---\n" + kv.String() + "---\n" + content
}
block, rest, found := strings.Cut(content[4:], "\n---")
if !found {
return "---\n" + kv.String() + "---\n" + content
}
var kept []string
for _, line := range strings.Split(block, "\n") {
if key, _, ok := strings.Cut(line, ":"); ok && proposalKeys[strings.TrimSpace(key)] && !strings.HasPrefix(line, " ") {
continue
}
kept = append(kept, line)
}
return "---\n" + kv.String() + strings.Join(kept, "\n") + "\n---" + rest
}