dsh-plugin-codegraph-tree-sitter 0.1.7 → 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/walk.js CHANGED
@@ -11,6 +11,7 @@
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";
@@ -71,28 +72,73 @@ async function discover(projectRoot, config, signal) {
71
72
  return { candidates, overflow };
72
73
  }
73
74
  /**
74
- * Parse one candidate.
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.
75
101
  * @param candidate - the file to parse.
76
102
  * @param config - the walk's bounds.
103
+ * @param cache - the previous run's extractions, keyed by project-relative path.
77
104
  * @param signal - aborts the parse.
78
- * @returns the extracted file, `'minified'` when it was skipped as generated output, or `undefined`
79
- * when it is over {@link WalkConfig.maxFileBytes}.
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}.
80
107
  */
81
- async function parseCandidate(candidate, config, signal) {
108
+ async function parseCandidate(candidate, config, cache, signal) {
82
109
  // Checked before the stat and read: a `.min.` name settles it with no I/O at all.
83
110
  if (config.skipMinified && isMinifiedName(candidate.path))
84
- return 'minified';
111
+ return { outcome: 'minified' };
85
112
  const stats = await stat(candidate.absolute);
86
113
  if (stats.size > config.maxFileBytes)
87
- return undefined;
114
+ return { outcome: 'skipped' };
88
115
  const spec = languageFor(posix.extname(candidate.path));
89
116
  /* v8 ignore next 2 -- discover() only ever produces a candidate whose extension resolved to a spec. */
90
117
  if (spec === undefined)
91
- return undefined;
92
- 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');
93
139
  const shape = measureText(text);
94
140
  if (config.skipMinified && looksMinified(candidate.path, shape))
95
- return 'minified';
141
+ return { outcome: 'minified' };
96
142
  const parser = await createParser(spec);
97
143
  try {
98
144
  signal?.throwIfAborted();
@@ -100,16 +146,20 @@ async function parseCandidate(candidate, config, signal) {
100
146
  /* v8 ignore next 2 -- createParser() always assigns a language before returning; parse() returns
101
147
  * null only when the parser has none. */
102
148
  if (tree === null)
103
- return undefined;
149
+ return { outcome: 'skipped' };
104
150
  const extraction = extractFile(tree, spec);
105
151
  return {
106
- path: candidate.path,
107
- language: spec.language,
108
- size: stats.size,
109
- modifiedAt: stats.mtimeMs,
110
- contentHash: createHash('sha256').update(text).digest('hex'),
111
- lineCount: shape.lineCount,
112
- 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
+ },
113
163
  };
114
164
  }
115
165
  finally {
@@ -138,26 +188,33 @@ async function mapWithConcurrency(items, concurrency, task) {
138
188
  return results;
139
189
  }
140
190
  /**
141
- * 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.
142
193
  * @param projectRoot - absolute path of the workspace to index.
143
194
  * @param config - the walk's bounds.
144
195
  * @param signal - aborts the walk.
145
- * @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.
146
198
  */
147
199
  export async function walkAndExtract(projectRoot, config, signal) {
148
200
  const { candidates, overflow } = await discover(projectRoot, config, signal);
149
- 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));
150
203
  const files = [];
151
204
  let filesSkipped = overflow;
152
205
  let filesMinified = 0;
206
+ let filesReused = 0;
153
207
  for (const result of parsed) {
154
- if (result === undefined)
208
+ if (result.outcome === 'skipped')
155
209
  filesSkipped++;
156
- else if (result === 'minified')
210
+ else if (result.outcome === 'minified')
157
211
  filesMinified++;
158
- else
159
- files.push(result);
212
+ else {
213
+ files.push(result.file);
214
+ if (result.reused)
215
+ filesReused++;
216
+ }
160
217
  }
161
- return { files, filesSkipped, filesMinified };
218
+ return { files, filesSkipped, filesMinified, filesReused };
162
219
  }
163
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