dsh-plugin-codegraph-tree-sitter 0.1.7 → 0.3.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/cache.d.ts +65 -0
- package/lib/cache.js +142 -0
- package/lib/index.d.ts +48 -7
- package/lib/index.js +133 -8
- package/lib/schema.d.ts +2 -0
- package/lib/schema.js +27 -2
- package/lib/walk.d.ts +25 -2
- package/lib/walk.js +82 -25
- package/lib/watcher.d.ts +106 -41
- package/lib/watcher.js +307 -163
- package/package.json +3 -2
package/lib/watcher.js
CHANGED
|
@@ -1,39 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* File-watch-driven reindexing
|
|
3
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
103
|
+
/** Close every live watch and clear in-flight bookkeeping, without touching `degraded`. */
|
|
67
104
|
function teardown() {
|
|
68
105
|
running = false;
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
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
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
|
148
|
-
|
|
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
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
/**
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
223
|
-
*
|
|
224
|
-
* part of the tree, once
|
|
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 (!
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
407
|
+
const target = await fs.resolve(config.root, { signal });
|
|
408
|
+
return live(gen) ? target : undefined;
|
|
252
409
|
}
|
|
253
410
|
catch {
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.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.
|
|
54
|
+
"dsh-plugin-codegraph-service": "^0.3.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": {
|