fix(watcher): never overwrite a parked note; a proposal cannot be forged or pre-empted; CRLF notes; wing dirs must be plain (#205)
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user