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/resolve.js CHANGED
@@ -3,13 +3,20 @@
3
3
  *
4
4
  * Pass 1 collects every definition into a name index. Pass 2 resolves each call site's callee name,
5
5
  * in order: (1) the enclosing file imports that name from a relative specifier resolving to a file
6
- * this run indexed — that file's matching declaration wins; (2) otherwise exactly one definition
7
- * workspace-wide carries the name — it wins; (3) otherwise the call is recorded in `unresolved` and no
8
- * `calls` edge is written. An ambiguous edge is worse than a missing one: the model acts on `callers`
9
- * output, so a confidently wrong caller sends it to edit the wrong file, while a missing caller merely
10
- * sends it back to text search.
6
+ * this run indexed — that file's matching declaration wins; (2) otherwise exactly one definition **in
7
+ * the calling file itself** carries the name — lexical scope settles it before the workspace gets a
8
+ * say, which is what keeps a helper declared in two test files (or a `render` in two components) from
9
+ * being counted as ambiguous; (3) otherwise exactly one definition workspace-wide carries the name —
10
+ * it wins; (4) otherwise the call is recorded in `unresolved` and no `calls` edge is written. An
11
+ * ambiguous edge is worse than a missing one: the model acts on `callers` output, so a confidently
12
+ * wrong caller sends it to edit the wrong file, while a missing caller merely sends it back to text
13
+ * search.
11
14
  *
12
- * Rule 3's `unresolved` entries are not one uniform signal. Each carries {@link UnresolvedRef.likelyExternal},
15
+ * Rule 2 is restricted to bare calls. A member call (`x.map()`) names a property of a receiver whose
16
+ * type this resolver does not track, so a same-named local declaration winning it would be a guess
17
+ * rather than a scope resolution — exactly the kind of confidently wrong edge rule 4 exists to avoid.
18
+ *
19
+ * Rule 4's `unresolved` entries are not one uniform signal. Each carries {@link UnresolvedRef.likelyExternal},
13
20
  * which is true for a member call (no type information behind its receiver) or a name the file itself
14
21
  * imports (a known external origin the specifier just didn't resolve) — both real code that was never a
15
22
  * candidate for a workspace edge, as against a bare, undeclared name, which is the actual gap this
@@ -99,6 +106,22 @@ function buildNameIndex(nodesByFile) {
99
106
  }
100
107
  return index;
101
108
  }
109
+ /**
110
+ * Group one file's own nodes by declaration name, for the same-file tier of call resolution.
111
+ * @param nodes - the nodes declared in one file.
112
+ * @returns every name that file declares, mapped to the nodes carrying it.
113
+ */
114
+ function indexByName(nodes) {
115
+ const index = new Map();
116
+ for (const node of nodes) {
117
+ const existing = index.get(node.name);
118
+ if (existing === undefined)
119
+ index.set(node.name, [node]);
120
+ else
121
+ existing.push(node);
122
+ }
123
+ return index;
124
+ }
102
125
  /**
103
126
  * A name index restricted to `class`/`interface` nodes, for `extends`/`implements` resolution.
104
127
  * `extractHeritage` only ever names a base class or interface, never a function or variable — indexing
@@ -213,9 +236,10 @@ export function resolveWorkspace(files, now) {
213
236
  }
214
237
  const keyToId = mustGet(defIdByKey, file.path);
215
238
  const importedLocalNames = new Set(file.extraction.imports.map(binding => binding.localName));
239
+ const localByName = indexByName(mustGet(nodesByFile, file.path));
216
240
  for (const call of file.extraction.calls) {
217
241
  const callerId = call.callerKey === null ? mustGet(fileNodeId, file.path) : mustGet(keyToId, call.callerKey);
218
- const resolved = resolveName(call.calleeName, bindings, nodesByFile, nameIndex);
242
+ const resolved = resolveName(call.calleeName, bindings, nodesByFile, call.isMemberCall ? undefined : localByName, nameIndex);
219
243
  if (resolved === undefined) {
220
244
  const likelyExternal = call.isMemberCall || importedLocalNames.has(call.calleeName);
221
245
  unresolved.push({ source: callerId, filePath: file.path, calleeName: call.calleeName, line: call.line, col: call.column, likelyExternal });
@@ -228,7 +252,7 @@ export function resolveWorkspace(files, now) {
228
252
  // on it) is a calibrated signal specifically about call-resolution gaps; folding in a different edge
229
253
  // kind would change what that count measures.
230
254
  for (const ref of file.extraction.heritage) {
231
- const resolved = resolveName(ref.targetName, bindings, nodesByFile, typeNameIndex);
255
+ const resolved = resolveName(ref.targetName, bindings, nodesByFile, undefined, typeNameIndex);
232
256
  if (resolved === undefined)
233
257
  continue;
234
258
  edges.push({ source: mustGet(keyToId, ref.sourceKey), target: resolved, kind: ref.relation, provenance: 'tree-sitter' });
@@ -237,13 +261,21 @@ export function resolveWorkspace(files, now) {
237
261
  return { nodes, edges, unresolved };
238
262
  }
239
263
  /**
240
- * Resolve a bare name to exactly one declaration, or `undefined` per rule 3 — shared by call
264
+ * Resolve a bare name to exactly one declaration, or `undefined` per rule 4 — shared by call
241
265
  * resolution (against the workspace-wide, any-kind `nameIndex`) and `extends`/`implements` resolution
242
- * (against the `class`/`interface`-only {@link buildTypeNameIndex}); the two-tier rule itself (an
243
- * imported binding wins if unambiguous in its target file, else a workspace-wide-unique name wins) does
244
- * not depend on which candidate pool the caller passes in.
266
+ * (against the `class`/`interface`-only {@link buildTypeNameIndex}); the rule itself (an imported
267
+ * binding wins if unambiguous in its target file, else a same-file declaration when the caller passes
268
+ * one, else a workspace-wide-unique name wins) does not depend on which candidate pool the caller
269
+ * passes in.
270
+ * @param calleeName - the name at the call or heritage site.
271
+ * @param bindings - the calling file's resolved import bindings.
272
+ * @param nodesByFile - every file's declarations, for the import target lookup.
273
+ * @param localByName - the calling file's own declarations, or `undefined` to skip the same-file tier
274
+ * (the caller decides: member calls and heritage references do not get it).
275
+ * @param nameIndex - the workspace-wide candidate pool this reference resolves against.
276
+ * @returns the winning declaration's node id, or `undefined` when nothing settles it.
245
277
  */
246
- function resolveName(calleeName, bindings, nodesByFile, nameIndex) {
278
+ function resolveName(calleeName, bindings, nodesByFile, localByName, nameIndex) {
247
279
  const bound = bindings.byLocalName.get(calleeName);
248
280
  if (bound !== undefined) {
249
281
  const targetNodes = mustGet(nodesByFile, bound.targetFile);
@@ -253,6 +285,11 @@ function resolveName(calleeName, bindings, nodesByFile, nameIndex) {
253
285
  if (candidates.length === 1)
254
286
  return candidates[0]?.id;
255
287
  }
288
+ // Lexical scope before workspace scope: a name this very file declares is the call's declaration
289
+ // whenever exactly one declaration carries it, no matter how many other files reuse the name.
290
+ const local = localByName?.get(calleeName);
291
+ if (local !== undefined && local.length === 1)
292
+ return local[0]?.id;
256
293
  const workspaceWide = nameIndex.get(calleeName) ?? [];
257
294
  return workspaceWide.length === 1 ? workspaceWide[0]?.id : undefined;
258
295
  }
package/lib/schema.d.ts CHANGED
@@ -19,6 +19,8 @@ export interface WriteInput {
19
19
  readonly nodes: readonly GraphNode[];
20
20
  readonly edges: readonly GraphEdge[];
21
21
  readonly unresolved: readonly UnresolvedRef[];
22
+ /** Generated artifacts this run left out of the graph, carried into the status sidecar. */
23
+ readonly filesMinified: number;
22
24
  readonly indexedAt: number;
23
25
  }
24
26
  /**
package/lib/schema.js CHANGED
@@ -10,10 +10,11 @@
10
10
  * naive "delete then recreate" leaves open between `CREATE TABLE` and the first `COMMIT`.
11
11
  * @module dsh-plugin-codegraph-tree-sitter/schema
12
12
  */
13
- import { mkdir, rename, rm } from 'node:fs/promises';
14
- import { dirname } from 'node:path';
13
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
14
+ import { basename, dirname, join } from 'node:path';
15
15
  import { DatabaseSync } from 'node:sqlite';
16
16
  import { randomUUID } from 'node:crypto';
17
+ import { INDEX_STATUS_RELATIVE_PATH } from 'dsh-plugin-codegraph-service';
17
18
  /** The schema version this package writes, matching the format `dsh-codegraph-sqlite` reads. */
18
19
  export const SCHEMA_VERSION = 4;
19
20
  /**
@@ -122,5 +123,29 @@ export async function writeGraph(databasePath, input) {
122
123
  // rename() only moves the main file; the temp name's own sidecars (should the driver have left any)
123
124
  // never travel with it and would otherwise linger under the temp name forever.
124
125
  await removeGraphFiles(tempPath);
126
+ await writeStatusFile(dirname(databasePath), {
127
+ indexedAt: input.indexedAt,
128
+ filesIndexed: input.files.length,
129
+ filesMinified: input.filesMinified,
130
+ nodeCount: input.nodes.length,
131
+ edgeCount: input.edges.length,
132
+ unresolvedCount: input.unresolved.length,
133
+ unresolvedLikelyInternalCount: input.unresolved.filter(ref => !ref.likelyExternal).length,
134
+ });
135
+ }
136
+ /**
137
+ * Write the prompt-time status sidecar beside the graph, replacing it atomically.
138
+ *
139
+ * Deliberately after the graph's own rename: a reader that sees this file can trust that the graph it
140
+ * summarizes is already in place, which is what lets a prompt line report "336 files, built 12s ago"
141
+ * without opening SQLite on every model request.
142
+ * @param directory - the graph's directory (`.codegraph`).
143
+ * @param status - this run's summary.
144
+ */
145
+ async function writeStatusFile(directory, status) {
146
+ const target = join(directory, basename(INDEX_STATUS_RELATIVE_PATH));
147
+ const temp = `${target}.tmp`;
148
+ await writeFile(temp, JSON.stringify(status));
149
+ await rename(temp, target);
125
150
  }
126
151
  //# sourceMappingURL=schema.js.map
package/lib/walk.d.ts CHANGED
@@ -33,6 +33,12 @@ export interface WalkConfig {
33
33
  readonly concurrency: number;
34
34
  /** Restrict extraction to these seam language labels when set; every loaded grammar otherwise. */
35
35
  readonly languages?: readonly string[];
36
+ /**
37
+ * Leave generated (bundled or minified) artifacts out of the index instead of parsing them. See
38
+ * `./minified.ts` for the two signals; a workspace that commits its build output is the case this
39
+ * exists for, since such output is neither gitignored nor declared in `exclude`.
40
+ */
41
+ readonly skipMinified: boolean;
36
42
  /**
37
43
  * Also exclude whatever the project root's `.gitignore` names, unioned with `exclude`. A project
38
44
  * that compiles to a gitignored directory (`lib`, `dist`, ...) would otherwise hand the same symbol
@@ -40,18 +46,45 @@ export interface WalkConfig {
40
46
  * workspace-wide name wins" call resolution would pick between them arbitrarily.
41
47
  */
42
48
  readonly respectGitignore: boolean;
49
+ /**
50
+ * Reuse the extraction a previous run cached for every file whose content is unchanged, instead of
51
+ * reading and parsing it again. The cache lives under `<projectRoot>/.codegraph/cache` and is
52
+ * written by the indexer after a run succeeds; a missing or unusable cache simply means every file
53
+ * is parsed.
54
+ */
55
+ readonly reuseParsedCache: boolean;
56
+ /**
57
+ * How a cached file is confirmed unchanged before its extraction is reused. `'stat'` compares the
58
+ * modification time and size the cache recorded — no read at all, and enough for a watcher rebuild,
59
+ * whose changes always move mtime. `'hash'` reads the file and compares a SHA-256, which also
60
+ * catches a rewrite that preserved both mtime and size (a checkout restoring an old file) at the
61
+ * cost of the read it would otherwise save.
62
+ */
63
+ readonly cacheVerify: 'stat' | 'hash';
43
64
  }
44
65
  /** What the walk produced. */
45
66
  export interface WalkResult {
46
67
  readonly files: ExtractedFile[];
68
+ /** Candidates skipped for a reason other than being generated output: over `maxFileBytes`, or cut
69
+ * off by `maxFiles`. */
47
70
  readonly filesSkipped: number;
71
+ /** Candidates skipped because they are generated output — see {@link WalkConfig.skipMinified}. */
72
+ readonly filesMinified: number;
73
+ /**
74
+ * Files whose extraction came from the parse cache instead of a fresh parse. These are still
75
+ * present in {@link files} and still indexed — the count says how much work the cache saved, not
76
+ * how much of the workspace was left out.
77
+ */
78
+ readonly filesReused: number;
48
79
  }
49
80
  /**
50
- * Walk `projectRoot` and parse every candidate file it finds.
81
+ * Walk `projectRoot` and parse every candidate file it finds — or reuse the extraction a previous
82
+ * run cached for it.
51
83
  * @param projectRoot - absolute path of the workspace to index.
52
84
  * @param config - the walk's bounds.
53
85
  * @param signal - aborts the walk.
54
- * @returns the parsed files and how many candidates were skipped (over size, or past `maxFiles`).
86
+ * @returns the parsed files, how many candidates were skipped (over size, or past `maxFiles`), and
87
+ * how many files reused a cached extraction.
55
88
  */
56
89
  export declare function walkAndExtract(projectRoot: string, config: WalkConfig, signal?: AbortSignal): Promise<WalkResult>;
57
90
  //# sourceMappingURL=walk.d.ts.map
package/lib/walk.js CHANGED
@@ -11,10 +11,12 @@
11
11
  import { createHash } from 'node:crypto';
12
12
  import { readdir, readFile, stat } from 'node:fs/promises';
13
13
  import { posix } from 'node:path';
14
+ import { readParseCache } from "./cache.js";
14
15
  import { createParser } from "./grammar.js";
15
16
  import { extractFile } from "./extract.js";
16
17
  import { loadGitignore, matchesGitignore } from "./gitignore.js";
17
18
  import { languageFor } from "./languages.js";
19
+ import { isMinifiedName, looksMinified, measureText } from "./minified.js";
18
20
  /**
19
21
  * Whether one directory entry is excluded from both the index and (for `watcher.ts`) the watch tree —
20
22
  * the single definition both consult, so "what the index covers" and "what gets watched" can never
@@ -69,16 +71,74 @@ async function discover(projectRoot, config, signal) {
69
71
  await scan('');
70
72
  return { candidates, overflow };
71
73
  }
72
- /** Parse one candidate, or return `undefined` when it is over {@link WalkConfig.maxFileBytes}. */
73
- async function parseCandidate(candidate, config, signal) {
74
+ /**
75
+ * The walk's view of a file whose extraction came from the cache: the same record a fresh parse
76
+ * would have produced, with the extraction the cache matched.
77
+ *
78
+ * The modification time and size come from the stat taken just now, not from the cache entry, because
79
+ * the graph records them and `status` measures staleness as `file mtime > recorded mtime`. Under
80
+ * stat verification the two are equal by construction; under hash verification a file whose content
81
+ * matched but whose mtime was touched would otherwise be recorded with the old mtime and reported
82
+ * stale on every later status call, even though the graph describes its exact content.
83
+ * @param path - the candidate's project-relative path, which the cache entry does not store itself.
84
+ * @param cached - the entry this file's content matched.
85
+ * @param stats - the file's stat from this run.
86
+ * @returns the file as the resolver and graph writer expect it.
87
+ */
88
+ function reusedFile(path, cached, stats) {
89
+ return {
90
+ path,
91
+ language: cached.language,
92
+ size: stats.size,
93
+ modifiedAt: stats.mtimeMs,
94
+ contentHash: cached.contentHash,
95
+ lineCount: cached.lineCount,
96
+ extraction: cached.extraction,
97
+ };
98
+ }
99
+ /**
100
+ * Parse one candidate, or reuse the extraction a previous run cached for it.
101
+ * @param candidate - the file to parse.
102
+ * @param config - the walk's bounds.
103
+ * @param cache - the previous run's extractions, keyed by project-relative path.
104
+ * @param signal - aborts the parse.
105
+ * @returns the extracted file (and whether it came from the cache), `'minified'` when it was skipped
106
+ * as generated output, or `'skipped'` when it is over {@link WalkConfig.maxFileBytes}.
107
+ */
108
+ async function parseCandidate(candidate, config, cache, signal) {
109
+ // Checked before the stat and read: a `.min.` name settles it with no I/O at all.
110
+ if (config.skipMinified && isMinifiedName(candidate.path))
111
+ return { outcome: 'minified' };
74
112
  const stats = await stat(candidate.absolute);
75
113
  if (stats.size > config.maxFileBytes)
76
- return undefined;
114
+ return { outcome: 'skipped' };
77
115
  const spec = languageFor(posix.extname(candidate.path));
78
116
  /* v8 ignore next 2 -- discover() only ever produces a candidate whose extension resolved to a spec. */
79
117
  if (spec === undefined)
80
- return undefined;
81
- const text = await readFile(candidate.absolute, 'utf8');
118
+ return { outcome: 'skipped' };
119
+ // Verified before the parser is even created: this is the whole point of the cache — an unchanged
120
+ // file costs one stat when stat verification is configured, and nothing beyond that.
121
+ const cached = cache.get(candidate.path);
122
+ let text;
123
+ if (cached !== undefined) {
124
+ if (config.cacheVerify === 'stat') {
125
+ if (cached.modifiedAt === stats.mtimeMs && cached.size === stats.size) {
126
+ return { outcome: 'extracted', file: reusedFile(candidate.path, cached, stats), reused: true };
127
+ }
128
+ }
129
+ else {
130
+ // Content is the only thing this mode trusts, so the read it needs to verify is also the read
131
+ // the parse below would need — a miss here costs nothing extra.
132
+ text = await readFile(candidate.absolute, 'utf8');
133
+ if (createHash('sha256').update(text).digest('hex') === cached.contentHash) {
134
+ return { outcome: 'extracted', file: reusedFile(candidate.path, cached, stats), reused: true };
135
+ }
136
+ }
137
+ }
138
+ text ??= await readFile(candidate.absolute, 'utf8');
139
+ const shape = measureText(text);
140
+ if (config.skipMinified && looksMinified(candidate.path, shape))
141
+ return { outcome: 'minified' };
82
142
  const parser = await createParser(spec);
83
143
  try {
84
144
  signal?.throwIfAborted();
@@ -86,16 +146,20 @@ async function parseCandidate(candidate, config, signal) {
86
146
  /* v8 ignore next 2 -- createParser() always assigns a language before returning; parse() returns
87
147
  * null only when the parser has none. */
88
148
  if (tree === null)
89
- return undefined;
149
+ return { outcome: 'skipped' };
90
150
  const extraction = extractFile(tree, spec);
91
151
  return {
92
- path: candidate.path,
93
- language: spec.language,
94
- size: stats.size,
95
- modifiedAt: stats.mtimeMs,
96
- contentHash: createHash('sha256').update(text).digest('hex'),
97
- lineCount: text.split('\n').length,
98
- extraction,
152
+ outcome: 'extracted',
153
+ reused: false,
154
+ file: {
155
+ path: candidate.path,
156
+ language: spec.language,
157
+ size: stats.size,
158
+ modifiedAt: stats.mtimeMs,
159
+ contentHash: createHash('sha256').update(text).digest('hex'),
160
+ lineCount: shape.lineCount,
161
+ extraction,
162
+ },
99
163
  };
100
164
  }
101
165
  finally {
@@ -124,23 +188,33 @@ async function mapWithConcurrency(items, concurrency, task) {
124
188
  return results;
125
189
  }
126
190
  /**
127
- * Walk `projectRoot` and parse every candidate file it finds.
191
+ * Walk `projectRoot` and parse every candidate file it finds — or reuse the extraction a previous
192
+ * run cached for it.
128
193
  * @param projectRoot - absolute path of the workspace to index.
129
194
  * @param config - the walk's bounds.
130
195
  * @param signal - aborts the walk.
131
- * @returns the parsed files and how many candidates were skipped (over size, or past `maxFiles`).
196
+ * @returns the parsed files, how many candidates were skipped (over size, or past `maxFiles`), and
197
+ * how many files reused a cached extraction.
132
198
  */
133
199
  export async function walkAndExtract(projectRoot, config, signal) {
134
200
  const { candidates, overflow } = await discover(projectRoot, config, signal);
135
- const parsed = await mapWithConcurrency(candidates, config.concurrency, candidate => parseCandidate(candidate, config, signal));
201
+ const cache = config.reuseParsedCache ? await readParseCache(projectRoot) : new Map();
202
+ const parsed = await mapWithConcurrency(candidates, config.concurrency, candidate => parseCandidate(candidate, config, cache, signal));
136
203
  const files = [];
137
204
  let filesSkipped = overflow;
138
- for (const file of parsed) {
139
- if (file === undefined)
205
+ let filesMinified = 0;
206
+ let filesReused = 0;
207
+ for (const result of parsed) {
208
+ if (result.outcome === 'skipped')
140
209
  filesSkipped++;
141
- else
142
- files.push(file);
210
+ else if (result.outcome === 'minified')
211
+ filesMinified++;
212
+ else {
213
+ files.push(result.file);
214
+ if (result.reused)
215
+ filesReused++;
216
+ }
143
217
  }
144
- return { files, filesSkipped };
218
+ return { files, filesSkipped, filesMinified, filesReused };
145
219
  }
146
220
  //# sourceMappingURL=walk.js.map
package/lib/watcher.d.ts CHANGED
@@ -1,52 +1,112 @@
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
- /** One raw watch event, in the shape `node:fs`'s `fs.watch` listener callback receives. */
27
- export type WatchEventListener = (eventType: 'rename' | 'change', filename: string | null) => void;
28
- /** A watch error, in the shape `fs.watch`'s `'error'` event delivers. */
29
- export type WatchErrorListener = (error: unknown) => void;
30
- /** A live OS-level watch, closeable independent of anything else this module tracks. */
31
- export interface WatchHandle {
32
- close(): void;
36
+ /**
37
+ * One resolved filesystem target — the opaque identity `ctx.fs` hands back and every other fs call
38
+ * takes. Only `targetKey` is read here: the module never parses it, and a remote backend's key is
39
+ * as valid as a local path.
40
+ */
41
+ export interface WatchTarget {
42
+ /** Opaque backend identity of the target. */
43
+ readonly targetKey: string;
44
+ }
45
+ /** One direct child of a listed directory, as `ctx.fs.listDir` reports it. */
46
+ export interface WatchDirEntry {
47
+ /** Basename of the child inside the listed directory. */
48
+ readonly name: string;
49
+ /** Whether the child is a regular file, a directory, or something else. */
50
+ readonly type: 'file' | 'directory' | 'other';
51
+ /** Resolved child target, usable for a follow-up `watch`/`listDir`. */
52
+ readonly target: WatchTarget;
53
+ /**
54
+ * Opaque freshness token, when the backend can report one cheaply. Opting into it is what makes a
55
+ * same-size content change visible to the change diff below, so a backend that omits it can still
56
+ * attribute creations, removals, and size-changing rewrites — but not a rewrite that preserved the
57
+ * size, which is then covered by the next attributable change or by the next explicit index.
58
+ */
59
+ readonly version?: string;
60
+ /** Byte size of a regular file, when the backend can report one. */
61
+ readonly size?: number;
33
62
  }
34
63
  /**
35
- * Indirection over `fs.watch` so tests can inject a fake that emits events/errors deterministically —
36
- * `fs.watch` is a non-configurable module property that can't be spied, and provoking real EMFILE/
37
- * ENOSPC exhaustion in a test run isn't reliable. Production always uses {@link nodeWatch}.
64
+ * The narrow slice of `ctx.fs` this module consumes.
65
+ *
66
+ * Deliberately structural rather than an import of the host's `FileSystem`: the tree-sitter package
67
+ * declares no dependency on `@deepseek-ai/dsh-fs`, and `watch` only exists from host 0.2 onward,
68
+ * while the package's supported peer range starts earlier. Naming the three calls it uses — resolve a
69
+ * directory, list it, observe it — keeps this module compilable and loadable across that range, and
70
+ * keeps the port small enough that a test double is a few lines.
38
71
  */
39
- export type WatchPrimitive = (path: string, options: {
40
- readonly recursive: boolean;
41
- }, onEvent: WatchEventListener, onError: WatchErrorListener) => WatchHandle;
42
- /** The real `fs.watch`-backed primitive. */
43
- export declare const nodeWatch: WatchPrimitive;
72
+ export interface WatchFileSystem {
73
+ /**
74
+ * Resolve a path into a stable backend target.
75
+ * @param path - the path to resolve; absolute paths are what this module passes.
76
+ * @param opts - optional resolution base and cancellation signal.
77
+ * @returns the resolved target.
78
+ */
79
+ resolve(path: string, opts?: {
80
+ readonly cwd?: string;
81
+ readonly signal?: AbortSignal;
82
+ }): Promise<WatchTarget>;
83
+ /**
84
+ * List one directory's direct children.
85
+ * @param target - the resolved directory target.
86
+ * @param signal - aborts the listing.
87
+ * @returns one entry per direct child.
88
+ */
89
+ listDir(target: WatchTarget, signal?: AbortSignal): Promise<readonly WatchDirEntry[]>;
90
+ /**
91
+ * Observe one directory's direct entries.
92
+ * @param target - the resolved directory target.
93
+ * @param changed - invalidation callback, called with no argument for a change and with an error
94
+ * when the backend loses the watch; initialization failures are reported by rejecting instead.
95
+ * @param signal - cancels initialization only; an initialized watch is released through the
96
+ * returned disposer.
97
+ * @returns the disposer that releases the watch.
98
+ */
99
+ watch(target: WatchTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>>;
100
+ }
44
101
  /** Why live watching was permanently disabled for this watcher instance. */
45
102
  export type DegradeReason = {
46
103
  readonly code: 'RESOURCE_EXHAUSTED';
47
104
  readonly cause: NodeJS.ErrnoException;
48
105
  } | {
49
106
  readonly code: 'WATCH_LIMIT_EXCEEDED';
107
+ } | {
108
+ readonly code: 'WATCH_UNAVAILABLE';
109
+ readonly cause: unknown;
50
110
  } | {
51
111
  readonly code: 'SYNC_FAILURE_LIMIT';
52
112
  readonly cause: unknown;
@@ -61,11 +121,11 @@ export interface WatchConfig {
61
121
  readonly respectGitignore: boolean;
62
122
  /** Milliseconds of quiet after the last change before `sync` runs. */
63
123
  readonly debounceMs: number;
64
- /** Hard cap on directories watched individually (Linux only); exceeding it degrades the watcher. */
124
+ /** Hard cap on directories watched individually; exceeding it degrades the watcher. */
65
125
  readonly maxWatchedDirectories: number;
66
126
  /**
67
- * Run one reindex. `paths` are the project-relative paths a debounce window collected, offered for
68
- * a future incremental sync to consult; this version's caller always ignores them and rebuilds the
127
+ * Run one reindex. `paths` are the project-relative paths of the direct entries whose change the
128
+ * per-directory watch could attribute; this version's caller always ignores them and rebuilds the
69
129
  * whole graph.
70
130
  */
71
131
  readonly sync: (paths?: readonly string[]) => Promise<{
@@ -83,13 +143,18 @@ export interface Watcher {
83
143
  stop(): void;
84
144
  /** Whether live watching has permanently degraded (until the next {@link start}). */
85
145
  isDegraded(): boolean;
86
- /** Project-relative paths seen since the last debounce window fired a sync. */
146
+ /**
147
+ * Project-relative paths of the entries whose change is queued for the next sync, collected since
148
+ * the last debounce window fired one. A path names a direct entry of a watched directory — file or
149
+ * directory — because that is the finest grain the seam can attribute a change to.
150
+ */
87
151
  getPendingFiles(): readonly string[];
88
152
  }
89
153
  /**
90
154
  * Build a watcher over one workspace root. Nothing runs until {@link Watcher.start} is called.
91
155
  * @param config - scope, timing, and the sync callback to drive.
92
- * @param primitive - the watch primitive to use; defaults to the real `fs.watch`.
156
+ * @param fs - the filesystem seam to resolve, enumerate, and watch through.
157
+ * @returns the watcher; every resource it acquires is released by {@link Watcher.stop}.
93
158
  */
94
- export declare function createWatcher(config: WatchConfig, primitive?: WatchPrimitive): Watcher;
159
+ export declare function createWatcher(config: WatchConfig, fs: WatchFileSystem): Watcher;
95
160
  //# sourceMappingURL=watcher.d.ts.map