Summary
Watcher builds its ScopeIgnore once in start() and never rebuilds it. Because the MCP daemon is long-lived, a codegraph.json that is created or edited after the daemon started is invisible to the live watcher — while codegraph sync (which rebuilds the matcher per invocation) honours it immediately.
The result is that the CLI and the daemon disagree about project scope, and the watcher silently re-adds files the user just excluded. From the user side this looks like "exclude doesn't work", with the confusing detail that running codegraph sync does remove the file — and then it comes back a few seconds later.
To be clear: buildScopeIgnore() does read codegraph.json correctly (loadExcludeMatcher → loadExcludePatterns). This is purely a staleness issue, not a missing code path.
Reproduction
Minimal, on a 2-file scratch project. codegraph 1.5.0, Windows 11.
mkdir -p repro/keepme repro/skipme
printf 'local M = {}\nfunction M.hello() return 1 end\nreturn M\n' > repro/keepme/a.lua
printf 'local N = {}\nfunction N.world() return 2 end\nreturn N\n' > repro/skipme/b.lua
printf '{ "exclude": ["skipme/"] }\n' > repro/codegraph.json
cd repro
codegraph init .
# -> Indexed 1 files. files table = ['keepme/a.lua'] ✅ exclude honoured
# start a long-lived MCP server (this is what an agent client does)
codegraph serve --mcp # leave running in another shell
# 1) sanity: touching an already-excluded file does NOT re-add it ✅
printf 'local N = {}\nfunction N.world() return 3 end\nreturn N\n' > skipme/b.lua
sleep 8
sqlite3 .codegraph/codegraph.db 'select path from files'
# -> keepme/a.lua (correct)
# 2) now ADD a new exclude while the daemon is running
printf '{ "exclude": ["skipme/", "keepme/"] }\n' > codegraph.json
# 3) touch the newly-excluded file
printf 'local M = {}\nfunction M.hello() return 1 end\nfunction M.added() return 99 end\nreturn M\n' > keepme/a.lua
sleep 8
sqlite3 .codegraph/codegraph.db 'select path from files'
# -> keepme/a.lua ❌ still indexed
sqlite3 .codegraph/codegraph.db 'select name from nodes'
# -> M, a.lua, added, hello
# the new symbol `added` proves the watcher re-parsed the file
# AFTER it had been excluded
# 4) CLI sync on the same project, same config
codegraph sync .
# -> Removed: 1
sqlite3 .codegraph/codegraph.db 'select path from files'
# -> (empty) ✅ CLI honours the new exclude
Step 3 and step 4 read the same codegraph.json and reach opposite conclusions.
Expected
After codegraph.json changes, the live watcher applies the new exclude/include patterns — or at minimum, the daemon and codegraph sync agree on scope.
Actual
The watcher keeps enforcing the patterns that were in effect when the daemon started, for the daemon's entire lifetime. A restart is required, and nothing tells the user that.
Root cause
src/sync/watcher.ts — the matcher is captured once:
// in start()
this.ignoreMatcher = buildScopeIgnore(this.projectRoot);
and reused for every event:
private handleChange(rel: string): void {
if (!rel || rel === '.' || rel.startsWith('..')) return;
if (this.isAlwaysIgnored(rel)) return;
if (this.ignoreMatcher && this.ignoreMatcher.ignores(rel)) return;
if (!isSourceFile(rel, loadExtensionOverrides(this.projectRoot))) {
this.maybeScheduleForRemovedDir(rel);
return;
}
...
}
There is an asymmetry inside that one function worth pointing at: loadExtensionOverrides() is re-read per event, so codegraph.json extensions changes take effect live — but exclude/include do not, because they are baked into this.ignoreMatcher. Two fields of the same config file behave differently.
project-config.ts already mtime-caches (Map<string, CacheEntry> keyed by root, guarded by mtimeMs), so re-reading is one stat in the common case.
Impact
- Editing
codegraph.json in an editor that has an agent attached (the normal case) has no effect on the live index until the daemon is restarted, with no warning.
codegraph sync and the daemon can hold different views of scope indefinitely.
- Users who add
exclude to trim a noisy directory see it removed by sync and re-added by the watcher, which reads as the feature being broken.
Suggested fix
Rebuild ignoreMatcher when codegraph.json changes. Either:
- call
buildScopeIgnore(this.projectRoot) lazily per event behind the existing mtime cache (symmetric with the loadExtensionOverrides() call already on that line), or
- treat
codegraph.json as a watched path and rebuild the matcher on change.
Option 2 avoids per-event work, but option 1 is a one-line change and project-config.ts is already built for it.
Environment
- codegraph 1.5.0 (bundled runtime)
- Windows 11 Pro 26200, local NTFS drive
- Reproduced on a fresh 2-file project, and originally hit on a real 100-file project where the daemon had been running for 3 days before
codegraph.json was created.
Summary
Watcherbuilds itsScopeIgnoreonce instart()and never rebuilds it. Because the MCP daemon is long-lived, acodegraph.jsonthat is created or edited after the daemon started is invisible to the live watcher — whilecodegraph sync(which rebuilds the matcher per invocation) honours it immediately.The result is that the CLI and the daemon disagree about project scope, and the watcher silently re-adds files the user just excluded. From the user side this looks like "
excludedoesn't work", with the confusing detail that runningcodegraph syncdoes remove the file — and then it comes back a few seconds later.To be clear:
buildScopeIgnore()does readcodegraph.jsoncorrectly (loadExcludeMatcher→loadExcludePatterns). This is purely a staleness issue, not a missing code path.Reproduction
Minimal, on a 2-file scratch project. codegraph 1.5.0, Windows 11.
Step 3 and step 4 read the same
codegraph.jsonand reach opposite conclusions.Expected
After
codegraph.jsonchanges, the live watcher applies the newexclude/includepatterns — or at minimum, the daemon andcodegraph syncagree on scope.Actual
The watcher keeps enforcing the patterns that were in effect when the daemon started, for the daemon's entire lifetime. A restart is required, and nothing tells the user that.
Root cause
src/sync/watcher.ts— the matcher is captured once:and reused for every event:
There is an asymmetry inside that one function worth pointing at:
loadExtensionOverrides()is re-read per event, socodegraph.jsonextensionschanges take effect live — butexclude/includedo not, because they are baked intothis.ignoreMatcher. Two fields of the same config file behave differently.project-config.tsalready mtime-caches (Map<string, CacheEntry>keyed by root, guarded bymtimeMs), so re-reading is onestatin the common case.Impact
codegraph.jsonin an editor that has an agent attached (the normal case) has no effect on the live index until the daemon is restarted, with no warning.codegraph syncand the daemon can hold different views of scope indefinitely.excludeto trim a noisy directory see it removed bysyncand re-added by the watcher, which reads as the feature being broken.Suggested fix
Rebuild
ignoreMatcherwhencodegraph.jsonchanges. Either:buildScopeIgnore(this.projectRoot)lazily per event behind the existing mtime cache (symmetric with theloadExtensionOverrides()call already on that line), orcodegraph.jsonas a watched path and rebuild the matcher on change.Option 2 avoids per-event work, but option 1 is a one-line change and
project-config.tsis already built for it.Environment
codegraph.jsonwas created.