dsh-plugin-codegraph-tree-sitter 0.5.0 → 0.5.2

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/index.d.ts CHANGED
@@ -73,12 +73,25 @@ export declare const DEFAULT_EXCLUDE: string[];
73
73
  export declare const DEFAULT_MAX_FILE_BYTES = 2000000;
74
74
  /** Default ceiling on how many files one run indexes. */
75
75
  export declare const DEFAULT_MAX_FILES = 50000;
76
+ /**
77
+ * How many candidate source files a workspace may contain before a **session-start** index declines
78
+ * to run (default 8,000).
79
+ *
80
+ * `maxFiles` above is the bound for a run someone asked for; this one guards the run nobody asked
81
+ * for. A session opened with `cwd` pointing at a home directory or a tool's own data directory can
82
+ * cover tens of thousands of files — a real case reached 21,752 under `~/.dsh`, under the 50,000
83
+ * ceiling, and the Tree-sitter WASM heap died mid-parse while the desktop froze. Declining early
84
+ * costs one `readdir` pass; declining late costs the whole machine.
85
+ */
86
+ export declare const DEFAULT_AUTO_INDEX_MAX_FILES = 8000;
76
87
  /** Default number of files parsed concurrently. */
77
88
  export declare const DEFAULT_CONCURRENCY = 4;
78
89
  /** Default quiet period after the last change before a watch-triggered refresh runs. */
79
90
  export declare const DEFAULT_WATCH_DEBOUNCE_MS = 2000;
80
91
  /** Default cap on directories watched individually, one watch each, by the fs seam. */
81
92
  export declare const DEFAULT_MAX_WATCHED_DIRECTORIES = 4000;
93
+ /** Default age past which a session-open refreshes an existing index (ten minutes). */
94
+ export declare const DEFAULT_AUTO_REFRESH_AFTER_MS: number;
82
95
  /** Plugin configuration: the indexer's identity and every deployment-varying bound on a run. */
83
96
  export interface Config {
84
97
  /**
@@ -173,7 +186,16 @@ export interface Config {
173
186
  * Requires the host's `jobs` service: the build is never run synchronously inside session creation,
174
187
  * so a composition without background jobs simply schedules nothing.
175
188
  */
189
+ /** Cap on candidate source files a session-start scan may find before it declines to index. */
190
+ autoIndexMaxFiles?: number;
176
191
  autoIndexOnSession?: boolean;
192
+ /**
193
+ * How stale an existing index may be before a new session refreshes it in the background (default
194
+ * 600000, ten minutes). An index older than this is rebuilt on session start using the parse cache,
195
+ * which is milliseconds — and rebuilding is also what restarts the file watcher, since watching
196
+ * only ever begins after a successful index.
197
+ */
198
+ autoRefreshAfterMs?: number;
177
199
  }
178
200
  export declare const Config: z<Config>;
179
201
  /**
package/lib/index.js CHANGED
@@ -27,13 +27,14 @@
27
27
  * this package ships with.
28
28
  * @module dsh-plugin-codegraph-tree-sitter
29
29
  */
30
- import { existsSync } from 'node:fs';
30
+ import { readFileSync } from 'node:fs';
31
+ import { readdir } from 'node:fs/promises';
31
32
  import { stat } from 'node:fs/promises';
32
- import { join } from 'node:path';
33
+ import { join, posix } from 'node:path';
33
34
  import z from '@deepseek-ai/schemastery';
34
35
  import { CodegraphIndexerId, INDEX_STATUS_RELATIVE_PATH } from 'dsh-plugin-codegraph-service';
35
- import { LANGUAGE_TABLE } from "./languages.js";
36
- import { walkAndExtract } from "./walk.js";
36
+ import { LANGUAGE_TABLE, languageFor } from "./languages.js";
37
+ import { isExcluded, walkAndExtract } from "./walk.js";
37
38
  import { writeParseCache } from "./cache.js";
38
39
  import { resolveWorkspace } from "./resolve.js";
39
40
  import { writeGraph } from "./schema.js";
@@ -77,12 +78,25 @@ export const DEFAULT_EXCLUDE = ['node_modules', 'dist', 'build', 'coverage', '.g
77
78
  export const DEFAULT_MAX_FILE_BYTES = 2_000_000;
78
79
  /** Default ceiling on how many files one run indexes. */
79
80
  export const DEFAULT_MAX_FILES = 50_000;
81
+ /**
82
+ * How many candidate source files a workspace may contain before a **session-start** index declines
83
+ * to run (default 8,000).
84
+ *
85
+ * `maxFiles` above is the bound for a run someone asked for; this one guards the run nobody asked
86
+ * for. A session opened with `cwd` pointing at a home directory or a tool's own data directory can
87
+ * cover tens of thousands of files — a real case reached 21,752 under `~/.dsh`, under the 50,000
88
+ * ceiling, and the Tree-sitter WASM heap died mid-parse while the desktop froze. Declining early
89
+ * costs one `readdir` pass; declining late costs the whole machine.
90
+ */
91
+ export const DEFAULT_AUTO_INDEX_MAX_FILES = 8_000;
80
92
  /** Default number of files parsed concurrently. */
81
93
  export const DEFAULT_CONCURRENCY = 4;
82
94
  /** Default quiet period after the last change before a watch-triggered refresh runs. */
83
95
  export const DEFAULT_WATCH_DEBOUNCE_MS = 2_000;
84
96
  /** Default cap on directories watched individually, one watch each, by the fs seam. */
85
97
  export const DEFAULT_MAX_WATCHED_DIRECTORIES = 4_000;
98
+ /** Default age past which a session-open refreshes an existing index (ten minutes). */
99
+ export const DEFAULT_AUTO_REFRESH_AFTER_MS = 10 * 60 * 1000;
86
100
  export const Config = z.object({
87
101
  indexerId: z.string().default(DEFAULT_INDEXER_ID),
88
102
  languages: z.array(z.string()).default([...new Set(LANGUAGE_TABLE.map(spec => spec.language))]),
@@ -98,7 +112,10 @@ export const Config = z.object({
98
112
  watch: z.boolean().default(true),
99
113
  watchDebounceMs: z.number().default(DEFAULT_WATCH_DEBOUNCE_MS),
100
114
  maxWatchedDirectories: z.number().default(DEFAULT_MAX_WATCHED_DIRECTORIES),
115
+ /** Cap on candidate source files a session-start scan may find before it declines to index. */
116
+ autoIndexMaxFiles: z.number().default(DEFAULT_AUTO_INDEX_MAX_FILES),
101
117
  autoIndexOnSession: z.boolean().default(true),
118
+ autoRefreshAfterMs: z.number().default(DEFAULT_AUTO_REFRESH_AFTER_MS),
102
119
  });
103
120
  /**
104
121
  * Register the tree-sitter indexer.
@@ -124,10 +141,11 @@ export function apply(ctx, config) {
124
141
  // cross-plugin relation here for an invariant to describe — just process-local bookkeeping this
125
142
  // effect's cleanup below tears down on unload.
126
143
  const watchers = new Map();
127
- // Roots this plugin instance has already started an automatic index for. The set is only a guard
128
- // against a burst of agents opened on one workspace each scheduling the same rebuild; what a
129
- // previous *process* indexed is recorded by the status sidecar `autoIndexOnSession` reads instead.
130
- const autoIndexed = new Set();
144
+ // When this instance last started an automatic index, keyed by root. This is not a "already done"
145
+ // set: an index goes stale while the process runs, so a later session in the same workspace may
146
+ // legitimately trigger another refresh. The map only keeps a burst of sessions opened at once from
147
+ // queueing the same rebuild repeatedly; the freshness decision itself reads the status sidecar.
148
+ const lastAutoRun = new Map();
131
149
  // `agent/created` is typed by `@deepseek-ai/dsh-agent`'s module augmentation. Reading the event
132
150
  // through a local structural face keeps this package's host dependencies unchanged: it needs one
133
151
  // event name and one payload shape, not the whole agent package.
@@ -192,7 +210,7 @@ export function apply(ctx, config) {
192
210
  if (resolved.autoIndexOnSession) {
193
211
  // Registered inside this effect so the listener follows the plugin's lifetime: unloading the
194
212
  // plugin must stop new sessions from scheduling work this instance can no longer run.
195
- yield agentEvents.on('agent/created', payload => autoIndexOnSession(ctx, autoIndexed, payload));
213
+ yield agentEvents.on('agent/created', payload => autoIndexOnSession(ctx, resolved, lastAutoRun, payload));
196
214
  }
197
215
  yield ctx.codegraph.registerIndexer(indexer);
198
216
  }, 'codegraph-tree-sitter');
@@ -200,30 +218,39 @@ export function apply(ctx, config) {
200
218
  /** Stable job kind of an automatic session-start index, used for retention and diagnostics. */
201
219
  const AUTO_INDEX_JOB_KIND = 'codegraph-index';
202
220
  /**
203
- * Index a new session's workspace in the background, when nothing has indexed it yet.
221
+ * Index a new session's workspace in the background — when nothing has indexed it yet, or when what
222
+ * is there has gone stale.
204
223
  *
205
224
  * Runs synchronously inside `agent/created` dispatch, so it must never block or throw: it only decides
206
225
  * whether a build is warranted and hands one to the host's job registry. A synchronous build here
207
226
  * would stall agent creation for the length of a parse, which is why a composition without background
208
227
  * jobs does nothing instead.
228
+ *
229
+ * Refreshing a stale index is what restarts the file watcher after a host restart: watching only
230
+ * begins once a successful index has established a baseline, so a workspace whose index is minutes
231
+ * old would otherwise stay frozen at whatever the last process left behind.
209
232
  * @param ctx - the plugin context, consulted for the optional job registry.
210
- * @param autoIndexed - roots this process has already started an automatic index for.
233
+ * @param config - the resolved plugin configuration, for the staleness threshold.
234
+ * @param lastAutoRun - when this instance last started an automatic index for each root.
211
235
  * @param payload - the `agent/created` dispatch payload.
212
236
  */
213
- function autoIndexOnSession(ctx, autoIndexed, payload) {
237
+ function autoIndexOnSession(ctx, config, lastAutoRun, payload) {
214
238
  try {
215
239
  const agent = payload.agent;
216
240
  const root = agent?.session?.header?.cwd;
217
241
  // No workspace means no root to claim: an agent created without a `cwd` has nothing to index.
218
242
  if (agent === undefined || !root)
219
243
  return;
220
- // Once per root per process. A second agent opened on the same workspace must not queue a second
221
- // full rebuild of a graph the first one is already building.
222
- if (autoIndexed.has(root))
244
+ const now = Date.now();
245
+ // A burst of sessions opened on one workspace must not queue the same rebuild repeatedly.
246
+ const previous = lastAutoRun.get(root);
247
+ if (previous !== undefined && now - previous < config.autoRefreshAfterMs)
223
248
  return;
224
- // An existing status sidecar means a previous run already indexed this workspace. Checked before
225
- // the job lookup so a composition without background jobs never pays for a filesystem probe.
226
- if (existsSync(join(root, INDEX_STATUS_RELATIVE_PATH)))
249
+ // A fresh sidecar means a recent run is already describing this workspace; an absent, unreadable
250
+ // or old one means the graph is missing or behind. Checked before the job lookup so a composition
251
+ // without background jobs never pays for the probe.
252
+ const indexedAt = readStatusIndexedAt(root);
253
+ if (indexedAt !== undefined && now - indexedAt < config.autoRefreshAfterMs)
227
254
  return;
228
255
  // `ctx.get` rather than an `inject` entry: `jobs` is a host composition choice, and requiring it in
229
256
  // `inject` would hide this whole indexer from a profile that runs no background jobs.
@@ -235,11 +262,11 @@ function autoIndexOnSession(ctx, autoIndexed, payload) {
235
262
  owner: agent.id,
236
263
  kind: AUTO_INDEX_JOB_KIND,
237
264
  label: `codegraph auto index ${root}`,
238
- run: handle => runAutoIndexJob(ctx, root, handle),
265
+ run: handle => runAutoIndexJob(ctx, root, config, handle),
239
266
  });
240
267
  // Marked only after the registry accepted the job, so a transient refusal leaves the workspace
241
268
  // eligible for the next session that opens in it.
242
- autoIndexed.add(root);
269
+ lastAutoRun.set(root, now);
243
270
  }
244
271
  catch (error) {
245
272
  // The host treats a throwing listener as vetoing publication. Losing an index is a degradation,
@@ -247,6 +274,71 @@ function autoIndexOnSession(ctx, autoIndexed, payload) {
247
274
  console.error(`codegraph-tree-sitter: automatic indexing for a new session failed: ${String(error)}`);
248
275
  }
249
276
  }
277
+ /**
278
+ * How many candidate source files `root` holds, when that is past the session-start limit.
279
+ *
280
+ * Deliberately a **bounded directory walk that never reads a file**: it counts what `discover` would
281
+ * hand the parser and stops one past the limit. The alternative — starting the real run and bailing
282
+ * when memory runs out — is what froze the machine this guard exists to prevent.
283
+ * @param root - absolute workspace path.
284
+ * @param config - the resolved plugin configuration (for the limit and the exclude list).
285
+ * @param signal - aborts the walk.
286
+ * @returns the count once it exceeds the limit, or `undefined` when the workspace is small enough.
287
+ */
288
+ async function exceedsAutoIndexLimit(root, config, signal) {
289
+ const excluded = new Set(config.exclude);
290
+ const limit = config.autoIndexMaxFiles;
291
+ let count = 0;
292
+ async function scan(relativeDir) {
293
+ signal.throwIfAborted();
294
+ let entries;
295
+ try {
296
+ entries = await readdir(posix.join(root, relativeDir), { withFileTypes: true });
297
+ }
298
+ catch {
299
+ // An unreadable directory contributes nothing to a count that exists to protect against size.
300
+ return false;
301
+ }
302
+ for (const entry of entries) {
303
+ const relativePath = relativeDir === '' ? entry.name : `${relativeDir}/${entry.name}`;
304
+ if (isExcluded(entry.name, relativePath, entry.isDirectory(), excluded, []))
305
+ continue;
306
+ if (entry.isDirectory()) {
307
+ if (await scan(relativePath))
308
+ return true;
309
+ continue;
310
+ }
311
+ if (!entry.isFile())
312
+ continue;
313
+ const spec = languageFor(posix.extname(entry.name));
314
+ if (spec === undefined)
315
+ continue;
316
+ count += 1;
317
+ if (count > limit)
318
+ return true;
319
+ }
320
+ return false;
321
+ }
322
+ return (await scan('')) ? count : undefined;
323
+ }
324
+ /**
325
+ * When the workspace's sidecar says its index was built.
326
+ * @param root - the workspace root.
327
+ * @returns the epoch-millisecond timestamp, or `undefined` when the sidecar is absent or unreadable —
328
+ * either way the graph cannot be trusted to be current.
329
+ */
330
+ function readStatusIndexedAt(root) {
331
+ try {
332
+ const parsed = JSON.parse(readFileSync(join(root, INDEX_STATUS_RELATIVE_PATH), 'utf8'));
333
+ if (typeof parsed !== 'object' || parsed === null)
334
+ return undefined;
335
+ const indexedAt = parsed.indexedAt;
336
+ return typeof indexedAt === 'number' && Number.isFinite(indexedAt) ? indexedAt : undefined;
337
+ }
338
+ catch {
339
+ return undefined;
340
+ }
341
+ }
250
342
  /**
251
343
  * The producer body of one automatic indexing job.
252
344
  *
@@ -258,10 +350,19 @@ function autoIndexOnSession(ctx, autoIndexed, payload) {
258
350
  * @param handle - the job's producer face, for the job's live log.
259
351
  * @returns the job's cancellation hook and settlement promise.
260
352
  */
261
- function runAutoIndexJob(ctx, root, handle) {
353
+ function runAutoIndexJob(ctx, root, config, handle) {
262
354
  const controller = new AbortController();
263
355
  const done = (async () => {
264
356
  try {
357
+ const oversized = await exceedsAutoIndexLimit(root, config, controller.signal);
358
+ if (oversized !== undefined) {
359
+ // Declining here is the whole point of the check: parsing this workspace would exhaust the
360
+ // WASM heap and take the desktop down with it, and an automatic index is a convenience.
361
+ handle.append(`Skipped automatic indexing of ${root}: it holds at least ${String(oversized)} candidate ` +
362
+ `source files, past the ${String(config.autoIndexMaxFiles)}-file session-start limit. ` +
363
+ `Run codegraph_index yourself if you want a graph for it.\n`);
364
+ return { status: 'failed', detail: 'workspace too large for automatic indexing' };
365
+ }
265
366
  const report = await ctx.codegraph.index(root, controller.signal);
266
367
  handle.append(`Indexed ${root}: ${report.filesIndexed} files, ${report.nodeCount} symbols, ` +
267
368
  `${report.edgeCount} relationships.\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-codegraph-tree-sitter",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Self-built code-graph indexer for DeepSeek Harness — parses a workspace with web-tree-sitter and writes schema version 4 to .codegraph/codegraph.db, the same on-disk format the codegraph CLI writes, so no external indexer is required",
5
5
  "keywords": [
6
6
  "dsh",
@@ -51,7 +51,7 @@
51
51
  "@deepseek-ai/schemastery": "^3.18.1",
52
52
  "tree-sitter-wasms": "^0.1.13",
53
53
  "web-tree-sitter": "^0.25.10",
54
- "dsh-plugin-codegraph-service": "^0.5.0"
54
+ "dsh-plugin-codegraph-service": "^0.5.2"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@deepseek-ai/cordis": ">=4.0.1",