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/cache.d.ts +65 -0
- package/lib/cache.js +142 -0
- package/lib/index.d.ts +45 -7
- package/lib/index.js +36 -7
- package/lib/minified.d.ts +52 -0
- package/lib/minified.js +58 -0
- package/lib/resolve.d.ts +13 -6
- package/lib/resolve.js +50 -13
- package/lib/schema.d.ts +2 -0
- package/lib/schema.js +27 -2
- package/lib/walk.d.ts +35 -2
- package/lib/walk.js +95 -21
- package/lib/watcher.d.ts +106 -41
- package/lib/watcher.js +307 -163
- package/package.json +3 -2
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
|
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
|
|
243
|
-
*
|
|
244
|
-
* not depend on which candidate pool the caller
|
|
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
|
|
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
|
-
/**
|
|
73
|
-
|
|
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
|
|
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
|
|
81
|
-
|
|
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
|
|
149
|
+
return { outcome: 'skipped' };
|
|
90
150
|
const extraction = extractFile(tree, spec);
|
|
91
151
|
return {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
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
|