package indexer import ( "context" "errors" "fmt" "os" "path/filepath" "runtime" "sort" "strings" "sync" "syscall" "time" "github.com/sgtdi/fswatcher" "go.uber.org/zap" "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/excludes" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/pathkey" "github.com/zzet/gortex/internal/reach" ) // ChangeKind describes the type of filesystem change. type ChangeKind string const ( ChangeCreated ChangeKind = "created" ChangeModified ChangeKind = "modified" ChangeDeleted ChangeKind = "deleted" ChangeRenamed ChangeKind = "renamed" ) // GraphChangeEvent is emitted after a successful graph patch. type GraphChangeEvent struct { FilePath string `json:"file_path"` Kind ChangeKind `json:"kind"` NodesAdded int `json:"nodes_added"` NodesRemoved int `json:"nodes_removed"` EdgesAdded int `json:"edges_added"` EdgesRemoved int `json:"edges_removed"` Timestamp time.Time `json:"timestamp"` DurationMs int64 `json:"duration_ms"` } // SymbolChangeCallback is called when symbols change during file re-indexing. // It receives the file path, old symbols (before eviction), and new symbols (after re-index). type SymbolChangeCallback func(filePath string, oldSymbols, newSymbols []*graph.Node) // Watcher keeps the knowledge graph in live sync with the filesystem. type Watcher struct { indexer *Indexer fsw fswatcher.Watcher fsCancel context.CancelFunc config config.WatchConfig // degradedNoFsnotify is set when Start detected a slow mount (a WSL2 // 9p/drvfs Windows drive, an SMB share) and skipped the native fsnotify // backend, relying on the adaptive poller + git hooks instead. degradedNoFsnotify bool excludes *excludes.Matcher events chan GraphChangeEvent history []GraphChangeEvent historyMu sync.Mutex pending map[string]*time.Timer mu sync.Mutex // patchMu serialises per-path patchGraph invocations so the // post-patch reach rebuild (which scans every Node.Meta) cannot // race with another debounced patch's IndexFile / EvictFile / // detectClonesAndEmitEdges, all of which mutate the same Meta // maps unprotected. Storm-mode uses patchGraphNoResolve (driven // from a single goroutine in drainStorm) and bypasses this lock. patchMu sync.Mutex logger *zap.Logger done chan struct{} stopped chan struct{} symbolChangeCb SymbolChangeCallback symbolChangeCbMu sync.RWMutex // Degraded-watch state: set when the OS can't fully cover the tree — // inotify watch exhaustion (ENOSPC) or FD exhaustion (EMFILE/ENFILE). // degradedReason is the operator-facing explanation (surfaced as a // whole-index "frozen" banner on read tools); degradedLogged makes the // operator log warning fire exactly once; degradedCb (optional) pushes the // notice onto the daemon's health channel. Guarded by degradedMu. degradedMu sync.RWMutex degradedReason string degradedLogged bool degradedCb func(reason string) // probeWaiters maps a probe-file path (created during Start to confirm // the inotify watch is active) to a chan that handleEvent closes when // the probe's event arrives. Empty after Start returns. probeWaiters sync.Map // Storm-mode state. Guarded by stormMu so the hot per-file // debounce path (mu) doesn't contend with rate-tracking. stormMu sync.Mutex eventTimes []time.Time // sliding window of recent event timestamps stormBatch map[string]ChangeKind // dirty set during an event storm stormTimer *time.Timer // fires after the quiet period stormActive bool // true while waiting to drain stormDrained func(int) // test hook: batch drained; batch size arg // poller is the adaptive-interval fallback that re-checks git // HEAD movement and tracked-file mtimes on a timer, catching the // changes the fsnotify backend misses (inotify watch exhaustion, // network filesystems, dropped events). Created in Start and torn // down in Stop alongside the fsnotify backend. nil when the // per-repo watcher is disabled via WatchConfig.Enabled. poller *Poller // reconcileMu guards the overflow-driven full-tree reconcile. // reconcilePending coalesces a burst of overflow / dropped-event // signals into at most one reconcile in flight: the kernel inotify // queue can overflow (EventOverflow) or the backend can drop events // under backpressure (the Dropped() channel), and either means we // may have lost a create/modify with no path to re-index. macOS // FSEvents self-heals (it re-scans on UserDropped/KernelDropped), // but Linux inotify does not — without this the lost event waits on // the up-to-1h janitor. reconcileFn is a test seam: nil in // production (the real IncrementalReindex runs). reconcileMu sync.Mutex reconcilePending bool reconcileFn func() // pendingScanDirs coalesces newly-created directories awaiting a // scoped subtree re-index — the new-subdir race (see enqueueDirScan). // dirScanActive guards a single in-flight drainer goroutine; scanFn // is a test seam, nil in production (the real IncrementalReindexPaths // runs). All three are guarded by reconcileMu. pendingScanDirs map[string]struct{} dirScanActive bool scanFn func(map[string]struct{}) // pendingReresolve coalesces files the shape-degradation guard flagged // for a forced scoped re-resolve (see enqueueReresolve). reresolveActive // guards a single in-flight drainer; reresolveFn is a test seam, nil in // production (the real ReresolveFileScoped runs). All three are guarded // by reconcileMu. pendingReresolve map[string]struct{} reresolveActive bool reresolveFn func(map[string]struct{}) } const maxHistory = 1000 // probeMarker is the substring embedded in handshake-probe filenames // (see confirmWatchActive) and used by handleEvent to absorb their // create/remove events without touching the indexer. const probeMarker = ".gortex-watcher-handshake-" // NewWatcher creates a Watcher for the given indexer. // // cfg.Exclude is expected to carry the full effective pattern list (from // ConfigManager.EffectiveExclude). If it is empty — e.g. a direct caller // that bypasses ConfigManager — the watcher falls back to the builtin // baseline so the obvious non-source dirs stay ignored. func NewWatcher(idx *Indexer, cfg config.WatchConfig, logger *zap.Logger) (*Watcher, error) { debounce := cfg.DebounceMs if debounce <= 0 { debounce = 150 } cfg.DebounceMs = debounce // Storm-mode defaults — kept conservative so a repo producing // normal save traffic stays on the per-file path. Threshold of // zero means the user explicitly disabled storm mode; negative is // coerced to zero for safety. if cfg.StormThreshold < 0 { cfg.StormThreshold = 0 } if cfg.StormWindowMs <= 0 { cfg.StormWindowMs = 500 } if cfg.StormQuietPeriodMs <= 0 { cfg.StormQuietPeriodMs = 500 } patterns := cfg.Exclude if len(patterns) == 0 { patterns = excludes.Builtin } return &Watcher{ indexer: idx, config: cfg, excludes: excludes.New(patterns), events: make(chan GraphChangeEvent, 64), pending: make(map[string]*time.Timer), stormBatch: make(map[string]ChangeKind), logger: logger, done: make(chan struct{}), stopped: make(chan struct{}), }, nil } // Start begins watching the given paths recursively. The backend is // fswatcher, which uses FSEvents on macOS (one stream per root, // constant FD cost) and inotify on Linux (one watch per directory in // the tree). On the inotify path the per-user `max_user_watches` cap // applies; bump that sysctl if a multi-repo install grows beyond it. func (w *Watcher) Start(paths []string) error { if len(paths) == 0 { return errors.New("watcher: no paths to watch") } // WSL2 / slow-mount degradation: on a 9p/drvfs mount (a Windows drive // under WSL2, an SMB share) native fsnotify delivers events late or not // at all, and confirmWatchActive would hang ~5s per path before timing // out. Skip the fsnotify backend entirely and rely on the adaptive // poller + git hooks, which are mount-agnostic. The downstream code // already tolerates a nil fsw. GORTEX_FORCE_FSNOTIFY=1 overrides. if w.config.Enabled { probe := paths[0] if abs, err := filepath.Abs(probe); err == nil { probe = abs } if slowWatchMount(probe) { w.degradedNoFsnotify = true w.logger.Warn("watcher: slow mount detected — disabling native fsnotify, using adaptive poller fallback", zap.String("path", probe)) w.poller = newPoller(w, w.indexer, w.logger) w.poller.Start() return nil } } ready := make(chan struct{}) // Own the events/dropped channels so the library never closes them on // teardown. fswatcher's shutdown closes its events channel while its // EventAggregator goroutine may still be flushing a final event into // it — a "send on closed channel" panic under -race on the Linux // inotify path (the aggregator's close() does not join its run loop). // When we supply the channels, ownsEventsChannel is false and the // library skips the close; the aggregator's send is already // non-blocking, so a late flush lands harmlessly in our buffer (or // the dropped channel) and our loop still exits on its own stop // signal, not on the channel closing. Buffer sizes match the // library's defaults so coalescing behaviour is unchanged. droppedSize := max(fswatcher.DefaultBufferSize/fswatcher.MaxDroppedBufferRatio, fswatcher.MinDroppedBuffer) fswEvents := make(chan fswatcher.WatchEvent, fswatcher.DefaultBufferSize) fswDropped := make(chan fswatcher.WatchEvent, droppedSize) opts := []fswatcher.WatcherOpt{ // Disable fswatcher's internal debouncer. Its mergeEvents path // mutates the Types backing array of an already-delivered event // when a follow-up event for the same path arrives, racing with // our consumer's read. Our per-file debounce + storm-mode logic // is the authoritative coalescer anyway. fswatcher.WithCooldown(0), // Drop the library's own logging chatter; we surface what we // care about through our own logger. fswatcher.WithSeverity(fswatcher.SeverityError), // Block Start until the OS-level streams are actually live. // Without this the first events after Start race against // stream registration and silently disappear. fswatcher.WithReadyChannel(ready), // We own the channels (see above) — eliminates the teardown // send-on-closed-channel race. fswatcher.WithCustomChannels(fswEvents, fswDropped), } absPaths := make([]string, 0, len(paths)) for _, p := range paths { absPath, err := filepath.Abs(p) if err != nil { return err } absPaths = append(absPaths, absPath) opts = append(opts, fswatcher.WithPath(absPath)) } fsw, err := fswatcher.New(opts...) if err != nil { return err } w.fsw = fsw ctx, cancel := context.WithCancel(context.Background()) w.fsCancel = cancel watchErr := make(chan error, 1) go func() { err := fsw.Watch(ctx) watchErr <- err if err != nil && !errors.Is(err, context.Canceled) && w.logger != nil { w.logger.Warn("watcher: backend stopped", zap.Error(err)) } }() // Wait for the backend to become ready or fail fast on early // initialisation errors (e.g. an inotify add returning ENOSPC). select { case <-ready: case err := <-watchErr: cancel() // Watch / FD exhaustion is not a hard failure: keep Start succeeding, // log a one-time operator warning, and fall back to the adaptive // poller so the graph still catches git-HEAD + mtime changes. Failing // here would leave a busy machine with no daemon at all. if isInotifyExhausted(err) || isFDExhausted(err) { w.noteWatchDegraded(err) w.degradedNoFsnotify = true if w.fsw != nil { w.fsw.Close() w.fsw = nil } if w.config.Enabled { w.poller = newPoller(w, w.indexer, w.logger) w.poller.Start() } return nil } return err case <-time.After(5 * time.Second): cancel() return errors.New("watcher: backend did not become ready within 5s") } // FSEvents reports its stream as "started" the instant the C call // returns, but immediately fires synthetic "this file exists" // events for every pre-existing file under the watched root. The // flags on those events are indistinguishable from real changes // (Create + Modified are set), so we'd re-index every file on // every daemon start. Drain everything that lands in the events // buffer within a short grace window before starting the real // loop — anything genuinely happening to a file during that // window will fire again as new events. if runtime.GOOS == "darwin" { w.drainInitialReplay(150 * time.Millisecond) } go w.loop() // On Linux, fswatcher closes its ready channel as soon as the // inotify FD is allocated, but it registers initial paths in // background goroutines that may not have called inotify_add_watch // yet. Events fired before those goroutines run are lost forever. // Probe each path with a sentinel file and wait for the resulting // event before declaring the watcher ready. if runtime.GOOS != "darwin" { for _, p := range absPaths { if err := w.confirmWatchActive(p, 5*time.Second); err != nil { cancel() if w.fsw != nil { w.fsw.Close() } close(w.done) <-w.stopped return err } } } // Launch the adaptive-interval poller alongside the fsnotify // backend. It is a fallback for the changes fsnotify misses, so // it shares the watcher's lifecycle. Gated on WatchConfig.Enabled // — a repo that opted out of watching gets no fallback either. if w.config.Enabled { w.poller = newPoller(w, w.indexer, w.logger) w.poller.Start() } return nil } // confirmWatchActive writes sentinel files under root in a polling loop // until the corresponding fswatcher event arrives — proving the // OS-level watch is registered — or the overall timeout fires. The // retry loop is needed because the first probe may be written before // fswatcher's async addWatch goroutine has called inotify_add_watch, // in which case its create event is invisible to inotify entirely. // // The sentinel name avoids fswatcher's built-in isSystemFile filter // (which drops *.tmp / *.bak / *.swp / etc. before they reach our // handleEvent) and our own excludes matcher. func (w *Watcher) confirmWatchActive(root string, timeout time.Duration) error { deadline := time.Now().Add(timeout) const probeStep = 100 * time.Millisecond for time.Now().Before(deadline) { probe := filepath.Join(root, fmt.Sprintf("%s%d-%d", probeMarker, os.Getpid(), time.Now().UnixNano())) ch := make(chan struct{}) w.probeWaiters.Store(probe, ch) if err := os.WriteFile(probe, nil, 0o600); err != nil { w.probeWaiters.Delete(probe) if w.logger != nil { w.logger.Warn("watcher: could not write probe; continuing without confirmation", zap.String("root", root), zap.Error(err)) } return nil } select { case <-ch: _ = os.Remove(probe) return nil case <-time.After(probeStep): w.probeWaiters.Delete(probe) _ = os.Remove(probe) } } return fmt.Errorf("watcher: inotify watch on %s did not activate within %s", root, timeout) } // drainInitialReplay reads from the backend's events channel until // `window` of quiet has elapsed with no further events. macOS FSEvents // streams emit a burst of synthetic "exists" events at startup; this // burst is bounded by the per-stream latency (~50 ms). The first call // blocks at least one window so early events have a chance to arrive. func (w *Watcher) drainInitialReplay(window time.Duration) { if w.fsw == nil { return } eventsCh := w.fsw.Events() t := time.NewTimer(window) defer t.Stop() for { select { case <-eventsCh: t.Reset(window) case <-t.C: return } } } // Stop halts the watcher and cleans up resources. func (w *Watcher) Stop() error { // Stop the adaptive poller first so a poll cycle in flight can't // dispatch a patch into a half-torn-down watcher. if w.poller != nil { w.poller.Stop() } close(w.done) if w.fsCancel != nil { w.fsCancel() } if w.fsw != nil { w.fsw.Close() } // In slow-mount degraded mode the fsnotify loop never ran, so its // stopped channel is never closed — don't block on it. if !w.degradedNoFsnotify { <-w.stopped } return nil } // Events returns a read-only channel of graph change events. func (w *Watcher) Events() <-chan GraphChangeEvent { return w.events } // History returns recent change events (up to maxHistory). func (w *Watcher) History() []GraphChangeEvent { w.historyMu.Lock() defer w.historyMu.Unlock() out := make([]GraphChangeEvent, len(w.history)) copy(out, w.history) return out } // HistorySince returns change events after the given timestamp. func (w *Watcher) HistorySince(since time.Time) []GraphChangeEvent { w.historyMu.Lock() defer w.historyMu.Unlock() var out []GraphChangeEvent for _, ev := range w.history { if ev.Timestamp.After(since) { out = append(out, ev) } } return out } // OnSymbolChange registers a callback that is invoked when symbols change // during file re-indexing. The callback receives old symbols (before eviction) // and new symbols (after re-index). func (w *Watcher) OnSymbolChange(cb SymbolChangeCallback) { w.symbolChangeCbMu.Lock() defer w.symbolChangeCbMu.Unlock() w.symbolChangeCb = cb } // OnDegraded registers a callback invoked once when the file watcher first // enters a degraded state (inotify / FD exhaustion). The daemon wires it to its // health push-notification channel so a subscribed agent learns the index may // be frozen without polling. func (w *Watcher) OnDegraded(cb func(reason string)) { w.degradedMu.Lock() w.degradedCb = cb w.degradedMu.Unlock() } // DegradedReason returns a human-readable explanation when the native file // watcher is running degraded — inotify watch exhaustion or FD exhaustion — so // live edits may not reach the graph until a reindex. Empty when watching is // healthy. Read tools surface this as a whole-index "frozen" banner, distinct // from a per-file stale flag. func (w *Watcher) DegradedReason() string { w.degradedMu.RLock() defer w.degradedMu.RUnlock() return w.degradedReason } // isInotifyExhausted reports whether err is the inotify watch-limit error // (ENOSPC) — the kernel ran out of `fs.inotify.max_user_watches`. func isInotifyExhausted(err error) bool { return errors.Is(err, syscall.ENOSPC) } // isFDExhausted reports whether err is a file-descriptor-exhaustion error // (EMFILE per-process, ENFILE system-wide). func isFDExhausted(err error) bool { return errors.Is(err, syscall.EMFILE) || errors.Is(err, syscall.ENFILE) } // noteWatchDegraded records a watcher-degradation cause and logs a one-time // operator warning (subsequent calls are silent — "warns once"). ENOSPC names // the inotify watch-limit sysctl; FD exhaustion advises raising the open-file // limit. The first occurrence also fires the optional degraded callback so the // daemon can push the notice. Returns true on the first (logged) call. func (w *Watcher) noteWatchDegraded(err error) bool { var reason, logMsg string switch { case isInotifyExhausted(err): reason = "inotify watch limit reached — the graph may miss live edits until you raise fs.inotify.max_user_watches and reindex (the adaptive poller covers some changes)" logMsg = "watcher: inotify watch limit (ENOSPC) — watches partially installed; raise fs.inotify.max_user_watches. Falling back to the adaptive poller for missed changes" case isFDExhausted(err): reason = "open-file limit reached — the watcher is degraded until you raise the process file-descriptor limit (ulimit -n) and reindex (the adaptive poller covers some changes)" logMsg = "watcher: file-descriptor limit (EMFILE/ENFILE) — watcher degraded; raise ulimit -n. Falling back to the adaptive poller" default: return false } w.degradedMu.Lock() first := !w.degradedLogged w.degradedReason = reason w.degradedLogged = true cb := w.degradedCb w.degradedMu.Unlock() if first { if w.logger != nil { w.logger.Warn(logMsg) } if cb != nil { cb(reason) } } return first } func (w *Watcher) loop() { defer close(w.stopped) if w.fsw == nil { // Test path: handleEvent is being driven directly without // having called Start. Block until Stop closes w.done. <-w.done return } eventsCh := w.fsw.Events() droppedCh := w.fsw.Dropped() for { select { case <-w.done: return case event, ok := <-eventsCh: if !ok { return } w.handleEvent(event) case _, ok := <-droppedCh: if !ok { // Backend tore down its dropped channel; keep // draining Events only. droppedCh = nil continue } // The backend dropped an event under backpressure (the // main Events channel was full). We don't know which path // was lost, so reconcile the whole tree. w.triggerOverflowReconcile("dropped-event") } } } // guardWatcherPanic recovers a panic in a watcher background goroutine — // a debounced patch, a storm drain, an overflow reconcile, or a // new-directory scan. Those goroutines call into the graph store, and // store_sqlite turns a fatal storage error (a closed DB during a daemon // restart, a busy/locked DB, disk-full) into a panic via panicOnFatal. // The MCP tool path has its own firewall (wrapToolHandler); these // fsnotify-driven goroutines don't route through it, so without this a // single transient store error during a restart or rebuild takes the // whole daemon down. Recovering aborts just that unit of work — the file // stays stale until the next event or the reconcile janitor — instead of // crashing the process. func (w *Watcher) guardWatcherPanic(op string) { if r := recover(); r != nil && w.logger != nil { w.logger.Error("watcher: recovered from panic in background re-index", zap.String("op", op), zap.Any("panic", r), zap.Stack("stack")) } } // triggerOverflowReconcile schedules a single coalesced full-tree // reconcile in response to a lost-event signal (a kernel inotify queue // overflow or a backpressure-dropped event). A burst of signals // collapses into at most one reconcile in flight: the first caller sets // reconcilePending and runs the reconcile off the event loop; concurrent // callers observe the flag and return immediately. Best-effort and // logged — the event loop is never blocked. func (w *Watcher) triggerOverflowReconcile(reason string) { w.reconcileMu.Lock() if w.reconcilePending { w.reconcileMu.Unlock() return } w.reconcilePending = true fn := w.reconcileFn w.reconcileMu.Unlock() if w.logger != nil { w.logger.Warn("watcher: event signal lost — scheduling full-tree reconcile", zap.String("reason", reason), zap.String("root", w.indexer.rootPath)) } go func() { defer func() { w.reconcileMu.Lock() w.reconcilePending = false w.reconcileMu.Unlock() }() defer w.guardWatcherPanic("overflow-reconcile") if fn != nil { fn() return } if _, err := w.indexer.IncrementalReindex(w.indexer.rootPath); err != nil { if w.logger != nil { w.logger.Warn("watcher: overflow reconcile failed", zap.String("reason", reason), zap.Error(err)) } } }() } // dirScanEscalateCap bounds the scoped new-directory scan: a burst that // creates more than this many directories (a large checkout or unpack) // escalates to a single full-tree reconcile instead of fanning out into // that many scoped subtree walks. const dirScanEscalateCap = 64 // enqueueDirScan schedules a scoped re-index of a newly-created // directory's subtree, closing the new-subdir race: on Linux inotify a // file written into a directory before its watch attaches fires no // event. A burst of directory creates coalesces into a single in-flight // drainer (mirrors triggerOverflowReconcile) — the first caller starts // the goroutine, concurrent callers add their directory to // pendingScanDirs and return. The drainer loops until the set is empty, // so a directory enqueued while a scan is in flight is still picked up; // nothing is lost and there is no debounce-timing race. func (w *Watcher) enqueueDirScan(dir string) { w.reconcileMu.Lock() if w.pendingScanDirs == nil { w.pendingScanDirs = make(map[string]struct{}) } w.pendingScanDirs[dir] = struct{}{} if w.dirScanActive { w.reconcileMu.Unlock() return } w.dirScanActive = true w.reconcileMu.Unlock() go func() { for { w.reconcileMu.Lock() dirs := w.pendingScanDirs w.pendingScanDirs = nil if len(dirs) == 0 { w.dirScanActive = false w.reconcileMu.Unlock() return } fn := w.scanFn w.reconcileMu.Unlock() func() { defer w.guardWatcherPanic("dir-scan") w.runDirScan(dirs, fn) }() } }() } // runDirScan re-indexes the accumulated new directories. A large burst // escalates to one full-tree reconcile (dirScanEscalateCap); otherwise // the scoped subtrees are walked in a single IncrementalReindexPaths // call, which IsStale-gates each file so already-current files cost only // a stat. fn is the test seam. func (w *Watcher) runDirScan(dirs map[string]struct{}, fn func(map[string]struct{})) { if fn != nil { fn(dirs) return } if len(dirs) > dirScanEscalateCap { if w.logger != nil { w.logger.Info("watcher: large new-directory burst — full-tree reconcile", zap.Int("dirs", len(dirs)), zap.String("root", w.indexer.rootPath)) } if _, err := w.indexer.IncrementalReindex(w.indexer.rootPath); err != nil && w.logger != nil { w.logger.Warn("watcher: new-directory reconcile failed", zap.Error(err)) } return } paths := make([]string, 0, len(dirs)) for d := range dirs { paths = append(paths, d) } if _, err := w.indexer.IncrementalReindexPaths(w.indexer.rootPath, paths); err != nil && w.logger != nil { w.logger.Warn("watcher: new-directory scan failed", zap.Strings("dirs", paths), zap.Error(err)) } } // hasEventType reports whether the aggregated event-type set contains want. func hasEventType(types []fswatcher.EventType, want fswatcher.EventType) bool { for _, t := range types { if t == want { return true } } return false } func (w *Watcher) handleEvent(event fswatcher.WatchEvent) { // Kernel queue overflow arrives as a pathless EventOverflow on the // Events channel: the Linux inotify and Windows backends emit it when // the kernel drops events and cannot tell us which paths were lost. // macOS FSEvents never emits it — the darwin backend absorbs // UserDropped/KernelDropped by re-scanning the affected subtree // internally — so this branch is effectively Linux/Windows-only. With // no path to re-index, trigger a coalesced full-tree reconcile and // stop; every path-based step below would misfire on the empty path. for _, t := range event.Types { if t == fswatcher.EventOverflow { w.triggerOverflowReconcile("queue-overflow") return } } path := normalizeEventPath(event.Path, w.indexer.rootPath) // Probe artifacts: sentinel files Start writes to confirm the // OS-level watch is actually active. Their create event signals // the registered waiter; their remove event (after Start removes // the file) is silently absorbed so it never reaches user-visible // event consumers. if strings.Contains(filepath.Base(path), probeMarker) { if v, loaded := w.probeWaiters.LoadAndDelete(path); loaded { if ch, ok := v.(chan struct{}); ok { close(ch) } } return } // Skip events from excluded paths. A single matcher call covers // what the old code split across inExcludedDir + isExcluded. if w.isExcluded(path) { return } kind := pickKind(event.Types) if kind == "" { return } // Directory events. fswatcher with WatchNested attaches the watch // for a new directory itself, so we never re-attach. But on Linux // inotify that watch lands only AFTER the directory's create event is // read, so a file written into the directory in that gap fires no // event and would stay invisible until the hourly janitor. When the // event carries a Create, scan the new directory's subtree on disk so // those pre-watch files are picked up regardless of whether an event // ever fired ("watch first, then scan": files created after the watch // fire normal events, files created before are caught by the scan, // and the overlap is at worst a redundant idempotent re-index). A dir // event without a Create — a bare mtime bump on an existing dir — // needs no scan: entry changes inside it fire their own file events. // Either way the directory event itself reaches no indexer logic. if kind == ChangeCreated || kind == ChangeModified { if info, err := os.Stat(path); err == nil && info.IsDir() { if hasEventType(event.Types, fswatcher.EventCreate) { w.enqueueDirScan(path) } return } } // Only process files with a detectable language — an extension // the registry knows, or an unknown-extension script the shebang // fallback can place. if _, ok := w.indexer.effectiveLanguage(path, nil); !ok { // Still handle remove for previously indexed files. if kind != ChangeDeleted && kind != ChangeRenamed { return } } // Storm mode — if more than StormThreshold events arrived within // StormWindowMs, skip the per-file debounced path and accumulate // into a batch. The batch drains once StormQuietPeriodMs has // passed with no further events. if w.shouldEnterStorm() { w.recordInStorm(path, kind) return } // Debounce: reset or start timer for this file. w.mu.Lock() if timer, exists := w.pending[path]; exists { timer.Stop() } debounce := time.Duration(w.config.DebounceMs) * time.Millisecond w.pending[path] = time.AfterFunc(debounce, func() { // Clean up the pending entry even if the patch panics, then // recover so a fatal store error can't crash the daemon. defer func() { w.mu.Lock() delete(w.pending, path) w.mu.Unlock() }() defer w.guardWatcherPanic("patch " + path) w.patchGraph(path, kind) }) w.mu.Unlock() } // shouldEnterStorm records the current event in the rate window and // reports whether the watcher is over threshold. Returns false when // storm mode is disabled (threshold <= 0). The returned-true path // guarantees the caller will enqueue to the batch, so any single // event that crosses the threshold is captured correctly. func (w *Watcher) shouldEnterStorm() bool { if w.config.StormThreshold <= 0 { return false } now := time.Now() window := time.Duration(w.config.StormWindowMs) * time.Millisecond cutoff := now.Add(-window) w.stormMu.Lock() defer w.stormMu.Unlock() // Already batching — stay in storm until the drain completes. if w.stormActive { return true } // Drop timestamps older than the window. The slice is append-only // so a linear scan from the front is the minimal thing that // works; the window is O(threshold) bounded in steady state. trimFrom := 0 for i, t := range w.eventTimes { if t.After(cutoff) { trimFrom = i break } trimFrom = i + 1 } if trimFrom > 0 { w.eventTimes = w.eventTimes[trimFrom:] } w.eventTimes = append(w.eventTimes, now) return len(w.eventTimes) > w.config.StormThreshold } // recordInStorm adds the event to the pending batch and resets the // drain timer. Repeated create/modify collapse to a single patch; a // later delete of the same path overwrites an earlier create so the // drain does the right final thing (treats the path as deleted). func (w *Watcher) recordInStorm(path string, kind ChangeKind) { w.stormMu.Lock() defer w.stormMu.Unlock() w.stormActive = true // Cancel any pending per-file timers for this path — storm mode // takes over. w.mu.Lock() if timer, exists := w.pending[path]; exists { timer.Stop() delete(w.pending, path) } w.mu.Unlock() w.stormBatch[path] = kind quiet := time.Duration(w.config.StormQuietPeriodMs) * time.Millisecond if w.stormTimer != nil { w.stormTimer.Stop() } w.stormTimer = time.AfterFunc(quiet, w.drainStorm) } // drainStorm processes every path accumulated during the storm as a // single batch: per-path evict/index with the resolver stage skipped, // then one global ResolveAll at the end. Cuts a 500-file checkout // from "resolver runs 500 times" to "resolver runs once." func (w *Watcher) drainStorm() { defer w.guardWatcherPanic("storm-drain") w.stormMu.Lock() batch := w.stormBatch w.stormBatch = make(map[string]ChangeKind) w.eventTimes = nil w.stormActive = false drained := w.stormDrained w.stormMu.Unlock() if len(batch) == 0 { return } start := time.Now() w.logger.Info("watcher: storm drain starting", zap.Int("paths", len(batch))) for path, kind := range batch { w.patchGraphNoResolve(path, kind) } w.indexer.ResolveAll() w.logger.Info("watcher: storm drain complete", zap.Int("paths", len(batch)), zap.Duration("elapsed", time.Since(start))) if drained != nil { drained(len(batch)) } } // patchGraphNoResolve is patchGraph for the batched path: same evict // / index dispatch, but without per-file resolver work. The caller is // responsible for running indexer.ResolveAll() after the batch. func (w *Watcher) patchGraphNoResolve(path string, kind ChangeKind) { // Same disk-truth reconciliation as patchGraph: a storm-batched // replace must not be evicted as a delete when the file is present. kind = w.reconcileKindWithDisk(path, kind) switch kind { case ChangeCreated, ChangeModified: if err := w.indexer.IndexFileNoResolve(path); err != nil { w.logger.Warn("storm: index file failed", zap.String("path", path), zap.Error(err)) } case ChangeDeleted, ChangeRenamed: w.indexer.EvictFile(path) // Keep the persisted mtime in step with the eviction (see // forgetDeletedFileMtime) so a warm restart after a storm-drain // delete does not treat the vanished path as a phantom deletion. w.forgetDeletedFileMtime(w.indexer.RelKey(path)) } } // reconcileKindWithDisk corrects an event kind against the file's actual // on-disk state at patch time. FSEvents accumulates flags per path, so an // atomic replace — git checkout writing a temp file and renaming it over the // target, or an unlink + recreate — surfaces with ItemRemoved / ItemRenamed // set even though a file is right back at the same path with a new inode. // pickKind ranks Remove and Rename above Modify, so it classifies that replace // as a deletion; the delete branch then EvictFiles the definition and stubs // its cross-file callers with nothing to rebind them, and find_usages goes // silently to zero. By the time the debounced patch runs the filesystem has // settled, so the path's existence is authoritative: a delete/rename whose // path is still a regular file is really a modify (re-parse + rebind incoming // refs), and a create/modify whose path has vanished is really a delete. An // in-place write (same inode) is already a bare Modify, so it is unaffected. func (w *Watcher) reconcileKindWithDisk(path string, kind ChangeKind) ChangeKind { info, err := os.Stat(path) exists := err == nil && info.Mode().IsRegular() switch kind { case ChangeDeleted, ChangeRenamed: if exists { return ChangeModified } case ChangeCreated, ChangeModified: if !exists { return ChangeDeleted } } return kind } // forgetDeletedFileMtime drops a just-evicted file's recorded mtime from // both the in-memory map and the store's FileMtime sidecar. EvictFile // removes the file's nodes but leaves its mtime behind, so without this the // persisted mtime row outlives the file: the next warm restart reads it // back, finds the path gone from disk, and treats it as a phantom deletion // — re-running a scoped reconcile for a file that is already correct on // every boot. Mirrors IncrementalReindex's deletion handling: prune the // in-memory map first (pruneDeletedFileMtimes documents that its caller has // already done so, and a later snapshot persist would otherwise resurrect // the row from the stale in-memory entry), then the store, which self-skips // on a backend without the FileMtimeDeleter capability. relPath must be the // canonical relKey the mtime map and store are keyed on — the same key // EvictFile evicted the file's nodes under. func (w *Watcher) forgetDeletedFileMtime(relPath string) { w.indexer.mtimeMu.Lock() delete(w.indexer.fileMtimes, relPath) w.indexer.mtimeMu.Unlock() w.indexer.pruneDeletedFileMtimes([]string{relPath}) } func (w *Watcher) patchGraph(path string, kind ChangeKind) { w.patchMu.Lock() defer w.patchMu.Unlock() // A replace/revert (rename-over or unlink+recreate) reaches us as a // delete/rename even though a file is right back at the same path; // reconcile against disk so it takes the parse-then-swap + incoming- // rebind modify path instead of a hard evict that would silently zero // the definition's callers. A vanished create/modify becomes a delete. kind = w.reconcileKindWithDisk(path, kind) start := time.Now() var nodesAdded, nodesRemoved, edgesAdded, edgesRemoved int // Compute the relative path for snapshotting old symbols. RelKey // folds it to the canonical key (slash form, Unicode NFC) so the // GetFileNodes / snapshotSymbols lookups below hit the same graph // key the indexer stored — a watcher event for a non-ASCII-named // file arrives in the filesystem's Unicode form (NFD on macOS), // which would otherwise miss an NFC-keyed node. relPath := path if w.indexer.rootPath != "" { relPath = w.indexer.RelKey(path) } switch kind { case ChangeCreated: if err := w.indexer.IndexFile(path); err != nil { w.logger.Warn("index file failed", zap.String("path", path), zap.Error(err)) return } newSymbols := w.indexer.graph.GetFileNodes(relPath) nodesAdded = len(newSymbols) edgesAdded = w.countFileEdges(newSymbols) // Notify callback: no old symbols, only new symbols. w.symbolChangeCbMu.RLock() cb := w.symbolChangeCb w.symbolChangeCbMu.RUnlock() if cb != nil { cb(relPath, nil, newSymbols) } case ChangeModified: // Snapshot old symbols before eviction. oldSymbols := w.snapshotSymbols(relPath) // Content-aware skip: if the saved file's structural symbols // are byte-for-byte identical to the ones already in the // graph, the change touched no Function / Type / Method / // etc. — a comment-only edit, a whitespace reflow, a doc // change, or a config / JSON value save. Re-indexing it would // evict and rebuild every node for no graph-level effect, so // skip the structural reindex entirely. The probe is // read-only; only on a proven match do we take the cheap // path. A probe that can't run (unknown language, over-cap // file, parser quarantine) returns ok == false and falls // through to the normal reindex. if newSymbols, ok := w.indexer.StructuralSymbols(path); ok && structuralFingerprint(newSymbols) == structuralFingerprint(oldSymbols) { w.recordInertModify(path, relPath, oldSymbols, start) return } // Do NOT pre-evict. IndexFile parse-then-swaps internally: it // evicts the file's prior nodes and re-adds the new ones only on a // successful parse, and leaves the prior nodes intact on a parse // failure. Pre-evicting here was the node-loss bug — a transiently // unparseable save (mid-edit) dropped the file's symbols from the // graph until the next clean save. Capture the file's prior node // count first (still present pre-swap) so removed/added telemetry // stays gross: a rename removes one node and adds one even though // the net node delta is zero. priorNodes := w.indexer.graph.GetFileNodes(relPath) fileEdgesBefore := w.countFileEdges(priorNodes) resolvedBefore := w.countResolvedFileEdges(priorNodes) incomingBeforeByID := w.resolvedIncomingByNode(priorNodes) if err := w.indexer.IndexFile(path); err != nil { w.logger.Warn("reindex file failed", zap.String("path", path), zap.Error(err)) return } nodesRemoved = len(priorNodes) newSymbols := w.indexer.graph.GetFileNodes(relPath) nodesAdded = len(newSymbols) // Edge churn scoped to this file's nodes. A graph-wide // EdgeCount delta would also pick up edges landed by whatever // else mutates the graph during this patch (concurrent // reconciles, deferred passes), which made the edges+ figure // meaningless noise on a busy daemon. if fileEdgesAfter := w.countFileEdges(newSymbols); fileEdgesAfter >= fileEdgesBefore { edgesAdded = fileEdgesAfter - fileEdgesBefore } else { edgesRemoved = fileEdgesBefore - fileEdgesAfter } // Shape-degradation guard: a modify that kept its symbols but lost // most of its resolved edges is a transient resolution failure, not a // real deletion — flag it and enqueue a forced scoped re-resolve so it // self-heals instead of persisting the degraded shape. incomingBefore, incomingAfter := w.incomingRegressionForSurvivors(incomingBeforeByID, newSymbols) w.guardResolvedEdgeRegression(path, len(priorNodes), len(newSymbols), resolvedBefore, w.countResolvedFileEdges(newSymbols), incomingBefore, incomingAfter) // Notify callback with old and new symbols. w.symbolChangeCbMu.RLock() cb := w.symbolChangeCb w.symbolChangeCbMu.RUnlock() if cb != nil { cb(relPath, oldSymbols, newSymbols) } case ChangeDeleted, ChangeRenamed: // Snapshot old symbols before eviction. oldSymbols := w.snapshotSymbols(relPath) nr, er := w.indexer.EvictFile(path) nodesRemoved = nr edgesRemoved = er // The file is genuinely gone from disk here — reconcileKindWithDisk // already downgraded a replace/revert (path still present) to // ChangeModified. Drop its now-orphaned mtime so a warm restart does // not re-discover the vanished path as a phantom deletion. relPath is // the canonical relKey EvictFile evicted under. w.forgetDeletedFileMtime(relPath) // Notify callback: old symbols removed, no new symbols. w.symbolChangeCbMu.RLock() cb := w.symbolChangeCb w.symbolChangeCbMu.RUnlock() if cb != nil { cb(relPath, oldSymbols, nil) } } ev := GraphChangeEvent{ FilePath: path, Kind: kind, NodesAdded: nodesAdded, NodesRemoved: nodesRemoved, EdgesAdded: edgesAdded, EdgesRemoved: edgesRemoved, Timestamp: time.Now(), DurationMs: time.Since(start).Milliseconds(), } // Rebuild the reachability index so AnalyzeImpact / // explain_change_impact stay correct against the patched topology. // Lazy reach: instead of eagerly recomputing every seed's reach // after a watcher-driven patch (the old reach.BuildIndex call // here paid the full O(seeds) cost on every file edit), we just // invalidate the build counter so subsequent AnalyzeImpact calls // recompute on demand against the fresh graph. No-op patches // (nodesAdded == 0 && nodesRemoved == 0 && edgesAdded == 0 && // edgesRemoved == 0) leave the counter alone so existing caches // stay valid. if nodesAdded+nodesRemoved+edgesAdded+edgesRemoved > 0 { reach.InvalidateIndex() } w.historyMu.Lock() w.history = append(w.history, ev) if len(w.history) > maxHistory { w.history = w.history[len(w.history)-maxHistory:] } w.historyMu.Unlock() // Non-blocking send. select { case w.events <- ev: default: } w.logger.Info("graph patch", zap.String("kind", string(kind)), zap.String("file", path), zap.Int("nodes+", nodesAdded), zap.Int("nodes-", nodesRemoved), zap.Int("edges+", edgesAdded), zap.Int("edges-", edgesRemoved), zap.Int64("ms", ev.DurationMs), ) } // countFileEdges counts the edges incident to the given file nodes: // every out-edge plus the in-edges that originate outside the file // (an intra-file edge is already counted on its From side). Batched // so a disk backend pays two bulk lookups instead of 2N point queries. func (w *Watcher) countFileEdges(nodes []*graph.Node) int { if len(nodes) == 0 { return 0 } ids := make([]string, 0, len(nodes)) inFile := make(map[string]struct{}, len(nodes)) for _, n := range nodes { ids = append(ids, n.ID) inFile[n.ID] = struct{}{} } total := 0 for _, edges := range w.indexer.graph.GetOutEdgesByNodeIDs(ids) { total += len(edges) } for _, edges := range w.indexer.graph.GetInEdgesByNodeIDs(ids) { for _, e := range edges { if _, ok := inFile[e.From]; !ok { total++ } } } return total } // resolvedEdgeRegressionFloor is the minimum pre-patch resolved-edge count // below which the shape-degradation guard stays quiet — a 1→0 or 3→1 file is // noise, not a resolution collapse worth self-healing. const resolvedEdgeRegressionFloor = 4 // countResolvedFileEdges counts this file's OUTGOING edges whose target is a // concrete (resolved) node — an edge pointing at an `unresolved::` stub does // not count. Restricted to out-edges: an incoming edge's resolution state is // owned by the OTHER file, not this one. This is the signal countFileEdges // cannot give: an edge demoted from a resolved target to a stub keeps the total // incident-edge count identical while losing a resolution. func (w *Watcher) countResolvedFileEdges(nodes []*graph.Node) int { if len(nodes) == 0 { return 0 } ids := make([]string, 0, len(nodes)) for _, n := range nodes { ids = append(ids, n.ID) } total := 0 for _, edges := range w.indexer.graph.GetOutEdgesByNodeIDs(ids) { for _, e := range edges { if e != nil && !graph.IsUnresolvedTarget(e.To) { total++ } } } return total } // guardResolvedEdgeRegression fires when a modify patch lost most of its // resolved edges on either side of the graph: the file's own out-edges dropped // while it kept its symbols, OR a surviving definition lost most of the callers // bound into it. Both are transient resolution failures (a dependency mid-write, // an LSP provider not yet warm, an external revert re-parsing a definition // file), not real deletions. It logs loudly, bumps the process-global // regression counter, and enqueues the file for a forced scoped re-resolve so // the graph self-heals instead of persisting the degraded shape. func (w *Watcher) guardResolvedEdgeRegression(path string, nodesBefore, nodesAfter, resolvedBefore, resolvedAfter, incomingBefore, incomingAfter int) { // Out-edge regression: this file's own references lost their resolutions // while it kept (or grew) its symbols. Gated on nodesAfter >= nodesBefore — // a symbol removal legitimately drops out-edges. outRegressed := resolvedBefore >= resolvedEdgeRegressionFloor && nodesAfter >= nodesBefore && resolvedAfter*2 < resolvedBefore // Incoming-edge regression: a definition that SURVIVED the re-parse lost // most of the callers bound INTO it. The caller restricts the counts to // surviving definitions, so a genuinely deleted symbol's lost callers do // not read as a loss — which is why this arm is deliberately NOT gated on // nodesAfter >= nodesBefore. An external revert removes the appended probe // symbol (node count drops) yet the definition it leaves behind must keep // its incoming resolved edges; their loss is a resolution failure, not a // deletion, and without this arm the revert never self-heals the way the // symmetric add does. inRegressed := incomingBefore >= resolvedEdgeRegressionFloor && incomingAfter*2 < incomingBefore if !outRegressed && !inRegressed { return } RecordResolutionRegression() if w.logger != nil { w.logger.Warn("watcher: resolved-edge regression — file kept its symbols but lost most resolved edges; enqueuing forced scoped re-resolve", zap.String("file", path), zap.Int("nodes_before", nodesBefore), zap.Int("nodes_after", nodesAfter), zap.Int("resolved_edges_before", resolvedBefore), zap.Int("resolved_edges_after", resolvedAfter), zap.Int("incoming_resolved_before", incomingBefore), zap.Int("incoming_resolved_after", incomingAfter)) } w.enqueueReresolve(path) } // resolvedIncomingByNode returns, per referenceable definition node in `nodes`, // the count of resolvable reference edges currently bound INTO it (callers, // type/field references, …). countResolvedFileEdges is out-edge-only and so is // structurally blind to a definition losing its incoming callers on a re-parse // — this is the signal that catches an external revert zeroing a surviving // symbol's usages. func (w *Watcher) resolvedIncomingByNode(nodes []*graph.Node) map[string]int { if len(nodes) == 0 { return nil } ids := make([]string, 0, len(nodes)) for _, n := range nodes { if n != nil && graph.IsReferenceableSymbol(n.Kind) { ids = append(ids, n.ID) } } if len(ids) == 0 { return nil } byNode := make(map[string]int, len(ids)) for id, edges := range w.indexer.graph.GetInEdgesByNodeIDs(ids) { c := 0 for _, e := range edges { if e != nil && graph.IsResolvableRefEdge(e.Kind) { c++ } } if c > 0 { byNode[id] = c } } return byNode } // incomingRegressionForSurvivors sums the incoming resolved-edge counts, before // and after the re-parse, over only the definitions that SURVIVED it (present // in both snapshots by node ID). Restricting to survivors keeps a genuinely // deleted symbol's lost callers from reading as a regression, so the guard's // incoming arm fires only when a symbol that is still defined lost the callers // bound into it. `before` is the resolvedIncomingByNode snapshot captured on // the pre-re-parse nodes; `after` is the file's fresh node set. func (w *Watcher) incomingRegressionForSurvivors(before map[string]int, after []*graph.Node) (sumBefore, sumAfter int) { if len(before) == 0 || len(after) == 0 { return 0, 0 } afterByID := w.resolvedIncomingByNode(after) for _, n := range after { if n == nil { continue } b, ok := before[n.ID] if !ok { continue } sumBefore += b sumAfter += afterByID[n.ID] } return sumBefore, sumAfter } // enqueueReresolve batches shape-degraded files for a forced scoped re-resolve. // Copies enqueueDirScan's coalescing drainer so a save-storm of degraded files // runs at most one drainer goroutine and only ever O(file) scoped re-resolves // (never a whole-graph pass). reresolveFn is a test seam. func (w *Watcher) enqueueReresolve(path string) { w.reconcileMu.Lock() if w.pendingReresolve == nil { w.pendingReresolve = make(map[string]struct{}) } w.pendingReresolve[path] = struct{}{} if w.reresolveActive { w.reconcileMu.Unlock() return } w.reresolveActive = true w.reconcileMu.Unlock() go func() { for { w.reconcileMu.Lock() files := w.pendingReresolve w.pendingReresolve = nil if len(files) == 0 { w.reresolveActive = false w.reconcileMu.Unlock() return } fn := w.reresolveFn w.reconcileMu.Unlock() func() { defer w.guardWatcherPanic("reresolve") if fn != nil { fn(files) return } for p := range files { if err := w.indexer.ReresolveFileScoped(p); err != nil && w.logger != nil { w.logger.Warn("watcher: forced scoped re-resolve failed", zap.String("file", p), zap.Error(err)) } } }() } }() } // recordInertModify finishes a ChangeModified patch that the // content-aware skip proved structurally inert. The graph already // holds the correct symbols, so the destructive evict + reindex is // skipped; this records the bookkeeping the skipped path would // otherwise have produced: // // - the indexer's recorded mtime is restamped so the adaptive // poller's mtime sweep does not keep re-flagging the file; // - a zero-delta GraphChangeEvent is appended to history and // published, so get_recent_changes still shows the save (with // all node/edge counts zero — nothing structural moved); // - the symbol-change callback fires with the unchanged symbol set // on both sides, mirroring the no-op so consumers see a // consistent before == after. // // The reachability index is intentionally not rebuilt — the topology // did not change, so the existing reach stamps stay valid. func (w *Watcher) recordInertModify(path, relPath string, symbols []*graph.Node, start time.Time) { // Advance the recorded mtime past this save so the poller does // not treat the (untouched) file as perpetually stale. w.indexer.RefreshFileMtime(path) ev := GraphChangeEvent{ FilePath: path, Kind: ChangeModified, Timestamp: time.Now(), DurationMs: time.Since(start).Milliseconds(), } w.historyMu.Lock() w.history = append(w.history, ev) if len(w.history) > maxHistory { w.history = w.history[len(w.history)-maxHistory:] } w.historyMu.Unlock() select { case w.events <- ev: default: } w.symbolChangeCbMu.RLock() cb := w.symbolChangeCb w.symbolChangeCbMu.RUnlock() if cb != nil { cb(relPath, symbols, symbols) } w.logger.Info("graph patch skipped: structurally inert change", zap.String("file", path), zap.Int64("ms", ev.DurationMs), ) } // snapshotSymbols returns a deep copy of the symbols for a file, preserving // their signatures in Meta so they can be compared after re-indexing. func (w *Watcher) snapshotSymbols(relPath string) []*graph.Node { nodes := w.indexer.graph.GetFileNodes(relPath) snapshot := make([]*graph.Node, 0, len(nodes)) for _, n := range nodes { // Skip file and import nodes — we only track code symbols. if n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } cp := &graph.Node{ ID: n.ID, Kind: n.Kind, Name: n.Name, QualName: n.QualName, FilePath: n.FilePath, } if sig, ok := n.Meta["signature"]; ok { cp.Meta = map[string]any{"signature": sig} } snapshot = append(snapshot, cp) } return snapshot } // structuralFingerprint reduces a set of symbols to an order-independent // string identity built only from each structural symbol's kind, name, // qualified name, and signature — never its line range. Two snapshots // of the same file taken before and after an edit produce an equal // fingerprint exactly when the edit changed no structural symbol: a // comment-only change, a whitespace reflow, or a doc / config-value // edit shifts line numbers but leaves every (kind, name, sig) tuple // intact, while renaming a function, changing a signature, or // adding / removing a declaration changes the fingerprint. // // Non-structural nodes (file, import, params, closures, coverage-domain // kinds) are skipped so a change confined to them is still treated as // inert — they carry no structural graph shape. func structuralFingerprint(symbols []*graph.Node) string { lines := make([]string, 0, len(symbols)) for _, n := range symbols { if !isStructuralKind(n.Kind) { continue } sig, _ := n.Meta["signature"].(string) // NUL separates fields so a value containing the field // delimiter can't forge a collision across two symbols. lines = append(lines, string(n.Kind)+"\x00"+n.Name+"\x00"+n.QualName+"\x00"+sig) } sort.Strings(lines) return strings.Join(lines, "\n") } // normalizeEventPath aligns an event path emitted by the OS-level // backend with the form the indexer stored when it walked the tree. // // Two macOS-specific corrections are applied: // // - /private/ symlink resolution: FSEvents reports paths under // /private/var/... and /private/tmp/... even when the watcher was // registered with /var/... or /tmp/... — those are real // /private/-rooted symlinks. The indexer keyed its symbols by the // user-facing form, so without this we'd fail to find any symbols // to evict on modify or delete. // // - Unicode NFC folding: APFS / HFS+ hand back filenames in // decomposed NFD form, so a watcher event for a non-ASCII-named // file carries different bytes than the same file does in `git // diff` output or on a Linux checkout. Folding the path to NFC // here means every consumer downstream — the exclude matcher, the // storm batch, the per-file debounce map — sees one stable form. // IndexFile / EvictFile fold again at their own boundary, so this // is belt-and-braces, but it also keeps the debounce/batch maps // (keyed on this path directly) free of accidental NFD/NFC // duplicates for the same file. func normalizeEventPath(path, rootPath string) string { path = pathkey.Normalize(path) if runtime.GOOS != "darwin" { return path } if !strings.HasPrefix(path, "/private/") { return path } // Without a rootPath we have no way to know which form (the // /private/-prefixed canonical or the symlink form) the rest of // the daemon expects, so leave it alone. if rootPath == "" || strings.HasPrefix(rootPath, "/private/") { return path } stripped := path[len("/private"):] if !strings.HasPrefix(stripped, rootPath) { // Different prefix entirely — leave the canonical form alone. return path } return stripped } // pickKind reduces the aggregated event-type set from fswatcher to a // single ChangeKind. Priority: Remove > Rename > Modify > Create. // Modify outranks Create because FSEvents flags are cumulative — a // write to an existing file fires with both Create and Modify set, // and treating that as "created" loses the old-symbols snapshot the // modify path produces. An event with only types we don't act on // (e.g. chmod alone) returns "". func pickKind(types []fswatcher.EventType) ChangeKind { var hasCreate, hasModify, hasRemove, hasRename bool for _, t := range types { switch t { case fswatcher.EventCreate: hasCreate = true case fswatcher.EventMod: hasModify = true case fswatcher.EventRemove: hasRemove = true case fswatcher.EventRename: hasRename = true } } switch { case hasRemove: return ChangeDeleted case hasRename: return ChangeRenamed case hasModify: return ChangeModified case hasCreate: return ChangeCreated } return "" } // isExcluded reports whether path is excluded by the effective pattern list. func (w *Watcher) isExcluded(path string) bool { root := w.indexer.rootPath if root == "" { return w.excludes.MatchRel(filepath.Base(path)) } return w.excludes.MatchAbs(path, root) }