dsh-plugin-codegraph-tree-sitter 0.1.6 → 0.2.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.
package/lib/watcher.js CHANGED
@@ -1,39 +1,40 @@
1
1
  /**
2
- * File-watch-driven reindexing: debounce a workspace's file-change events into a bounded number of
3
- * `sync` calls, using a per-platform strategy chosen to keep the open-descriptor / kernel-watch cost
4
- * BOUNDED rather than growing with the number of files:
2
+ * File-watch-driven reindexing over the harness filesystem seam (`ctx.fs`): turn a workspace's
3
+ * file-change notifications into a bounded number of `sync` calls, using ONE WATCH PER DIRECTORY.
5
4
  *
6
- * - macOS / Windows: a SINGLE recursive `fs.watch(root, {recursive:true})`. This is the fix for the
7
- * macOS file-table exhaustion a reference implementation hit watching one file descriptor per
8
- * watched file (tens of thousands of REG fds), which exhausted `kern.maxfiles` and crashed
9
- * unrelated processes system-wide (see NOTES.local.md). One recursive stream costs O(1)
10
- * descriptors no matter how large the tree.
11
- * - Linux: recursive `fs.watch` is unsupported, so one inotify watch is installed per (non-excluded)
12
- * directory — O(directories), NOT O(files). New directories are picked up dynamically as events
13
- * reveal them, and {@link WatchConfig.maxWatchedDirectories} bounds inotify usage on pathological
14
- * monorepos.
5
+ * Two facts about the seam decide this module's shape. `watch()` reports a directory's DIRECT
6
+ * entries only (`depth: 0`, so a nested change never arrives on an ancestor's watch), and it never
7
+ * names the path that changed:
15
8
  *
16
- * `sync` is always a full rebuild in this version: the `paths` a debounce window collected are passed
17
- * through but never consulted by the caller supplied here (`runIndex` reruns `walkAndExtract` over the
18
- * whole tree regardless). That keeps this file's only new correctness surface confined to "when do we
19
- * call sync", not "what does sync do with a subset of files" — the latter is deliberately deferred
20
- * until a real incremental reindex is built (see NOTES.local.md). Because sync is always full, a
21
- * directory delete or a `mkdir` immediately followed by writes inside it never needs special-casing:
22
- * the parent directory's own create event is enough to schedule a rebuild that discovers (or removes)
23
- * everything under the path from scratch, watched or not.
9
+ * - Coverage, therefore, comes from watching every in-scope directory: O(directories) watches, never
10
+ * O(files). A directory created after startup is picked up by the same re-listing that a parent's
11
+ * event triggers, so the watch tree grows with the workspace instead of freezing at `start()`.
12
+ * - A notification is a directory-level invalidation with no filename, so attributing it to paths
13
+ * means re-listing that directory and diffing it against the previous listing, using the
14
+ * `version`/`size` the listing itself carries. That diff is what keeps a rebuild from being
15
+ * scheduled by a change this indexer excludes — notably a change to `.codegraph`, which is what
16
+ * stops the watcher from rebuilding forever off its own graph write.
17
+ *
18
+ * `sync` is still always a full rebuild: the paths a debounce window collected are passed through but
19
+ * never consulted by the caller supplied here (see `runSync` in `./index.ts`). That keeps this file's
20
+ * correctness surface confined to "when do we call sync", not "what does sync do with a subset of
21
+ * files".
22
+ *
23
+ * A change landing between the baseline index that starts the watcher and the listing that arms its
24
+ * root is in no snapshot and so produces no notification. That window is one directory listing wide,
25
+ * and the next attributable change — or the next explicit index — covers whatever it missed.
26
+ *
27
+ * Resource discipline is explicit. Every watch's disposer is retained and invoked on `stop()` or
28
+ * permanent degradation, and a watch whose initialization finishes after its watcher was stopped is
29
+ * closed immediately instead of being adopted — a late init can never leak a live watch. A
30
+ * generation counter invalidates every in-flight async step of a previous start(), so a restart
31
+ * cannot inherit state from the session it replaced. {@link WatchConfig.maxWatchedDirectories}
32
+ * bounds the number of open watches, and reaching it degrades the whole watcher rather than silently
33
+ * covering only part of the tree.
24
34
  * @module dsh-plugin-codegraph-tree-sitter/watcher
25
35
  */
26
- import { readdir, stat } from 'node:fs/promises';
27
- import { join, sep } from 'node:path';
28
- import * as fs from 'node:fs';
29
36
  import { loadGitignore } from "./gitignore.js";
30
37
  import { isExcluded } from "./walk.js";
31
- /** The real `fs.watch`-backed primitive. */
32
- export const nodeWatch = (path, options, onEvent, onError) => {
33
- const watcher = fs.watch(path, { recursive: options.recursive, persistent: true }, onEvent);
34
- watcher.on('error', onError);
35
- return { close: () => watcher.close() };
36
- };
37
38
  /** Error codes that mean "the OS ran out of watch resources", across the platforms that report them. */
38
39
  const RESOURCE_EXHAUSTION_CODES = new Set(['EMFILE', 'ENFILE', 'ENOSPC']);
39
40
  /** Consecutive sync failures tolerated before a watcher gives up and degrades. */
@@ -41,20 +42,56 @@ const SYNC_FAILURE_LIMIT = 3;
41
42
  /**
42
43
  * Build a watcher over one workspace root. Nothing runs until {@link Watcher.start} is called.
43
44
  * @param config - scope, timing, and the sync callback to drive.
44
- * @param primitive - the watch primitive to use; defaults to the real `fs.watch`.
45
+ * @param fs - the filesystem seam to resolve, enumerate, and watch through.
46
+ * @returns the watcher; every resource it acquires is released by {@link Watcher.stop}.
45
47
  */
46
- export function createWatcher(config, primitive = nodeWatch) {
48
+ export function createWatcher(config, fs) {
47
49
  const excluded = new Set(config.exclude);
48
50
  let gitignoreRules = [];
51
+ /** Project-relative paths of the directories currently watched. */
49
52
  const watchedDirs = new Set();
50
- const handles = [];
53
+ /** One disposer per live watch, invoked exactly once on teardown. */
54
+ const disposers = [];
55
+ /** The last listing of each watched directory, keyed by project-relative path. */
56
+ const snapshots = new Map();
51
57
  const pendingPaths = new Set();
58
+ /** Directories whose change-triggered re-listing is currently in flight. */
59
+ const refreshing = new Set();
60
+ /** Directories that reported another change while their re-listing was in flight. */
61
+ const dirtyDirs = new Set();
52
62
  let running = false;
53
63
  let degraded = false;
64
+ /** Bumped by every teardown, so async work from a previous start() can recognize itself as stale. */
65
+ let generation = 0;
66
+ let controller;
54
67
  let debounceTimer;
55
68
  let syncing = false;
56
69
  let rerunNeeded = false;
57
70
  let syncFailureCount = 0;
71
+ /**
72
+ * Whether async work started under `gen` still belongs to a live watching session. Everything
73
+ * that resumes after an `await` checks this before touching shared state: a stop, a degrade, or a
74
+ * restart all invalidate the generation the work captured.
75
+ * @param gen - the generation the caller started under.
76
+ * @returns whether that generation is still the live, non-degraded one.
77
+ */
78
+ function live(gen) {
79
+ return running && !degraded && gen === generation;
80
+ }
81
+ /**
82
+ * Release one watch, swallowing both ways a backend's close can fail. A watcher is being torn down
83
+ * whenever this runs; there is no caller to report to and nothing to recover, and an unhandled
84
+ * rejection here would crash a process that was only shutting a watcher down.
85
+ * @param dispose - the disposer the backend returned.
86
+ */
87
+ function closeQuietly(dispose) {
88
+ try {
89
+ void dispose().catch(() => { });
90
+ }
91
+ catch {
92
+ // A backend whose close throws synchronously instead of rejecting is released all the same.
93
+ }
94
+ }
58
95
  /** One-way: once degraded, only a fresh `start()` clears it. */
59
96
  function degrade(reason) {
60
97
  if (degraded)
@@ -63,31 +100,74 @@ export function createWatcher(config, primitive = nodeWatch) {
63
100
  teardown();
64
101
  config.onDegraded?.(reason);
65
102
  }
66
- /** Close every live watch and clear the debounce timer, without touching `degraded`. */
103
+ /** Close every live watch and clear in-flight bookkeeping, without touching `degraded`. */
67
104
  function teardown() {
68
105
  running = false;
69
- for (const handle of handles.splice(0))
70
- handle.close();
106
+ generation += 1;
107
+ controller?.abort();
108
+ controller = undefined;
109
+ for (const dispose of disposers.splice(0))
110
+ closeQuietly(dispose);
71
111
  watchedDirs.clear();
112
+ snapshots.clear();
113
+ dirtyDirs.clear();
114
+ // Cleared even though the re-listing that owns this marker still holds it: that work belongs to
115
+ // a dead generation and will drop out at its next `live()` check, and leaving the marker behind
116
+ // would make a restarted watcher treat the directory as perpetually mid-refresh.
117
+ refreshing.clear();
72
118
  if (debounceTimer !== undefined) {
73
119
  clearTimeout(debounceTimer);
74
120
  debounceTimer = undefined;
75
121
  }
76
122
  }
123
+ /**
124
+ * Read the `code` a watch failure carries, if it carries one.
125
+ * @param error - the rejection reason to inspect.
126
+ * @returns the code as a string, or undefined when there is none.
127
+ */
77
128
  function errorCode(error) {
78
129
  return typeof error === 'object' && error !== null && 'code' in error
79
130
  ? String(error.code)
80
131
  : undefined;
81
132
  }
82
- function handleWatchError(error) {
83
- if (!running || degraded)
84
- return;
133
+ /**
134
+ * Whether a failure means the OS ran out of watch resources.
135
+ * @param error - the rejection reason to inspect.
136
+ * @returns whether its code is one of the resource-exhaustion codes.
137
+ */
138
+ function resourceExhausted(error) {
85
139
  const code = errorCode(error);
86
- if (code !== undefined && RESOURCE_EXHAUSTION_CODES.has(code)) {
140
+ return code !== undefined && RESOURCE_EXHAUSTION_CODES.has(code);
141
+ }
142
+ /**
143
+ * Degrade for a watch that never initialized. Unlike an error reported later by a live watch, a
144
+ * failed initialization leaves that directory — and everything under it — uncovered, and silent
145
+ * partial coverage is precisely what the caller cannot recover from on its own.
146
+ * @param error - the rejection reason from `fs.watch`.
147
+ */
148
+ function handleInstallFailure(error) {
149
+ if (resourceExhausted(error)) {
87
150
  degrade({ code: 'RESOURCE_EXHAUSTED', cause: error });
151
+ return;
152
+ }
153
+ degrade({ code: 'WATCH_UNAVAILABLE', cause: error });
154
+ }
155
+ /**
156
+ * Degrade for an error an already-running watch reported. Only resource exhaustion and the seam's
157
+ * own "this provider cannot watch" code prove that live watching can no longer make progress; any
158
+ * other error is the backend's own report about one watch and leaves the rest working.
159
+ * @param error - the error the backend passed to the change callback.
160
+ * @param gen - the generation of the watch that reported it.
161
+ */
162
+ function handleWatchError(error, gen) {
163
+ if (!live(gen))
164
+ return;
165
+ if (resourceExhausted(error)) {
166
+ degrade({ code: 'RESOURCE_EXHAUSTED', cause: error });
167
+ }
168
+ else if (errorCode(error) === 'FS_IO_ERROR') {
169
+ degrade({ code: 'WATCH_UNAVAILABLE', cause: error });
88
170
  }
89
- // An error this watcher doesn't recognize as resource exhaustion is left to the OS-level watch's
90
- // own reporting; it doesn't by itself prove live watching can no longer make progress.
91
171
  }
92
172
  function scheduleSync() {
93
173
  if (debounceTimer !== undefined)
@@ -96,7 +176,7 @@ export function createWatcher(config, primitive = nodeWatch) {
96
176
  }
97
177
  function runSync() {
98
178
  // Also called directly (not just as the timer callback) when a queued rerun fires immediately;
99
- // clear any timer a later event armed in the meantime so it can't fire a redundant, empty sync.
179
+ // clear any timer a later change armed in the meantime so it can't fire a redundant, empty sync.
100
180
  if (debounceTimer !== undefined) {
101
181
  clearTimeout(debounceTimer);
102
182
  debounceTimer = undefined;
@@ -110,11 +190,10 @@ export function createWatcher(config, primitive = nodeWatch) {
110
190
  const batch = [...pendingPaths];
111
191
  pendingPaths.clear();
112
192
  syncing = true;
113
- // `batch` is never actually empty here: the only two ways into this function are the debounce
114
- // timer (armed exclusively by handleChange(), right after it adds to pendingPaths) and a queued
115
- // rerun (only set when handleChange() re-armed the timer while a sync was already in flight,
116
- // which itself added to pendingPaths). The `undefined` branch is API contract, not reachable code.
117
- /* v8 ignore next */
193
+ // `batch` is empty when the change that armed this timer could not be attributed to any entry —
194
+ // either the re-listing failed, or it found nothing in scope while the backend cannot report a
195
+ // version for an in-place modification. The rebuild is still what the caller asked for; only the
196
+ // path list is missing.
118
197
  config.sync(batch.length === 0 ? undefined : batch)
119
198
  .then(() => {
120
199
  syncFailureCount = 0;
@@ -140,132 +219,215 @@ export function createWatcher(config, primitive = nodeWatch) {
140
219
  });
141
220
  }
142
221
  /**
143
- * Whether one path segment, already known to sit inside in-scope ancestors, is itself in scope.
144
- * Used while recursing the initial watch tree, where every ancestor was already checked on the way
145
- * down — checking only the leaf segment here is enough, and cheaper than re-testing the whole path.
222
+ * Whether a listed entry is unchanged relative to the previous listing.
223
+ *
224
+ * `version` is preferred when both listings have one: it is the backend's own freshness token, so a
225
+ * content change that preserved size and mtime still shows up. A backend that omits it falls back
226
+ * to size, which catches truncation and growth but not a same-size rewrite — that case is left to
227
+ * the next change the backend can describe.
228
+ * @param previous - the stamp recorded for this entry name on the previous listing, if any.
229
+ * @param entry - the entry from the listing taken now.
230
+ * @returns whether the entry looks identical to the one recorded before.
146
231
  */
147
- function segmentInScope(name, relativePath, isDirectory) {
148
- return !isExcluded(name, relativePath, isDirectory, excluded, gitignoreRules);
232
+ function sameEntry(previous, entry) {
233
+ if (previous === undefined)
234
+ return false;
235
+ if (previous.type !== entry.type)
236
+ return false;
237
+ if (previous.version !== undefined && entry.version !== undefined)
238
+ return previous.version === entry.version;
239
+ return previous.size === entry.size;
149
240
  }
150
241
  /**
151
- * Whether a path an `fs.watch` event named — possibly several segments deep, with no guarantee any
152
- * ancestor was checked first (the recursive macOS/Windows watch reports the whole subtree in one
153
- * stream) — is in scope. Walks every prefix from the root down, exactly like the initial directory
154
- * walk would if it descended this far, so `node_modules/dep/index.js` is excluded by its
155
- * `node_modules` ancestor even though the leaf name itself is unremarkable.
242
+ * Re-list one directory: watch any new in-scope subdirectory, and report which direct entries
243
+ * changed since the previous listing of it.
244
+ *
245
+ * The first listing of a directory records a snapshot and reports no changes: everything it shows
246
+ * was already there when the baseline index ran, so reporting it would only schedule a redundant
247
+ * rebuild.
248
+ * @param relativePath - the directory's project-relative path (`''` is the root).
249
+ * @param target - the directory's resolved target.
250
+ * @param gen - the generation this re-listing belongs to.
251
+ * @param signal - cancels the listing and any watch it initializes.
252
+ * @returns the project-relative paths that changed, or undefined when the listing itself failed and
253
+ * the change therefore cannot be attributed at all.
156
254
  */
157
- function pathInScope(relativePath, leafIsDirectory) {
158
- const segments = relativePath.split('/');
159
- let prefix = '';
160
- for (const [index, name] of segments.entries()) {
161
- prefix = index === 0 ? name : `${prefix}/${name}`;
162
- const isDirectory = index < segments.length - 1 || leafIsDirectory;
163
- if (!segmentInScope(name, prefix, isDirectory))
164
- return false;
255
+ async function listAndWatchChildren(relativePath, target, gen, signal) {
256
+ let entries;
257
+ try {
258
+ entries = await fs.listDir(target, signal);
259
+ }
260
+ catch {
261
+ // The directory vanished, or the backend refused the listing. Its own watch still reports its
262
+ // direct entries, so the caller rebuilds rather than assuming nothing changed.
263
+ return undefined;
165
264
  }
166
- return true;
265
+ const previous = snapshots.get(relativePath);
266
+ const next = new Map();
267
+ const changed = [];
268
+ for (const entry of entries) {
269
+ const childPath = relativePath === '' ? entry.name : `${relativePath}/${entry.name}`;
270
+ const isDirectory = entry.type === 'directory';
271
+ // Same predicate, same arguments, that the walk applies to the same entry: `exclude` plus the
272
+ // project's `.gitignore`, so what is watched and what is indexed cannot drift apart.
273
+ if (isExcluded(entry.name, childPath, isDirectory, excluded, gitignoreRules))
274
+ continue;
275
+ next.set(entry.name, { type: entry.type, version: entry.version, size: entry.size });
276
+ if (previous !== undefined && !sameEntry(previous.get(entry.name), entry))
277
+ changed.push(childPath);
278
+ if (isDirectory)
279
+ await watchDirectory(childPath, entry.target, gen, signal);
280
+ }
281
+ if (previous !== undefined) {
282
+ for (const name of previous.keys()) {
283
+ if (next.has(name))
284
+ continue;
285
+ changed.push(relativePath === '' ? name : `${relativePath}/${name}`);
286
+ }
287
+ }
288
+ // A teardown mid-listing means this snapshot describes a session that no longer exists; keeping
289
+ // it would let the next session diff against stale state.
290
+ if (!live(gen))
291
+ return undefined;
292
+ snapshots.set(relativePath, next);
293
+ return changed;
167
294
  }
168
295
  /**
169
- * Record one in-scope change and arm the debounce. Also picks up a directory created after startup
170
- * so Linux's per-directory watch tree grows with the workspace instead of only covering what existed
171
- * at `start()`.
296
+ * React to one directory reporting a change: re-list it, record what changed, and arm the debounce.
297
+ *
298
+ * The parent directory's reported change and its own listing are both async, and a burst of writes
299
+ * produces many notifications for one directory; re-listing for each of them would read the same
300
+ * entries repeatedly. A per-directory "in flight" marker coalesces the burst into one listing plus
301
+ * at most one follow-up listing for whatever arrived while the first was reading.
302
+ * @param relativePath - the directory's project-relative path (`''` is the root).
303
+ * @param target - the directory's resolved target.
304
+ * @param gen - the generation this re-listing belongs to.
305
+ * @param signal - cancels the work.
172
306
  */
173
- function handleChange(relativePath) {
174
- if (!running || degraded)
175
- return;
176
- if (relativePath === '')
177
- return;
178
- if (!pathInScope(relativePath, false))
179
- return;
180
- pendingPaths.add(relativePath);
181
- scheduleSync();
182
- if (process.platform !== 'darwin' && process.platform !== 'win32' && !watchedDirs.has(relativePath)) {
183
- // Fire-and-forget: a directory that turns out not to exist (a file event, or something already
184
- // removed again) simply fails the stat and watchDirectory() is never called.
185
- void maybeWatchNewDirectory(relativePath);
186
- }
187
- }
188
- async function maybeWatchNewDirectory(relativePath) {
189
- let isDirectory;
307
+ async function refreshDirectory(relativePath, target, gen, signal) {
190
308
  try {
191
- isDirectory = (await stat(join(config.root, relativePath))).isDirectory();
309
+ for (;;) {
310
+ dirtyDirs.delete(relativePath);
311
+ const changed = await listAndWatchChildren(relativePath, target, gen, signal);
312
+ if (!live(gen))
313
+ return;
314
+ if (changed === undefined) {
315
+ // Nothing could be attributed, so the change itself is the only fact available: rebuild.
316
+ scheduleSync();
317
+ }
318
+ else if (changed.length > 0) {
319
+ for (const path of changed)
320
+ pendingPaths.add(path);
321
+ scheduleSync();
322
+ }
323
+ // An empty diff schedules nothing on purpose: that is how a change to an excluded entry —
324
+ // `.codegraph`'s own writes included — stops here instead of retriggering the rebuild that
325
+ // wrote it.
326
+ if (!dirtyDirs.has(relativePath))
327
+ return;
328
+ }
192
329
  }
193
- catch {
194
- return;
330
+ finally {
331
+ refreshing.delete(relativePath);
195
332
  }
196
- // `running`/`degraded` are re-checked inside watchDirectory() itself; no need to duplicate that
197
- // check here too.
198
- if (!isDirectory || watchedDirs.has(relativePath) || !pathInScope(relativePath, true))
199
- return;
200
- await watchDirectory(relativePath);
201
333
  }
202
- /** Normalize an `fs.watch` filename (native separators, possibly nested under `recursive`) to posix. */
203
- function toRelativePosix(filename) {
204
- return filename.split(sep).join('/');
205
- }
206
- function startRecursive() {
207
- try {
208
- const handle = primitive(config.root, { recursive: true }, (_eventType, filename) => {
209
- if (filename === null)
210
- return;
211
- handleChange(toRelativePosix(filename));
212
- }, handleWatchError);
213
- handles.push(handle);
334
+ /**
335
+ * Handle one notification from a directory's watch.
336
+ * @param relativePath - the watched directory's project-relative path.
337
+ * @param target - the watched directory's resolved target.
338
+ * @param gen - the generation of the watch that reported it.
339
+ * @param signal - cancels the work this notification starts.
340
+ * @param error - the backend's error report, when the notification is not a change.
341
+ */
342
+ function onDirectoryEvent(relativePath, target, gen, signal, error) {
343
+ if (error !== undefined) {
344
+ handleWatchError(error, gen);
345
+ return;
214
346
  }
215
- catch (error) {
216
- handleWatchError(error);
217
- // A platform that claims to support recursive watch but rejects this call outright (not just an
218
- // ENOSYS at the fs.watch layer, which never reaches here) has nothing more this watcher can try.
347
+ if (!live(gen))
348
+ return;
349
+ if (refreshing.has(relativePath)) {
350
+ dirtyDirs.add(relativePath);
351
+ return;
219
352
  }
353
+ refreshing.add(relativePath);
354
+ void refreshDirectory(relativePath, target, gen, signal);
220
355
  }
221
356
  /**
222
- * Install one inotify watch on `relativePath` (project-relative; `''` is the root) and recurse into
223
- * its non-excluded subdirectories. Degrades the whole watcher, rather than silently covering only
224
- * part of the tree, once {@link WatchConfig.maxWatchedDirectories} is reached.
357
+ * Install one watch on `relativePath` and recurse into its in-scope subdirectories.
358
+ *
359
+ * Degrades the whole watcher, rather than silently covering only part of the tree, once
360
+ * {@link WatchConfig.maxWatchedDirectories} is reached.
361
+ * @param relativePath - the directory's project-relative path (`''` is the root).
362
+ * @param target - the directory's resolved target.
363
+ * @param gen - the generation this watch belongs to.
364
+ * @param signal - cancels initialization.
225
365
  */
226
- async function watchDirectory(relativePath) {
227
- if (!running || degraded || watchedDirs.has(relativePath))
366
+ async function watchDirectory(relativePath, target, gen, signal) {
367
+ if (!live(gen) || watchedDirs.has(relativePath))
228
368
  return;
229
369
  if (watchedDirs.size >= config.maxWatchedDirectories) {
230
370
  degrade({ code: 'WATCH_LIMIT_EXCEEDED' });
231
371
  return;
232
372
  }
233
- const absolute = join(config.root, relativePath);
234
- let handle;
373
+ // Reserved before the await below, so two notifications for the same new directory cannot
374
+ // initialize two watches on it.
375
+ watchedDirs.add(relativePath);
376
+ let dispose;
235
377
  try {
236
- handle = primitive(absolute, { recursive: false }, (_eventType, filename) => {
237
- if (filename === null)
238
- return;
239
- const childPosix = toRelativePosix(filename);
240
- handleChange(relativePath === '' ? childPosix : `${relativePath}/${childPosix}`);
241
- }, handleWatchError);
378
+ dispose = await fs.watch(target, (error) => onDirectoryEvent(relativePath, target, gen, signal, error), signal);
242
379
  }
243
380
  catch (error) {
244
- handleWatchError(error);
381
+ // A failure that lands after teardown is a canceled initialization, not evidence about the
382
+ // backend — teardown already cleared the reservation, so there is nothing to undo here.
383
+ if (!live(gen))
384
+ return;
385
+ watchedDirs.delete(relativePath);
386
+ handleInstallFailure(error);
245
387
  return;
246
388
  }
247
- handles.push(handle);
248
- watchedDirs.add(relativePath);
249
- let entries;
389
+ if (!live(gen)) {
390
+ // Initialization finished after the watcher moved on. Adopting the watch would leak it into a
391
+ // session that never asked for it, so release it here instead.
392
+ closeQuietly(dispose);
393
+ return;
394
+ }
395
+ disposers.push(dispose);
396
+ await listAndWatchChildren(relativePath, target, gen, signal);
397
+ }
398
+ /**
399
+ * Resolve the workspace root, or report that there is nothing to watch.
400
+ * @param gen - the generation this resolution belongs to.
401
+ * @param signal - cancels the resolution.
402
+ * @returns the resolved root target, or undefined when it cannot be resolved (or the session ended
403
+ * while resolving it).
404
+ */
405
+ async function resolveRoot(gen, signal) {
250
406
  try {
251
- entries = await readdir(absolute, { withFileTypes: true });
407
+ const target = await fs.resolve(config.root, { signal });
408
+ return live(gen) ? target : undefined;
252
409
  }
253
410
  catch {
254
- // The directory disappeared between being discovered and being listed; its own watch (or the
255
- // parent's) already reported enough to trigger a sync that reconciles this.
256
- return;
257
- }
258
- for (const entry of entries) {
259
- // Not re-checking `running`/`degraded` here: a stop/degrade mid-loop just means the recursive
260
- // watchDirectory() call below no-ops immediately (same check, at the top of that call).
261
- if (!entry.isDirectory())
262
- continue;
263
- const childPath = relativePath === '' ? entry.name : `${relativePath}/${entry.name}`;
264
- if (!segmentInScope(entry.name, childPath, true))
265
- continue;
266
- await watchDirectory(childPath);
411
+ // A root that cannot be resolved is not a watch failure to report: the index that started this
412
+ // watcher already proved the root exists, so this is a race (the workspace moved) that the next
413
+ // successful index() call will retry.
414
+ return undefined;
267
415
  }
268
416
  }
417
+ /**
418
+ * Arm the whole watch tree for this session: read the exclusion list, resolve the root, then recurse.
419
+ * @param gen - the generation this session runs under.
420
+ * @param signal - cancels the work.
421
+ */
422
+ async function startWatching(gen, signal) {
423
+ gitignoreRules = config.respectGitignore ? await loadGitignore(config.root) : [];
424
+ if (!live(gen))
425
+ return;
426
+ const root = await resolveRoot(gen, signal);
427
+ if (root === undefined)
428
+ return;
429
+ await watchDirectory('', root, gen, signal);
430
+ }
269
431
  return {
270
432
  start() {
271
433
  if (running)
@@ -274,28 +436,10 @@ export function createWatcher(config, primitive = nodeWatch) {
274
436
  degraded = false;
275
437
  syncFailureCount = 0;
276
438
  pendingPaths.clear();
277
- if (process.platform === 'darwin' || process.platform === 'win32') {
278
- // Started immediately, unlike the per-directory branch below, because the recursive `fs.watch`
279
- // call itself must stay synchronous within start() — nothing here should make callers await a
280
- // gitignore read just to get a watch installed. `gitignoreRules` starts empty and is filled in
281
- // once the read resolves; every event that arrives before then is a startup-window race no
282
- // different in kind from the FSEvents kernel-side warm-up delay already documented above, and
283
- // resolves in the time to read one small file, not the time a human takes to edit one.
284
- startRecursive();
285
- if (config.respectGitignore) {
286
- void loadGitignore(config.root).then(rules => {
287
- if (running)
288
- gitignoreRules = rules;
289
- });
290
- }
291
- return;
292
- }
293
- void (async () => {
294
- gitignoreRules = config.respectGitignore ? await loadGitignore(config.root) : [];
295
- if (!running)
296
- return;
297
- await watchDirectory('');
298
- })();
439
+ const abort = new AbortController();
440
+ controller = abort;
441
+ const gen = generation;
442
+ void startWatching(gen, abort.signal);
299
443
  },
300
444
  stop() {
301
445
  teardown();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-codegraph-tree-sitter",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Self-built code-graph indexer for DeepSeek Harness — parses a workspace with web-tree-sitter and writes schema version 4 to .codegraph/codegraph.db, the same on-disk format the codegraph CLI writes, so no external indexer is required",
5
5
  "keywords": [
6
6
  "dsh",
@@ -51,10 +51,11 @@
51
51
  "@deepseek-ai/schemastery": "^3.18.1",
52
52
  "tree-sitter-wasms": "^0.1.13",
53
53
  "web-tree-sitter": "^0.25.10",
54
- "dsh-plugin-codegraph-service": "^0.1.6"
54
+ "dsh-plugin-codegraph-service": "^0.2.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@deepseek-ai/cordis": ">=4.0.1",
58
+ "@deepseek-ai/dsh-fs": ">=0.1.0-rc.6",
58
59
  "@deepseek-ai/dsh-invariants": ">=0.1.0-rc.6"
59
60
  },
60
61
  "devDependencies": {