@colbymchenry/codegraph 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,16 @@
30
30
  * events are dropped before any sync is scheduled. Either way the watcher's
31
31
  * scope matches the indexer's (#276 / #407).
32
32
  */
33
+ import * as fs from 'fs';
34
+ /**
35
+ * Indirection over `fs.watch` so tests can inject a fake that throws or emits
36
+ * `EMFILE`/`ENFILE` deterministically (real watch-resource exhaustion can't be
37
+ * provoked reliably, and `fs.watch` is a non-configurable property so it can't
38
+ * be spied). Production always uses the real `fs.watch`.
39
+ */
40
+ type WatchFn = typeof fs.watch;
41
+ /** @internal Test-only seam to inject a fake fs.watch implementation. */
42
+ export declare function __setFsWatchForTests(fn: WatchFn | null): void;
33
43
  /**
34
44
  * Options for the file watcher
35
45
  */
@@ -51,6 +61,14 @@ export interface WatchOptions {
51
61
  * Callback when a sync errors (for logging/diagnostics).
52
62
  */
53
63
  onSyncError?: (error: Error) => void;
64
+ /**
65
+ * Callback fired ONCE when live watching degrades permanently and auto-sync
66
+ * is disabled — OS watch-resource exhaustion (EMFILE/ENFILE), or a write lock
67
+ * held past the retry budget. The string is an actionable, human-readable
68
+ * reason. Lets a host (MCP server, daemon, CLI) tell the user that the index
69
+ * will no longer auto-update instead of silently serving stale results.
70
+ */
71
+ onDegraded?: (reason: string) => void;
54
72
  /**
55
73
  * Test-only. When true, `start()` installs NO OS-level fs.watch — the
56
74
  * watcher is "inert" and only the {@link __emitWatchEventForTests} /
@@ -111,6 +129,22 @@ export declare class FileWatcher {
111
129
  private dirWatchers;
112
130
  /** Set once the per-directory watch cap is hit, so we log only once. */
113
131
  private dirCapWarned;
132
+ /**
133
+ * Set once the Linux inotify watch limit (ENOSPC) is hit. Double duty: we
134
+ * warn only once, AND we stop attempting new directory watches for the rest
135
+ * of the session — once the kernel budget is exhausted every further
136
+ * `inotify_add_watch` fails too, so trying the rest of the tree is pure
137
+ * waste. NON-fatal (does not degrade): installed watches keep working.
138
+ */
139
+ private inotifyLimitWarned;
140
+ /**
141
+ * One-way latch: the reason live watching was permanently disabled at runtime
142
+ * (watch-resource exhaustion, or lock contention past the retry budget), or
143
+ * null while healthy. Set by {@link degrade}; cleared only by a fresh start().
144
+ */
145
+ private degradedReason;
146
+ /** Consecutive lock-contention retries for watcher-triggered syncs. */
147
+ private lockRetryCount;
114
148
  /** Test-only inert mode: started, but with no OS watcher installed. */
115
149
  private inert;
116
150
  private debounceTimer;
@@ -152,6 +186,7 @@ export declare class FileWatcher {
152
186
  private readonly syncFn;
153
187
  private readonly onSyncComplete?;
154
188
  private readonly onSyncError?;
189
+ private readonly onDegraded?;
155
190
  private readonly inertForTests;
156
191
  constructor(projectRoot: string, syncFn: () => Promise<{
157
192
  filesChanged: number;
@@ -211,6 +246,31 @@ export declare class FileWatcher {
211
246
  * dir-only ignore rule like `build/` matches.
212
247
  */
213
248
  private shouldIgnoreDir;
249
+ /**
250
+ * Permanently disable live watching after a terminal runtime failure
251
+ * (watch-resource exhaustion, or lock contention past the retry budget).
252
+ * Idempotent: logs one actionable warning, fires {@link WatchOptions.onDegraded}
253
+ * once, and stops the watcher. A subsequent start() clears the latch.
254
+ */
255
+ private degrade;
256
+ /**
257
+ * Warn ONCE that the Linux inotify watch budget is exhausted (ENOSPC), and
258
+ * stop adding new watches for the rest of this session — every further
259
+ * `inotify_add_watch` would fail too, so walking the rest of the tree is
260
+ * waste. Unlike {@link degrade} this is NON-fatal: the watches already
261
+ * installed keep firing, and `codegraph sync` covers the unwatched remainder.
262
+ * The message names the kernel knob to raise (`fs.inotify.max_user_watches`).
263
+ */
264
+ private warnInotifyLimit;
265
+ /**
266
+ * Whether live watching has degraded permanently (until the next start()).
267
+ * Distinct from {@link isActive}: a degraded watcher is inactive, but an
268
+ * inactive watcher is not necessarily degraded (it may simply be stopped or
269
+ * never started). Hosts use this to tell the user auto-sync is off.
270
+ */
271
+ isDegraded(): boolean;
272
+ /** The reason live watching degraded, or null if it is healthy. */
273
+ getDegradedReason(): string | null;
214
274
  /**
215
275
  * Stop watching for file changes.
216
276
  */
@@ -238,9 +298,15 @@ export declare class FileWatcher {
238
298
  */
239
299
  waitUntilReady(timeoutMs?: number): Promise<void>;
240
300
  /**
241
- * Schedule a debounced sync.
301
+ * Schedule a normal debounced sync after a source edit.
242
302
  */
243
303
  private scheduleSync;
304
+ /**
305
+ * Schedule a retry after a recoverable sync failure (lock contention). Kept
306
+ * separate from {@link scheduleSync} so prolonged contention backs off
307
+ * exponentially instead of hammering the lock every debounce cycle.
308
+ */
309
+ private scheduleRetrySync;
244
310
  /**
245
311
  * Flush pending changes by running sync.
246
312
  *
@@ -280,4 +346,5 @@ export declare class FileWatcher {
280
346
  * test runtime, where the registry is intentionally not populated).
281
347
  */
282
348
  export declare function __emitWatchEventForTests(projectRoot: string, relPath: string): boolean;
349
+ export {};
283
350
  //# sourceMappingURL=watcher.d.ts.map
@@ -28,8 +28,6 @@ export type LifecycleEvent = 'install' | 'index' | 'uninstall';
28
28
  /** Coarse buckets — exact counts are deliberately not collected. */
29
29
  export declare function bucketFileCount(n: number): '<100' | '100-1k' | '1k-10k' | '10k+';
30
30
  export declare function bucketDuration(ms: number): '<10s' | '10-60s' | '1-5m' | '5m+';
31
- /** Collapse a backend identifier (e.g. `node-sqlite`) to the schema's enum. */
32
- export declare function backendKind(backend: string): 'native' | 'wasm';
33
31
  /**
34
32
  * Shared "a full index completed" event (CLI init/index + installer local
35
33
  * init): language names and coarse buckets only — never paths, file names,
@@ -39,7 +37,6 @@ export declare function recordIndexEvent(cg: {
39
37
  getStats(): {
40
38
  filesByLanguage: Record<string, number>;
41
39
  };
42
- getBackend(): string;
43
40
  }, result: {
44
41
  filesIndexed: number;
45
42
  durationMs: number;
package/dist/utils.d.ts CHANGED
@@ -57,12 +57,25 @@ export declare function isConfigLeafNode(node: {
57
57
  * (codegraph_node `includeCode`, codegraph_explore source) go through here, so
58
58
  * this is the chokepoint that keeps out-of-root file contents from leaking.
59
59
  *
60
+ * `allowSymlinkEscape` waives **only** the realpath-escape rejection (the
61
+ * lexical `../` guard still applies) for the INDEXING read path. The directory
62
+ * walk deliberately descends into in-root symlinks whose targets live outside
63
+ * the root (e.g. a `game/` symlink in a Dota custom-game tree, #935); discovery
64
+ * and the reader must agree, or every file the walk enumerated fails to index.
65
+ * Indexing only reads paths it just discovered, into a local index — it never
66
+ * serves them to an agent, so this does not widen the #527 leak surface. The
67
+ * content-serving sinks must never pass this flag.
68
+ *
60
69
  * @param projectRoot - The project root directory
61
70
  * @param filePath - The (relative or absolute) file path to validate
71
+ * @param options.allowSymlinkEscape - Follow in-root symlinks out of the root
72
+ * (indexing read path only); defaults to the strict, leak-safe behavior.
62
73
  * @returns The resolved absolute path (realpath when it exists), or null if it
63
74
  * escapes the root
64
75
  */
65
- export declare function validatePathWithinRoot(projectRoot: string, filePath: string): string | null;
76
+ export declare function validatePathWithinRoot(projectRoot: string, filePath: string, options?: {
77
+ allowSymlinkEscape?: boolean;
78
+ }): string | null;
66
79
  /**
67
80
  * Validate that a path is a safe project root directory.
68
81
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colbymchenry/codegraph",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Local-first code intelligence for AI agents (MCP). Self-contained — bundles its own runtime.",
5
5
  "bin": {
6
6
  "codegraph": "npm-shim.js"
@@ -15,12 +15,12 @@
15
15
  "./package.json": "./package.json"
16
16
  },
17
17
  "optionalDependencies": {
18
- "@colbymchenry/codegraph-darwin-arm64": "1.0.1",
19
- "@colbymchenry/codegraph-darwin-x64": "1.0.1",
20
- "@colbymchenry/codegraph-linux-arm64": "1.0.1",
21
- "@colbymchenry/codegraph-linux-x64": "1.0.1",
22
- "@colbymchenry/codegraph-win32-arm64": "1.0.1",
23
- "@colbymchenry/codegraph-win32-x64": "1.0.1"
18
+ "@colbymchenry/codegraph-darwin-arm64": "1.1.0",
19
+ "@colbymchenry/codegraph-darwin-x64": "1.1.0",
20
+ "@colbymchenry/codegraph-linux-arm64": "1.1.0",
21
+ "@colbymchenry/codegraph-linux-x64": "1.1.0",
22
+ "@colbymchenry/codegraph-win32-arm64": "1.1.0",
23
+ "@colbymchenry/codegraph-win32-x64": "1.1.0"
24
24
  },
25
25
  "files": [
26
26
  "npm-shim.js",