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/cache.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse-result cache: the extraction this package computed for each file on the previous run, so a
|
|
3
|
+
* repeat index of an unchanged workspace can skip the read, the parse, and the tree walk for every
|
|
4
|
+
* file whose content did not change.
|
|
5
|
+
*
|
|
6
|
+
* The cache is an accelerator with no authority. It is read only as a hint — anything missing,
|
|
7
|
+
* unreadable, corrupt, written by another version, or shaped unexpectedly is discarded and the file
|
|
8
|
+
* is parsed again, so losing it costs time and never correctness. That is what lets
|
|
9
|
+
* {@link readParseCache} swallow every failure and {@link writeParseCache} tolerate a workspace this
|
|
10
|
+
* process may not write to.
|
|
11
|
+
* @module dsh-plugin-codegraph-tree-sitter/cache
|
|
12
|
+
*/
|
|
13
|
+
import type { FileExtraction } from './extract.ts';
|
|
14
|
+
import type { ExtractedFile } from './resolve.ts';
|
|
15
|
+
/**
|
|
16
|
+
* Version of the extraction semantics this cache records. Bump it by hand whenever `extract.ts` (or
|
|
17
|
+
* a grammar that feeds it) starts producing a different result for the same source text: a stale
|
|
18
|
+
* entry would otherwise be reused as if it were current, and the graph would silently keep the old
|
|
19
|
+
* shape.
|
|
20
|
+
*/
|
|
21
|
+
export declare const EXTRACTION_CACHE_VERSION = 1;
|
|
22
|
+
/** Path of the parse cache, relative to a project root. */
|
|
23
|
+
export declare const EXTRACTION_CACHE_RELATIVE_PATH = ".codegraph/cache/extractions.json";
|
|
24
|
+
/** One file's cached extraction, together with the identity it was computed from. */
|
|
25
|
+
export interface CachedFile {
|
|
26
|
+
/** Epoch milliseconds of the file's modification time when its extraction was produced. */
|
|
27
|
+
readonly modifiedAt: number;
|
|
28
|
+
/** File size in bytes at that same moment. */
|
|
29
|
+
readonly size: number;
|
|
30
|
+
/** SHA-256 of the content the extraction was produced from, hex-encoded. */
|
|
31
|
+
readonly contentHash: string;
|
|
32
|
+
/** Line count of that content, recorded so a reused file still gets a correct span. */
|
|
33
|
+
readonly lineCount: number;
|
|
34
|
+
/** Seam language label the extraction was produced for. */
|
|
35
|
+
readonly language: string;
|
|
36
|
+
/** The extraction itself. */
|
|
37
|
+
readonly extraction: FileExtraction;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Load the previous run's extractions for `projectRoot`.
|
|
41
|
+
*
|
|
42
|
+
* Never throws: a cache that cannot be read, parsed, or trusted yields an empty map, which makes the
|
|
43
|
+
* caller parse everything. A single malformed entry discards the whole file — an entry is either a
|
|
44
|
+
* complete record this package wrote or evidence the document was damaged, and reasoning about
|
|
45
|
+
* partial reuse is not worth the risk of a graph built from a half-written entry.
|
|
46
|
+
* @param projectRoot - absolute path of the workspace whose cache to load.
|
|
47
|
+
* @returns the cached extractions keyed by project-relative path, empty when none can be trusted.
|
|
48
|
+
*/
|
|
49
|
+
export declare function readParseCache(projectRoot: string): Promise<Map<string, CachedFile>>;
|
|
50
|
+
/**
|
|
51
|
+
* Record this run's extractions as the next run's cache.
|
|
52
|
+
*
|
|
53
|
+
* Written through a sibling temporary name and `rename`d into place, the same way `./schema.ts`
|
|
54
|
+
* replaces its status sidecar, so a reader never sees half a document. The temporary name is fixed
|
|
55
|
+
* rather than unique: a write interrupted before its rename is overwritten by the next attempt
|
|
56
|
+
* instead of accumulating, and a torn leftover is rejected by {@link readParseCache} at the cost of
|
|
57
|
+
* one run's reuse.
|
|
58
|
+
*
|
|
59
|
+
* A failure is swallowed on purpose. A read-only workspace, a full disk, or a locked directory must
|
|
60
|
+
* still be indexable; the cache is the optimization, never the product.
|
|
61
|
+
* @param projectRoot - absolute path of the workspace to record the cache for.
|
|
62
|
+
* @param files - every file this run produced an extraction for.
|
|
63
|
+
*/
|
|
64
|
+
export declare function writeParseCache(projectRoot: string, files: readonly ExtractedFile[]): Promise<void>;
|
|
65
|
+
//# sourceMappingURL=cache.d.ts.map
|
package/lib/cache.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse-result cache: the extraction this package computed for each file on the previous run, so a
|
|
3
|
+
* repeat index of an unchanged workspace can skip the read, the parse, and the tree walk for every
|
|
4
|
+
* file whose content did not change.
|
|
5
|
+
*
|
|
6
|
+
* The cache is an accelerator with no authority. It is read only as a hint — anything missing,
|
|
7
|
+
* unreadable, corrupt, written by another version, or shaped unexpectedly is discarded and the file
|
|
8
|
+
* is parsed again, so losing it costs time and never correctness. That is what lets
|
|
9
|
+
* {@link readParseCache} swallow every failure and {@link writeParseCache} tolerate a workspace this
|
|
10
|
+
* process may not write to.
|
|
11
|
+
* @module dsh-plugin-codegraph-tree-sitter/cache
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
/**
|
|
16
|
+
* Version of the extraction semantics this cache records. Bump it by hand whenever `extract.ts` (or
|
|
17
|
+
* a grammar that feeds it) starts producing a different result for the same source text: a stale
|
|
18
|
+
* entry would otherwise be reused as if it were current, and the graph would silently keep the old
|
|
19
|
+
* shape.
|
|
20
|
+
*/
|
|
21
|
+
export const EXTRACTION_CACHE_VERSION = 1;
|
|
22
|
+
/** Path of the parse cache, relative to a project root. */
|
|
23
|
+
export const EXTRACTION_CACHE_RELATIVE_PATH = '.codegraph/cache/extractions.json';
|
|
24
|
+
/**
|
|
25
|
+
* Whether `value` is a plain JSON object. Arrays are deliberately included: every caller still
|
|
26
|
+
* validates the fields it needs, and an array simply carries none of them.
|
|
27
|
+
* @param value - the parsed JSON value to test.
|
|
28
|
+
* @returns whether it is a non-null object.
|
|
29
|
+
*/
|
|
30
|
+
function isRecord(value) {
|
|
31
|
+
return typeof value === 'object' && value !== null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Whether `value` is a usable extraction. Deliberately shallow past the four arrays: those are what
|
|
35
|
+
* `resolveWorkspace` indexes into, and the only expected producer is this package's own writer.
|
|
36
|
+
* @param value - the value to test.
|
|
37
|
+
* @returns whether it has the four collections an extraction consists of.
|
|
38
|
+
*/
|
|
39
|
+
function isFileExtraction(value) {
|
|
40
|
+
return isRecord(value)
|
|
41
|
+
&& Array.isArray(value.definitions)
|
|
42
|
+
&& Array.isArray(value.calls)
|
|
43
|
+
&& Array.isArray(value.imports)
|
|
44
|
+
&& Array.isArray(value.heritage);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether `value` is a complete cache entry.
|
|
48
|
+
* @param value - the value to test.
|
|
49
|
+
* @returns whether every field is present and of the type a reader will use it as.
|
|
50
|
+
*/
|
|
51
|
+
function isCachedFile(value) {
|
|
52
|
+
if (!isRecord(value))
|
|
53
|
+
return false;
|
|
54
|
+
return typeof value.modifiedAt === 'number'
|
|
55
|
+
&& typeof value.size === 'number'
|
|
56
|
+
&& typeof value.contentHash === 'string'
|
|
57
|
+
&& typeof value.lineCount === 'number'
|
|
58
|
+
&& typeof value.language === 'string'
|
|
59
|
+
&& isFileExtraction(value.extraction);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Load the previous run's extractions for `projectRoot`.
|
|
63
|
+
*
|
|
64
|
+
* Never throws: a cache that cannot be read, parsed, or trusted yields an empty map, which makes the
|
|
65
|
+
* caller parse everything. A single malformed entry discards the whole file — an entry is either a
|
|
66
|
+
* complete record this package wrote or evidence the document was damaged, and reasoning about
|
|
67
|
+
* partial reuse is not worth the risk of a graph built from a half-written entry.
|
|
68
|
+
* @param projectRoot - absolute path of the workspace whose cache to load.
|
|
69
|
+
* @returns the cached extractions keyed by project-relative path, empty when none can be trusted.
|
|
70
|
+
*/
|
|
71
|
+
export async function readParseCache(projectRoot) {
|
|
72
|
+
const cachePath = join(projectRoot, EXTRACTION_CACHE_RELATIVE_PATH);
|
|
73
|
+
let raw;
|
|
74
|
+
try {
|
|
75
|
+
raw = await readFile(cachePath, 'utf8');
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// The ordinary first run has no cache file yet; an unreadable one is the same answer to the
|
|
79
|
+
// caller — nothing to reuse — and must not fail an index that can still parse the workspace.
|
|
80
|
+
return new Map();
|
|
81
|
+
}
|
|
82
|
+
let document;
|
|
83
|
+
try {
|
|
84
|
+
document = JSON.parse(raw);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return new Map();
|
|
88
|
+
}
|
|
89
|
+
if (!isRecord(document))
|
|
90
|
+
return new Map();
|
|
91
|
+
if (document.cacheVersion !== EXTRACTION_CACHE_VERSION)
|
|
92
|
+
return new Map();
|
|
93
|
+
const entries = document.files;
|
|
94
|
+
if (!isRecord(entries))
|
|
95
|
+
return new Map();
|
|
96
|
+
const cache = new Map();
|
|
97
|
+
for (const [path, entry] of Object.entries(entries)) {
|
|
98
|
+
if (!isCachedFile(entry))
|
|
99
|
+
return new Map();
|
|
100
|
+
cache.set(path, entry);
|
|
101
|
+
}
|
|
102
|
+
return cache;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Record this run's extractions as the next run's cache.
|
|
106
|
+
*
|
|
107
|
+
* Written through a sibling temporary name and `rename`d into place, the same way `./schema.ts`
|
|
108
|
+
* replaces its status sidecar, so a reader never sees half a document. The temporary name is fixed
|
|
109
|
+
* rather than unique: a write interrupted before its rename is overwritten by the next attempt
|
|
110
|
+
* instead of accumulating, and a torn leftover is rejected by {@link readParseCache} at the cost of
|
|
111
|
+
* one run's reuse.
|
|
112
|
+
*
|
|
113
|
+
* A failure is swallowed on purpose. A read-only workspace, a full disk, or a locked directory must
|
|
114
|
+
* still be indexable; the cache is the optimization, never the product.
|
|
115
|
+
* @param projectRoot - absolute path of the workspace to record the cache for.
|
|
116
|
+
* @param files - every file this run produced an extraction for.
|
|
117
|
+
*/
|
|
118
|
+
export async function writeParseCache(projectRoot, files) {
|
|
119
|
+
const cachePath = join(projectRoot, EXTRACTION_CACHE_RELATIVE_PATH);
|
|
120
|
+
const tempPath = `${cachePath}.tmp`;
|
|
121
|
+
try {
|
|
122
|
+
const entries = {};
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
entries[file.path] = {
|
|
125
|
+
modifiedAt: file.modifiedAt,
|
|
126
|
+
size: file.size,
|
|
127
|
+
contentHash: file.contentHash,
|
|
128
|
+
lineCount: file.lineCount,
|
|
129
|
+
language: file.language,
|
|
130
|
+
extraction: file.extraction,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const document = { cacheVersion: EXTRACTION_CACHE_VERSION, files: entries };
|
|
134
|
+
await mkdir(dirname(cachePath), { recursive: true });
|
|
135
|
+
await writeFile(tempPath, JSON.stringify(document));
|
|
136
|
+
await rename(tempPath, cachePath);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// See above: an unusable cache location only means the next run parses everything again.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=cache.js.map
|
package/lib/index.d.ts
CHANGED
|
@@ -10,6 +10,18 @@
|
|
|
10
10
|
* runs implicitly from a query — it is a caller-initiated, potentially multi-minute operation, kept
|
|
11
11
|
* out of the seam's read-only query path on purpose.
|
|
12
12
|
*
|
|
13
|
+
* Watching a root runs through `ctx.fs` (see `./watcher.ts`): the package observes each in-scope
|
|
14
|
+
* directory through the host's filesystem seam rather than opening paths itself, so a workspace a
|
|
15
|
+
* backend serves from another execution world is watched where it actually lives, and a backend that
|
|
16
|
+
* cannot watch at all degrades to explicit indexing instead of failing the plugin.
|
|
17
|
+
*
|
|
18
|
+
* A workspace with no index yet is also indexed automatically the first time a session is created in
|
|
19
|
+
* it (`agent/created`), through the host's background-job registry when the composition has one. The
|
|
20
|
+
* graph is most useful on the first `codegraph` query, and a session that starts in an unindexed
|
|
21
|
+
* workspace is exactly the moment a caller would otherwise have to ask for a build by hand. This
|
|
22
|
+
* never runs synchronously inside the event dispatch: creating an agent must not wait on a
|
|
23
|
+
* multi-minute parse, and an index is an optimization, never a precondition for starting work.
|
|
24
|
+
*
|
|
13
25
|
* Namespace plugin (named exports, no default export). Grammars load lazily, one per language, on
|
|
14
26
|
* first sight of a matching file, and stay cached for the process — never eagerly for every grammar
|
|
15
27
|
* this package ships with.
|
|
@@ -28,10 +40,12 @@ export type { ExtractedFile, GraphEdge, GraphNode, ResolvedGraph, UnresolvedRef
|
|
|
28
40
|
export { isMinifiedName, looksMinified, measureText, MINIFIED_AVERAGE_LINE_LENGTH } from './minified.ts';
|
|
29
41
|
export type { TextShape } from './minified.ts';
|
|
30
42
|
export { SCHEMA_VERSION, writeGraph } from './schema.ts';
|
|
43
|
+
export { EXTRACTION_CACHE_RELATIVE_PATH, EXTRACTION_CACHE_VERSION, readParseCache, writeParseCache } from './cache.ts';
|
|
44
|
+
export type { CachedFile } from './cache.ts';
|
|
31
45
|
export { isExcluded, walkAndExtract } from './walk.ts';
|
|
32
46
|
export type { WalkConfig, WalkResult } from './walk.ts';
|
|
33
|
-
export { createWatcher
|
|
34
|
-
export type { DegradeReason, WatchConfig,
|
|
47
|
+
export { createWatcher } from './watcher.ts';
|
|
48
|
+
export type { DegradeReason, WatchConfig, WatchDirEntry, WatchFileSystem, WatchTarget, Watcher, } from './watcher.ts';
|
|
35
49
|
export { decideWatch, readProcVersion } from './watch-policy.ts';
|
|
36
50
|
export type { WatchDecision, WatchPolicyInput } from './watch-policy.ts';
|
|
37
51
|
export { detectWorktree, execGit } from './worktree.ts';
|
|
@@ -60,7 +74,7 @@ export declare const DEFAULT_MAX_FILES = 50000;
|
|
|
60
74
|
export declare const DEFAULT_CONCURRENCY = 4;
|
|
61
75
|
/** Default quiet period after the last change before a watch-triggered refresh runs. */
|
|
62
76
|
export declare const DEFAULT_WATCH_DEBOUNCE_MS = 2000;
|
|
63
|
-
/** Default cap on directories watched individually,
|
|
77
|
+
/** Default cap on directories watched individually, one watch each, by the fs seam. */
|
|
64
78
|
export declare const DEFAULT_MAX_WATCHED_DIRECTORIES = 4000;
|
|
65
79
|
/** Plugin configuration: the indexer's identity and every deployment-varying bound on a run. */
|
|
66
80
|
export interface Config {
|
|
@@ -100,6 +114,22 @@ export interface Config {
|
|
|
100
114
|
maxFiles?: number;
|
|
101
115
|
/** Files parsed concurrently (default 4). */
|
|
102
116
|
concurrency?: number;
|
|
117
|
+
/**
|
|
118
|
+
* Reuse the extraction a previous run cached for every file whose content is unchanged (default
|
|
119
|
+
* true), so a repeat index — or a watch rebuild after one edit — parses only what actually changed
|
|
120
|
+
* instead of the whole workspace. The cache lives at
|
|
121
|
+
* {@link EXTRACTION_CACHE_RELATIVE_PATH} and is rewritten after each successful run. Set false to
|
|
122
|
+
* parse every file every time, at the cost of the full parse each run.
|
|
123
|
+
*/
|
|
124
|
+
reuseParsedCache?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* How a cached file's unchanged content is confirmed before its extraction is reused (default
|
|
127
|
+
* `'stat'`): `stat` compares the modification time and size the cache recorded and reads nothing;
|
|
128
|
+
* `hash` re-reads the file and compares a SHA-256, which also catches a rewrite that preserved both
|
|
129
|
+
* mtime and size (a checkout restoring an older file over a newer one). Hash mode still skips the
|
|
130
|
+
* parse and the tree walk, which dominate the per-file cost.
|
|
131
|
+
*/
|
|
132
|
+
cacheVerify?: 'stat' | 'hash';
|
|
103
133
|
/**
|
|
104
134
|
* Watch the workspace for file changes and refresh the index automatically after a successful
|
|
105
135
|
* `index()` call establishes a baseline (default true). Set `watch: false` to keep indexing purely
|
|
@@ -114,16 +144,27 @@ export interface Config {
|
|
|
114
144
|
/** Quiet period after the last change before a watch-triggered refresh runs, in ms (default 2000). */
|
|
115
145
|
watchDebounceMs?: number;
|
|
116
146
|
/**
|
|
117
|
-
* Hard cap on directories watched individually (default 4000).
|
|
118
|
-
*
|
|
119
|
-
*
|
|
147
|
+
* Hard cap on directories watched individually (default 4000). Watching goes through the host's
|
|
148
|
+
* filesystem seam, whose `watch` covers one directory's direct entries, so a workspace costs one
|
|
149
|
+
* watch per in-scope directory; exceeding this cap degrades that root's watcher rather than
|
|
150
|
+
* covering only part of the tree.
|
|
120
151
|
*/
|
|
121
152
|
maxWatchedDirectories?: number;
|
|
153
|
+
/**
|
|
154
|
+
* Build a first index automatically, in the background, when a session is created in a workspace
|
|
155
|
+
* that has none (default true). A workspace is only ever auto-indexed once per process and never
|
|
156
|
+
* when its status sidecar already exists, so this cannot rebuild a graph that is already there.
|
|
157
|
+
* Set false to keep every build explicitly requested.
|
|
158
|
+
*
|
|
159
|
+
* Requires the host's `jobs` service: the build is never run synchronously inside session creation,
|
|
160
|
+
* so a composition without background jobs simply schedules nothing.
|
|
161
|
+
*/
|
|
162
|
+
autoIndexOnSession?: boolean;
|
|
122
163
|
}
|
|
123
164
|
export declare const Config: z<Config>;
|
|
124
165
|
/**
|
|
125
166
|
* Register the tree-sitter indexer.
|
|
126
|
-
* @param ctx - the plugin context (must inject `codegraph`).
|
|
167
|
+
* @param ctx - the plugin context (must inject `codegraph` and `fs`).
|
|
127
168
|
* @param config - the resolved plugin configuration.
|
|
128
169
|
*/
|
|
129
170
|
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
CHANGED
|
@@ -10,17 +10,31 @@
|
|
|
10
10
|
* runs implicitly from a query — it is a caller-initiated, potentially multi-minute operation, kept
|
|
11
11
|
* out of the seam's read-only query path on purpose.
|
|
12
12
|
*
|
|
13
|
+
* Watching a root runs through `ctx.fs` (see `./watcher.ts`): the package observes each in-scope
|
|
14
|
+
* directory through the host's filesystem seam rather than opening paths itself, so a workspace a
|
|
15
|
+
* backend serves from another execution world is watched where it actually lives, and a backend that
|
|
16
|
+
* cannot watch at all degrades to explicit indexing instead of failing the plugin.
|
|
17
|
+
*
|
|
18
|
+
* A workspace with no index yet is also indexed automatically the first time a session is created in
|
|
19
|
+
* it (`agent/created`), through the host's background-job registry when the composition has one. The
|
|
20
|
+
* graph is most useful on the first `codegraph` query, and a session that starts in an unindexed
|
|
21
|
+
* workspace is exactly the moment a caller would otherwise have to ask for a build by hand. This
|
|
22
|
+
* never runs synchronously inside the event dispatch: creating an agent must not wait on a
|
|
23
|
+
* multi-minute parse, and an index is an optimization, never a precondition for starting work.
|
|
24
|
+
*
|
|
13
25
|
* Namespace plugin (named exports, no default export). Grammars load lazily, one per language, on
|
|
14
26
|
* first sight of a matching file, and stay cached for the process — never eagerly for every grammar
|
|
15
27
|
* this package ships with.
|
|
16
28
|
* @module dsh-plugin-codegraph-tree-sitter
|
|
17
29
|
*/
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
18
31
|
import { stat } from 'node:fs/promises';
|
|
19
32
|
import { join } from 'node:path';
|
|
20
33
|
import z from '@deepseek-ai/schemastery';
|
|
21
|
-
import { CodegraphIndexerId } from 'dsh-plugin-codegraph-service';
|
|
34
|
+
import { CodegraphIndexerId, INDEX_STATUS_RELATIVE_PATH } from 'dsh-plugin-codegraph-service';
|
|
22
35
|
import { LANGUAGE_TABLE } from "./languages.js";
|
|
23
36
|
import { walkAndExtract } from "./walk.js";
|
|
37
|
+
import { writeParseCache } from "./cache.js";
|
|
24
38
|
import { resolveWorkspace } from "./resolve.js";
|
|
25
39
|
import { writeGraph } from "./schema.js";
|
|
26
40
|
import { createWatcher } from "./watcher.js";
|
|
@@ -31,15 +45,16 @@ export { loadGitignore, matchesGitignore, parseGitignore } from "./gitignore.js"
|
|
|
31
45
|
export { resolveWorkspace } from "./resolve.js";
|
|
32
46
|
export { isMinifiedName, looksMinified, measureText, MINIFIED_AVERAGE_LINE_LENGTH } from "./minified.js";
|
|
33
47
|
export { SCHEMA_VERSION, writeGraph } from "./schema.js";
|
|
48
|
+
export { EXTRACTION_CACHE_RELATIVE_PATH, EXTRACTION_CACHE_VERSION, readParseCache, writeParseCache } from "./cache.js";
|
|
34
49
|
export { isExcluded, walkAndExtract } from "./walk.js";
|
|
35
|
-
export { createWatcher
|
|
50
|
+
export { createWatcher } from "./watcher.js";
|
|
36
51
|
export { decideWatch, readProcVersion } from "./watch-policy.js";
|
|
37
52
|
export { detectWorktree, execGit } from "./worktree.js";
|
|
38
53
|
export { HOOK_MARKER_BEGIN, HOOK_MARKER_END, installGitHooks, uninstallGitHooks } from "./git-hooks.js";
|
|
39
54
|
/** Cordis plugin name for loader diagnostics. */
|
|
40
55
|
export const name = 'codegraph-tree-sitter';
|
|
41
56
|
/** Services required by this plugin. */
|
|
42
|
-
export const inject = ['codegraph'];
|
|
57
|
+
export const inject = ['codegraph', 'fs'];
|
|
43
58
|
/** Path of the on-disk graph this package writes, relative to a project root. */
|
|
44
59
|
export const DATABASE_RELATIVE_PATH = '.codegraph/codegraph.db';
|
|
45
60
|
/** Default branded identity this indexer reserves on the seam. */
|
|
@@ -58,7 +73,7 @@ export const DEFAULT_MAX_FILES = 50_000;
|
|
|
58
73
|
export const DEFAULT_CONCURRENCY = 4;
|
|
59
74
|
/** Default quiet period after the last change before a watch-triggered refresh runs. */
|
|
60
75
|
export const DEFAULT_WATCH_DEBOUNCE_MS = 2_000;
|
|
61
|
-
/** Default cap on directories watched individually,
|
|
76
|
+
/** Default cap on directories watched individually, one watch each, by the fs seam. */
|
|
62
77
|
export const DEFAULT_MAX_WATCHED_DIRECTORIES = 4_000;
|
|
63
78
|
export const Config = z.object({
|
|
64
79
|
indexerId: z.string().default(DEFAULT_INDEXER_ID),
|
|
@@ -69,13 +84,16 @@ export const Config = z.object({
|
|
|
69
84
|
maxFileBytes: z.number().default(DEFAULT_MAX_FILE_BYTES),
|
|
70
85
|
maxFiles: z.number().default(DEFAULT_MAX_FILES),
|
|
71
86
|
concurrency: z.number().default(DEFAULT_CONCURRENCY),
|
|
87
|
+
reuseParsedCache: z.boolean().default(true),
|
|
88
|
+
cacheVerify: z.union([z.const('stat'), z.const('hash')]).default('stat'),
|
|
72
89
|
watch: z.boolean().default(true),
|
|
73
90
|
watchDebounceMs: z.number().default(DEFAULT_WATCH_DEBOUNCE_MS),
|
|
74
91
|
maxWatchedDirectories: z.number().default(DEFAULT_MAX_WATCHED_DIRECTORIES),
|
|
92
|
+
autoIndexOnSession: z.boolean().default(true),
|
|
75
93
|
});
|
|
76
94
|
/**
|
|
77
95
|
* Register the tree-sitter indexer.
|
|
78
|
-
* @param ctx - the plugin context (must inject `codegraph`).
|
|
96
|
+
* @param ctx - the plugin context (must inject `codegraph` and `fs`).
|
|
79
97
|
* @param config - the resolved plugin configuration.
|
|
80
98
|
*/
|
|
81
99
|
export function apply(ctx, config) {
|
|
@@ -85,10 +103,25 @@ export function apply(ctx, config) {
|
|
|
85
103
|
assertPositiveInteger('concurrency', resolved.concurrency);
|
|
86
104
|
assertPositiveInteger('watchDebounceMs', resolved.watchDebounceMs);
|
|
87
105
|
assertPositiveInteger('maxWatchedDirectories', resolved.maxWatchedDirectories);
|
|
106
|
+
// Watching goes through the host's filesystem seam rather than `node:fs`, so it follows the same
|
|
107
|
+
// execution world the rest of the harness reads: a remote or confined backend watches its own paths.
|
|
108
|
+
// The `fs` service is a declared dependency (see `inject`), so it is present whenever `apply` runs.
|
|
109
|
+
// It is read structurally instead of importing `@deepseek-ai/dsh-fs`: that package is not a
|
|
110
|
+
// dependency here, and its `watch` method only exists from host 0.2 onward, while this package
|
|
111
|
+
// supports older peers that have no such member to import.
|
|
112
|
+
const fileSystem = ctx.fs;
|
|
88
113
|
// Keyed by project root, private to this plugin instance: never registered on `ctx`, so there is no
|
|
89
114
|
// cross-plugin relation here for an invariant to describe — just process-local bookkeeping this
|
|
90
115
|
// effect's cleanup below tears down on unload.
|
|
91
116
|
const watchers = new Map();
|
|
117
|
+
// Roots this plugin instance has already started an automatic index for. The set is only a guard
|
|
118
|
+
// against a burst of agents opened on one workspace each scheduling the same rebuild; what a
|
|
119
|
+
// previous *process* indexed is recorded by the status sidecar `autoIndexOnSession` reads instead.
|
|
120
|
+
const autoIndexed = new Set();
|
|
121
|
+
// `agent/created` is typed by `@deepseek-ai/dsh-agent`'s module augmentation. Reading the event
|
|
122
|
+
// through a local structural face keeps this package's host dependencies unchanged: it needs one
|
|
123
|
+
// event name and one payload shape, not the whole agent package.
|
|
124
|
+
const agentEvents = ctx;
|
|
92
125
|
/** Start (or leave running) file watching for `projectRoot`, once it has a baseline to refresh. */
|
|
93
126
|
function ensureWatching(projectRoot) {
|
|
94
127
|
if (watchers.has(projectRoot))
|
|
@@ -115,7 +148,7 @@ export function apply(ctx, config) {
|
|
|
115
148
|
console.error(`codegraph-tree-sitter: file watching for "${projectRoot}" stopped permanently (${reason.code}); ` +
|
|
116
149
|
'the index will no longer refresh on its own — run codegraph_index manually to update it.');
|
|
117
150
|
},
|
|
118
|
-
});
|
|
151
|
+
}, fileSystem);
|
|
119
152
|
watchers.set(projectRoot, watcher);
|
|
120
153
|
watcher.start();
|
|
121
154
|
}
|
|
@@ -146,9 +179,92 @@ export function apply(ctx, config) {
|
|
|
146
179
|
watcher.stop();
|
|
147
180
|
watchers.clear();
|
|
148
181
|
};
|
|
182
|
+
if (resolved.autoIndexOnSession) {
|
|
183
|
+
// Registered inside this effect so the listener follows the plugin's lifetime: unloading the
|
|
184
|
+
// plugin must stop new sessions from scheduling work this instance can no longer run.
|
|
185
|
+
yield agentEvents.on('agent/created', payload => autoIndexOnSession(ctx, autoIndexed, payload));
|
|
186
|
+
}
|
|
149
187
|
yield ctx.codegraph.registerIndexer(indexer);
|
|
150
188
|
}, 'codegraph-tree-sitter');
|
|
151
189
|
}
|
|
190
|
+
/** Stable job kind of an automatic session-start index, used for retention and diagnostics. */
|
|
191
|
+
const AUTO_INDEX_JOB_KIND = 'codegraph-index';
|
|
192
|
+
/**
|
|
193
|
+
* Index a new session's workspace in the background, when nothing has indexed it yet.
|
|
194
|
+
*
|
|
195
|
+
* Runs synchronously inside `agent/created` dispatch, so it must never block or throw: it only decides
|
|
196
|
+
* whether a build is warranted and hands one to the host's job registry. A synchronous build here
|
|
197
|
+
* would stall agent creation for the length of a parse, which is why a composition without background
|
|
198
|
+
* jobs does nothing instead.
|
|
199
|
+
* @param ctx - the plugin context, consulted for the optional job registry.
|
|
200
|
+
* @param autoIndexed - roots this process has already started an automatic index for.
|
|
201
|
+
* @param payload - the `agent/created` dispatch payload.
|
|
202
|
+
*/
|
|
203
|
+
function autoIndexOnSession(ctx, autoIndexed, payload) {
|
|
204
|
+
try {
|
|
205
|
+
const agent = payload.agent;
|
|
206
|
+
const root = agent?.session?.header?.cwd;
|
|
207
|
+
// No workspace means no root to claim: an agent created without a `cwd` has nothing to index.
|
|
208
|
+
if (agent === undefined || !root)
|
|
209
|
+
return;
|
|
210
|
+
// Once per root per process. A second agent opened on the same workspace must not queue a second
|
|
211
|
+
// full rebuild of a graph the first one is already building.
|
|
212
|
+
if (autoIndexed.has(root))
|
|
213
|
+
return;
|
|
214
|
+
// An existing status sidecar means a previous run already indexed this workspace. Checked before
|
|
215
|
+
// the job lookup so a composition without background jobs never pays for a filesystem probe.
|
|
216
|
+
if (existsSync(join(root, INDEX_STATUS_RELATIVE_PATH)))
|
|
217
|
+
return;
|
|
218
|
+
// `ctx.get` rather than an `inject` entry: `jobs` is a host composition choice, and requiring it in
|
|
219
|
+
// `inject` would hide this whole indexer from a profile that runs no background jobs.
|
|
220
|
+
const jobs = ctx.get('jobs');
|
|
221
|
+
if (jobs === undefined)
|
|
222
|
+
return;
|
|
223
|
+
jobs.start({
|
|
224
|
+
// The registry keys a job by its owning session id, not the agent object.
|
|
225
|
+
owner: agent.id,
|
|
226
|
+
kind: AUTO_INDEX_JOB_KIND,
|
|
227
|
+
label: `codegraph auto index ${root}`,
|
|
228
|
+
run: handle => runAutoIndexJob(ctx, root, handle),
|
|
229
|
+
});
|
|
230
|
+
// Marked only after the registry accepted the job, so a transient refusal leaves the workspace
|
|
231
|
+
// eligible for the next session that opens in it.
|
|
232
|
+
autoIndexed.add(root);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
// The host treats a throwing listener as vetoing publication. Losing an index is a degradation,
|
|
236
|
+
// never a reason to stop a session from starting.
|
|
237
|
+
console.error(`codegraph-tree-sitter: automatic indexing for a new session failed: ${String(error)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The producer body of one automatic indexing job.
|
|
242
|
+
*
|
|
243
|
+
* Both halves of the host's job contract are mandatory: `cancel` aborts the run, and `done` settles
|
|
244
|
+
* even on failure — a rejected `done` breaks the registry's bookkeeping and leaves the job looking
|
|
245
|
+
* stuck forever.
|
|
246
|
+
* @param ctx - the plugin context.
|
|
247
|
+
* @param root - the workspace to index.
|
|
248
|
+
* @param handle - the job's producer face, for the job's live log.
|
|
249
|
+
* @returns the job's cancellation hook and settlement promise.
|
|
250
|
+
*/
|
|
251
|
+
function runAutoIndexJob(ctx, root, handle) {
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
const done = (async () => {
|
|
254
|
+
try {
|
|
255
|
+
const report = await ctx.codegraph.index(root, controller.signal);
|
|
256
|
+
handle.append(`Indexed ${root}: ${report.filesIndexed} files, ${report.nodeCount} symbols, ` +
|
|
257
|
+
`${report.edgeCount} relationships.\n`);
|
|
258
|
+
return { status: 'completed' };
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
const message = String(error);
|
|
262
|
+
handle.append(`codegraph auto index failed for ${root}: ${message}\n`);
|
|
263
|
+
return { status: 'failed', detail: message };
|
|
264
|
+
}
|
|
265
|
+
})();
|
|
266
|
+
return { cancel: () => controller.abort(), done };
|
|
267
|
+
}
|
|
152
268
|
/**
|
|
153
269
|
* Run one watch-triggered reindex. Always a full rebuild in this version — `runIndex` has no notion of
|
|
154
270
|
* a file subset — so the watcher's own collected paths are not consulted here.
|
|
@@ -169,7 +285,7 @@ async function runSync(projectRoot, config) {
|
|
|
169
285
|
* @returns the run's report.
|
|
170
286
|
*/
|
|
171
287
|
async function runIndex(projectRoot, config, signal) {
|
|
172
|
-
const { files, filesSkipped, filesMinified } = await walkAndExtract(projectRoot, {
|
|
288
|
+
const { files, filesSkipped, filesMinified, filesReused } = await walkAndExtract(projectRoot, {
|
|
173
289
|
exclude: config.exclude,
|
|
174
290
|
respectGitignore: config.respectGitignore,
|
|
175
291
|
skipMinified: config.skipMinified,
|
|
@@ -177,13 +293,21 @@ async function runIndex(projectRoot, config, signal) {
|
|
|
177
293
|
maxFiles: config.maxFiles,
|
|
178
294
|
concurrency: config.concurrency,
|
|
179
295
|
languages: config.languages,
|
|
296
|
+
reuseParsedCache: config.reuseParsedCache,
|
|
297
|
+
cacheVerify: config.cacheVerify,
|
|
180
298
|
}, signal);
|
|
181
299
|
signal?.throwIfAborted();
|
|
182
300
|
const indexedAt = Date.now();
|
|
183
301
|
const graph = resolveWorkspace(files, indexedAt);
|
|
184
302
|
signal?.throwIfAborted();
|
|
185
303
|
const databasePath = join(projectRoot, DATABASE_RELATIVE_PATH);
|
|
186
|
-
await writeGraph(databasePath, {
|
|
304
|
+
await writeGraph(databasePath, {
|
|
305
|
+
files, nodes: graph.nodes, edges: graph.edges, unresolved: graph.unresolved, filesMinified, indexedAt,
|
|
306
|
+
});
|
|
307
|
+
// Only after the graph is in place: the cache records the extractions this run actually committed,
|
|
308
|
+
// so a run that failed before writing its graph leaves the previous cache — and the mtimes it was
|
|
309
|
+
// built from — alone instead of vouching for a graph that was never written.
|
|
310
|
+
await writeParseCache(projectRoot, files);
|
|
187
311
|
const languageCounts = new Map();
|
|
188
312
|
for (const file of files)
|
|
189
313
|
languageCounts.set(file.language, (languageCounts.get(file.language) ?? 0) + 1);
|
|
@@ -195,6 +319,7 @@ async function runIndex(projectRoot, config, signal) {
|
|
|
195
319
|
filesIndexed: files.length,
|
|
196
320
|
filesSkipped,
|
|
197
321
|
filesMinified,
|
|
322
|
+
filesReused,
|
|
198
323
|
nodeCount: graph.nodes.length,
|
|
199
324
|
edgeCount: graph.edges.length,
|
|
200
325
|
unresolvedCount: graph.unresolved.length,
|
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
|
@@ -46,6 +46,21 @@ export interface WalkConfig {
|
|
|
46
46
|
* workspace-wide name wins" call resolution would pick between them arbitrarily.
|
|
47
47
|
*/
|
|
48
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';
|
|
49
64
|
}
|
|
50
65
|
/** What the walk produced. */
|
|
51
66
|
export interface WalkResult {
|
|
@@ -55,13 +70,21 @@ export interface WalkResult {
|
|
|
55
70
|
readonly filesSkipped: number;
|
|
56
71
|
/** Candidates skipped because they are generated output — see {@link WalkConfig.skipMinified}. */
|
|
57
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;
|
|
58
79
|
}
|
|
59
80
|
/**
|
|
60
|
-
* 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.
|
|
61
83
|
* @param projectRoot - absolute path of the workspace to index.
|
|
62
84
|
* @param config - the walk's bounds.
|
|
63
85
|
* @param signal - aborts the walk.
|
|
64
|
-
* @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.
|
|
65
88
|
*/
|
|
66
89
|
export declare function walkAndExtract(projectRoot: string, config: WalkConfig, signal?: AbortSignal): Promise<WalkResult>;
|
|
67
90
|
//# sourceMappingURL=walk.d.ts.map
|