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/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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
92
|
-
|
|
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
|
|
149
|
+
return { outcome: 'skipped' };
|
|
104
150
|
const extraction = extractFile(tree, spec);
|
|
105
151
|
return {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
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 ===
|
|
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
|
|
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
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
export interface
|
|
32
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
68
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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,
|
|
159
|
+
export declare function createWatcher(config: WatchConfig, fs: WatchFileSystem): Watcher;
|
|
95
160
|
//# sourceMappingURL=watcher.d.ts.map
|