dsh-plugin-codegraph-tree-sitter 0.6.0 → 0.6.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 +20 -2
- package/lib/index.js +210 -76
- package/lib/resolve.js +48 -5
- package/lib/schema.d.ts +14 -0
- package/lib/schema.js +19 -4
- package/package.json +7 -7
package/lib/index.d.ts
CHANGED
|
@@ -92,6 +92,16 @@ export declare const DEFAULT_WATCH_DEBOUNCE_MS = 2000;
|
|
|
92
92
|
export declare const DEFAULT_MAX_WATCHED_DIRECTORIES = 4000;
|
|
93
93
|
/** Default age past which a session-open refreshes an existing index (ten minutes). */
|
|
94
94
|
export declare const DEFAULT_AUTO_REFRESH_AFTER_MS: number;
|
|
95
|
+
/**
|
|
96
|
+
* Default ceiling on one session-start automatic index (fifteen minutes).
|
|
97
|
+
*
|
|
98
|
+
* An automatic run nobody asked for holds its workspace in `autoIndexInFlight`, which is what keeps a
|
|
99
|
+
* burst of sessions from racing two builds over the same files. A run that never settles — a wedged
|
|
100
|
+
* parse, a backend whose abort is ignored — would therefore silence every later session in that
|
|
101
|
+
* workspace for the life of the process. The deadline is the way out: the run is aborted, the root is
|
|
102
|
+
* released, and the reason is recorded next to the graph for whoever asks next.
|
|
103
|
+
*/
|
|
104
|
+
export declare const DEFAULT_AUTO_INDEX_TIMEOUT_MS: number;
|
|
95
105
|
/** Plugin configuration: the indexer's identity and every deployment-varying bound on a run. */
|
|
96
106
|
export interface Config {
|
|
97
107
|
/**
|
|
@@ -183,8 +193,10 @@ export interface Config {
|
|
|
183
193
|
* when its status sidecar already exists, so this cannot rebuild a graph that is already there.
|
|
184
194
|
* Set false to keep every build explicitly requested.
|
|
185
195
|
*
|
|
186
|
-
*
|
|
187
|
-
* so a composition without background jobs
|
|
196
|
+
* Does not need the host's `jobs` service: the build never runs synchronously inside session
|
|
197
|
+
* creation, it runs as a plain background promise, so a composition without background jobs still
|
|
198
|
+
* indexes. A failure is reported through `console.error` and recorded in
|
|
199
|
+
* `.codegraph/last-error.json` for the `status` operation to explain.
|
|
188
200
|
*/
|
|
189
201
|
/** Cap on candidate source files a session-start scan may find before it declines to index. */
|
|
190
202
|
autoIndexMaxFiles?: number;
|
|
@@ -196,6 +208,12 @@ export interface Config {
|
|
|
196
208
|
* only ever begins after a successful index.
|
|
197
209
|
*/
|
|
198
210
|
autoRefreshAfterMs?: number;
|
|
211
|
+
/**
|
|
212
|
+
* How long one session-start automatic index may run before it is aborted and its workspace
|
|
213
|
+
* released (default 900000, fifteen minutes). See {@link DEFAULT_AUTO_INDEX_TIMEOUT_MS} for why an
|
|
214
|
+
* unanswered run needs a deadline at all.
|
|
215
|
+
*/
|
|
216
|
+
autoIndexTimeoutMs?: number;
|
|
199
217
|
}
|
|
200
218
|
export declare const Config: z<Config>;
|
|
201
219
|
/**
|
package/lib/index.js
CHANGED
|
@@ -28,16 +28,15 @@
|
|
|
28
28
|
* @module dsh-plugin-codegraph-tree-sitter
|
|
29
29
|
*/
|
|
30
30
|
import { existsSync, readFileSync } from 'node:fs';
|
|
31
|
-
import { readdir } from 'node:fs/promises';
|
|
32
|
-
import {
|
|
33
|
-
import { join, posix } from 'node:path';
|
|
31
|
+
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
|
|
32
|
+
import { dirname, join, posix } from 'node:path';
|
|
34
33
|
import z from '@deepseek-ai/schemastery';
|
|
35
|
-
import { CodegraphIndexerId, INDEX_STATUS_RELATIVE_PATH } from 'dsh-plugin-codegraph-service';
|
|
34
|
+
import { CodegraphIndexerId, INDEX_FAILURE_RELATIVE_PATH, INDEX_STATUS_RELATIVE_PATH } from 'dsh-plugin-codegraph-service';
|
|
36
35
|
import { LANGUAGE_TABLE, languageFor } from "./languages.js";
|
|
37
36
|
import { isExcluded, walkAndExtract } from "./walk.js";
|
|
38
37
|
import { writeParseCache } from "./cache.js";
|
|
39
38
|
import { resolveWorkspace } from "./resolve.js";
|
|
40
|
-
import { writeGraph } from "./schema.js";
|
|
39
|
+
import { writeGraph, writeJsonAtomically } from "./schema.js";
|
|
41
40
|
import { createWatcher } from "./watcher.js";
|
|
42
41
|
import { decideWatch, readProcVersion } from "./watch-policy.js";
|
|
43
42
|
export { LANGUAGE_TABLE, languageFor } from "./languages.js";
|
|
@@ -97,6 +96,16 @@ export const DEFAULT_WATCH_DEBOUNCE_MS = 2_000;
|
|
|
97
96
|
export const DEFAULT_MAX_WATCHED_DIRECTORIES = 4_000;
|
|
98
97
|
/** Default age past which a session-open refreshes an existing index (ten minutes). */
|
|
99
98
|
export const DEFAULT_AUTO_REFRESH_AFTER_MS = 10 * 60 * 1000;
|
|
99
|
+
/**
|
|
100
|
+
* Default ceiling on one session-start automatic index (fifteen minutes).
|
|
101
|
+
*
|
|
102
|
+
* An automatic run nobody asked for holds its workspace in `autoIndexInFlight`, which is what keeps a
|
|
103
|
+
* burst of sessions from racing two builds over the same files. A run that never settles — a wedged
|
|
104
|
+
* parse, a backend whose abort is ignored — would therefore silence every later session in that
|
|
105
|
+
* workspace for the life of the process. The deadline is the way out: the run is aborted, the root is
|
|
106
|
+
* released, and the reason is recorded next to the graph for whoever asks next.
|
|
107
|
+
*/
|
|
108
|
+
export const DEFAULT_AUTO_INDEX_TIMEOUT_MS = 15 * 60 * 1000;
|
|
100
109
|
export const Config = z.object({
|
|
101
110
|
indexerId: z.string().default(DEFAULT_INDEXER_ID),
|
|
102
111
|
languages: z.array(z.string()).default([...new Set(LANGUAGE_TABLE.map(spec => spec.language))]),
|
|
@@ -116,6 +125,7 @@ export const Config = z.object({
|
|
|
116
125
|
autoIndexMaxFiles: z.number().default(DEFAULT_AUTO_INDEX_MAX_FILES),
|
|
117
126
|
autoIndexOnSession: z.boolean().default(true),
|
|
118
127
|
autoRefreshAfterMs: z.number().default(DEFAULT_AUTO_REFRESH_AFTER_MS),
|
|
128
|
+
autoIndexTimeoutMs: z.number().default(DEFAULT_AUTO_INDEX_TIMEOUT_MS),
|
|
119
129
|
});
|
|
120
130
|
/**
|
|
121
131
|
* Register the tree-sitter indexer.
|
|
@@ -129,6 +139,7 @@ export function apply(ctx, config) {
|
|
|
129
139
|
assertPositiveInteger('concurrency', resolved.concurrency);
|
|
130
140
|
assertPositiveInteger('watchDebounceMs', resolved.watchDebounceMs);
|
|
131
141
|
assertPositiveInteger('maxWatchedDirectories', resolved.maxWatchedDirectories);
|
|
142
|
+
assertPositiveInteger('autoIndexTimeoutMs', resolved.autoIndexTimeoutMs);
|
|
132
143
|
// Watching and the parse cache both go through the host's filesystem seam rather than `node:fs`, so
|
|
133
144
|
// they follow the same execution world the rest of the harness reads: a remote or confined backend
|
|
134
145
|
// watches and caches on its own side, and the cache's read/write pass through the same policy the
|
|
@@ -146,6 +157,14 @@ export function apply(ctx, config) {
|
|
|
146
157
|
// legitimately trigger another refresh. The map only keeps a burst of sessions opened at once from
|
|
147
158
|
// queueing the same rebuild repeatedly; the freshness decision itself reads the status sidecar.
|
|
148
159
|
const lastAutoRun = new Map();
|
|
160
|
+
// Roots whose background run is still executing. Kept apart from `lastAutoRun`, which is a time
|
|
161
|
+
// window: a first build can outlive the window, and two builds on one root would only race the same
|
|
162
|
+
// index files.
|
|
163
|
+
const autoIndexInFlight = new Set();
|
|
164
|
+
// Controllers of the automatic runs still executing. Kept so the plugin's own unload can abort them
|
|
165
|
+
// rather than leaving a parse writing into a graph nobody is listening for any more; each run removes
|
|
166
|
+
// its own controller in `finally`.
|
|
167
|
+
const autoIndexControllers = new Set();
|
|
149
168
|
// `agent/created` is typed by `@deepseek-ai/dsh-agent`'s module augmentation. Reading the event
|
|
150
169
|
// through a local structural face keeps this package's host dependencies unchanged: it needs one
|
|
151
170
|
// event name and one payload shape, not the whole agent package.
|
|
@@ -194,15 +213,34 @@ export function apply(ctx, config) {
|
|
|
194
213
|
}
|
|
195
214
|
},
|
|
196
215
|
async index(projectRoot, signal) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
216
|
+
try {
|
|
217
|
+
const report = await runIndex(projectRoot, resolved, fileSystem, signal);
|
|
218
|
+
// Watching starts only after a successful index establishes the baseline it refreshes — never
|
|
219
|
+
// from a canIndex() probe or a failed run.
|
|
220
|
+
ensureWatching(projectRoot);
|
|
221
|
+
return report;
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
// One entry point for every explicit build: `codegraph_index` and the `/codegraph index`
|
|
225
|
+
// command both reach the seam here. An automatic run passes through too, but records its own
|
|
226
|
+
// 'auto' label afterwards, so the final record names the path that actually asked.
|
|
227
|
+
// An abort is not a failure of the workspace: the tool's cancel and the plugin's unload both
|
|
228
|
+
// abort this signal, and a record saying "This operation was aborted" would outlive the
|
|
229
|
+
// process that wrote it and be read back as a diagnosis.
|
|
230
|
+
if (signal?.aborted !== true)
|
|
231
|
+
await writeIndexFailure(projectRoot, 'tool', error);
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
202
234
|
},
|
|
203
235
|
};
|
|
204
236
|
ctx.effect(function* () {
|
|
205
237
|
yield () => {
|
|
238
|
+
// Aborting goes first: a run still writing when this plugin unloads would otherwise keep the
|
|
239
|
+
// parse alive past the index it serves, and leave a graph half-written by an instance nobody
|
|
240
|
+
// asked for any more. Each run's own `finally` drops it from the set.
|
|
241
|
+
for (const controller of autoIndexControllers)
|
|
242
|
+
controller.abort();
|
|
243
|
+
autoIndexControllers.clear();
|
|
206
244
|
for (const watcher of watchers.values())
|
|
207
245
|
watcher.stop();
|
|
208
246
|
watchers.clear();
|
|
@@ -210,13 +248,11 @@ export function apply(ctx, config) {
|
|
|
210
248
|
if (resolved.autoIndexOnSession) {
|
|
211
249
|
// Registered inside this effect so the listener follows the plugin's lifetime: unloading the
|
|
212
250
|
// plugin must stop new sessions from scheduling work this instance can no longer run.
|
|
213
|
-
yield agentEvents.on('agent/created', payload => autoIndexOnSession(ctx, resolved, lastAutoRun, payload, ensureWatching));
|
|
251
|
+
yield agentEvents.on('agent/created', payload => autoIndexOnSession(ctx, resolved, lastAutoRun, autoIndexInFlight, autoIndexControllers, payload, ensureWatching));
|
|
214
252
|
}
|
|
215
253
|
yield ctx.codegraph.registerIndexer(indexer);
|
|
216
254
|
}, 'codegraph-tree-sitter');
|
|
217
255
|
}
|
|
218
|
-
/** Stable job kind of an automatic session-start index, used for retention and diagnostics. */
|
|
219
|
-
const AUTO_INDEX_JOB_KIND = 'codegraph-index';
|
|
220
256
|
/**
|
|
221
257
|
* Whether a directory is the harness's own home rather than a project checkout.
|
|
222
258
|
*
|
|
@@ -235,19 +271,30 @@ function isHarnessHome(root) {
|
|
|
235
271
|
* is there has gone stale.
|
|
236
272
|
*
|
|
237
273
|
* Runs synchronously inside `agent/created` dispatch, so it must never block or throw: it only decides
|
|
238
|
-
* whether a build is warranted and
|
|
239
|
-
*
|
|
240
|
-
*
|
|
274
|
+
* whether a build is warranted and starts one in the background. A synchronous build here would stall
|
|
275
|
+
* agent creation for the length of a parse.
|
|
276
|
+
*
|
|
277
|
+
* The run deliberately does **not** go through the host's job registry. A registered job reports its
|
|
278
|
+
* settlement back to the owning agent: the host injects a completion notice into the next step and,
|
|
279
|
+
* under its default `wakeup` delivery, opens a turn on an idle one. An automatic index nobody asked
|
|
280
|
+
* for must not appear in the transcript or start a reply of its own, so this runs as a plain
|
|
281
|
+
* background promise. The explicit `codegraph_index` tool still registers a job — there a caller is
|
|
282
|
+
* waiting for the id.
|
|
241
283
|
*
|
|
242
284
|
* Refreshing a stale index is what restarts the file watcher after a host restart: watching only
|
|
243
285
|
* begins once a successful index has established a baseline, so a workspace whose index is minutes
|
|
244
286
|
* old would otherwise stay frozen at whatever the last process left behind.
|
|
245
|
-
* @param ctx - the plugin context
|
|
287
|
+
* @param ctx - the plugin context.
|
|
246
288
|
* @param config - the resolved plugin configuration, for the staleness threshold.
|
|
247
289
|
* @param lastAutoRun - when this instance last started an automatic index for each root.
|
|
290
|
+
* @param autoIndexInFlight - roots whose background run is still executing, so a slow build cannot be
|
|
291
|
+
* started twice by sessions that open past the freshness window.
|
|
292
|
+
* @param autoIndexControllers - controllers of the automatic runs this instance has in flight, so the
|
|
293
|
+
* plugin's unload can abort them.
|
|
248
294
|
* @param payload - the `agent/created` dispatch payload.
|
|
295
|
+
* @param ensureWatching - starts file watching for a workspace that is already current.
|
|
249
296
|
*/
|
|
250
|
-
function autoIndexOnSession(ctx, config, lastAutoRun, payload, ensureWatching) {
|
|
297
|
+
function autoIndexOnSession(ctx, config, lastAutoRun, autoIndexInFlight, autoIndexControllers, payload, ensureWatching) {
|
|
251
298
|
try {
|
|
252
299
|
const agent = payload.agent;
|
|
253
300
|
const root = agent?.session?.header?.cwd;
|
|
@@ -266,9 +313,12 @@ function autoIndexOnSession(ctx, config, lastAutoRun, payload, ensureWatching) {
|
|
|
266
313
|
const previous = lastAutoRun.get(root);
|
|
267
314
|
if (previous !== undefined && now - previous < config.autoRefreshAfterMs)
|
|
268
315
|
return;
|
|
316
|
+
// A build already running for this root is the one the window would have deduplicated had it been
|
|
317
|
+
// slow enough to straddle it; starting a second would only race the same index files.
|
|
318
|
+
if (autoIndexInFlight.has(root))
|
|
319
|
+
return;
|
|
269
320
|
// A fresh sidecar means a recent run is already describing this workspace; an absent, unreadable
|
|
270
|
-
// or old one means the graph is missing or behind.
|
|
271
|
-
// without background jobs never pays for the probe.
|
|
321
|
+
// or old one means the graph is missing or behind.
|
|
272
322
|
const indexedAt = readStatusIndexedAt(root);
|
|
273
323
|
if (indexedAt !== undefined && now - indexedAt < config.autoRefreshAfterMs) {
|
|
274
324
|
// The graph is current, so no build is warranted — but watching is process-local state and
|
|
@@ -278,28 +328,98 @@ function autoIndexOnSession(ctx, config, lastAutoRun, payload, ensureWatching) {
|
|
|
278
328
|
ensureWatching(root);
|
|
279
329
|
return;
|
|
280
330
|
}
|
|
281
|
-
// `ctx.get` rather than an `inject` entry: `jobs` is a host composition choice, and requiring it in
|
|
282
|
-
// `inject` would hide this whole indexer from a profile that runs no background jobs.
|
|
283
|
-
const jobs = ctx.get('jobs');
|
|
284
|
-
if (jobs === undefined)
|
|
285
|
-
return;
|
|
286
|
-
jobs.start({
|
|
287
|
-
// The registry keys a job by its owning session id, not the agent object.
|
|
288
|
-
owner: agent.id,
|
|
289
|
-
kind: AUTO_INDEX_JOB_KIND,
|
|
290
|
-
label: `codegraph auto index ${root}`,
|
|
291
|
-
run: handle => runAutoIndexJob(ctx, root, config, handle),
|
|
292
|
-
});
|
|
293
|
-
// Marked only after the registry accepted the job, so a transient refusal leaves the workspace
|
|
294
|
-
// eligible for the next session that opens in it.
|
|
295
331
|
lastAutoRun.set(root, now);
|
|
332
|
+
autoIndexInFlight.add(root);
|
|
333
|
+
// Never rejects: a lost index is a degradation, not an error the session can act on. The in-flight
|
|
334
|
+
// entry is released by the run itself rather than here, because only that run knows whether a
|
|
335
|
+
// deadline has already replaced it — see `runAutomaticIndex`.
|
|
336
|
+
void runAutomaticIndex(ctx, root, config, autoIndexInFlight, autoIndexControllers);
|
|
296
337
|
}
|
|
297
338
|
catch (error) {
|
|
298
339
|
// The host treats a throwing listener as vetoing publication. Losing an index is a degradation,
|
|
299
340
|
// never a reason to stop a session from starting.
|
|
341
|
+
// Nothing in the block above throws for a payload-shaped agent: the only ways here are the host's
|
|
342
|
+
// own event bus or a path helper, which a unit test cannot arrange without mocking `node:fs`.
|
|
343
|
+
/* v8 ignore next */
|
|
300
344
|
console.error(`codegraph-tree-sitter: automatic indexing for a new session failed: ${String(error)}`);
|
|
301
345
|
}
|
|
302
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Run one automatic index to completion, reporting a failure instead of propagating it.
|
|
349
|
+
*
|
|
350
|
+
* The size guard stays: parsing a workspace past `autoIndexMaxFiles` would exhaust the WASM heap and
|
|
351
|
+
* take the desktop down with it, and an automatic index is a convenience. Skipping it is a decision,
|
|
352
|
+
* not a failure, so it is logged once and the session continues untouched.
|
|
353
|
+
*
|
|
354
|
+
* A run that fails now also leaves {@link INDEX_FAILURE_RELATIVE_PATH} beside the graph, and a run that
|
|
355
|
+
* exceeds {@link Config.autoIndexTimeoutMs} is aborted and recorded as such. Both exist for the same
|
|
356
|
+
* reason: the user's only view of an automatic run is `codegraph status`, and "no index" without a
|
|
357
|
+
* reason sent them looking for a bug in their workspace rather than in the run.
|
|
358
|
+
* @param ctx - the plugin context.
|
|
359
|
+
* @param root - absolute workspace path.
|
|
360
|
+
* @param config - the resolved plugin configuration.
|
|
361
|
+
* @param autoIndexInFlight - roots whose background run is still executing; a timed-out run releases
|
|
362
|
+
* its own entry because a run that ignores its signal may never settle.
|
|
363
|
+
* @param autoIndexControllers - controllers of the automatic runs in flight, for the plugin's unload
|
|
364
|
+
* to abort.
|
|
365
|
+
*/
|
|
366
|
+
async function runAutomaticIndex(ctx, root, config, autoIndexInFlight, autoIndexControllers) {
|
|
367
|
+
const controller = new AbortController();
|
|
368
|
+
autoIndexControllers.add(controller);
|
|
369
|
+
const timeoutMs = config.autoIndexTimeoutMs;
|
|
370
|
+
let timedOut = false;
|
|
371
|
+
const timer = setTimeout(() => {
|
|
372
|
+
timedOut = true;
|
|
373
|
+
// The record is written here rather than left to the `catch` below: a run that ignores its signal
|
|
374
|
+
// may never settle, and then the failure this deadline is describing would never be recorded.
|
|
375
|
+
void writeIndexFailure(root, 'auto', `automatic index timed out after ${String(timeoutMs)}ms`);
|
|
376
|
+
// Released here for the same reason: the promise's own `finally` is exactly what a run that never
|
|
377
|
+
// settles never reaches, and every later session in this workspace would skip indexing forever.
|
|
378
|
+
autoIndexInFlight.delete(root);
|
|
379
|
+
// The controller is dropped here too: `finally` is what would normally release it, and this is the
|
|
380
|
+
// path a run that ignores its signal never reaches. Leaving it in the set would make the plugin's
|
|
381
|
+
// own unload abort a run that already gave up — and keep it alive, with the workspace it holds, for
|
|
382
|
+
// the life of the process.
|
|
383
|
+
autoIndexControllers.delete(controller);
|
|
384
|
+
controller.abort();
|
|
385
|
+
}, timeoutMs);
|
|
386
|
+
// A pending deadline must not be the reason a short-lived host process stays up.
|
|
387
|
+
timer.unref();
|
|
388
|
+
try {
|
|
389
|
+
const oversized = await exceedsAutoIndexLimit(root, config, controller.signal);
|
|
390
|
+
if (oversized !== undefined) {
|
|
391
|
+
console.error(`codegraph-tree-sitter: skipped automatic indexing of ${root}: it holds at least ${String(oversized)} ` +
|
|
392
|
+
`candidate source files, past the ${String(config.autoIndexMaxFiles)}-file session-start limit. ` +
|
|
393
|
+
'Run codegraph_index yourself if you want a graph for it.');
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// A successful index is what starts file watching, inside `indexer.index`.
|
|
397
|
+
await ctx.codegraph.index(root, controller.signal);
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
// An abort that did not come from the deadline is the plugin unloading: nobody is waiting for this
|
|
401
|
+
// graph any more, so it is not a workspace failure and gets neither a log line nor a record.
|
|
402
|
+
if (controller.signal.aborted && !timedOut)
|
|
403
|
+
return;
|
|
404
|
+
// One message either way, but "timed out" and "the indexer threw" are different diagnoses: the
|
|
405
|
+
// first says the workspace is too large or too slow, the second names what actually broke.
|
|
406
|
+
const message = timedOut
|
|
407
|
+
? `automatic index timed out after ${String(timeoutMs)}ms`
|
|
408
|
+
: describeFailure(error);
|
|
409
|
+
console.error(`codegraph-tree-sitter: automatic index for ${root} failed: ${message}`);
|
|
410
|
+
await writeIndexFailure(root, 'auto', message);
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
autoIndexControllers.delete(controller);
|
|
415
|
+
// Only while this run still owns the entry. A run that passed its deadline released the root in the
|
|
416
|
+
// timer callback — that release is what lets a later session retry at all, because a run that
|
|
417
|
+
// ignores its signal may never reach this line — and deleting here by root alone would then take
|
|
418
|
+
// away whichever newer run has claimed the same workspace since.
|
|
419
|
+
if (!timedOut)
|
|
420
|
+
autoIndexInFlight.delete(root);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
303
423
|
/**
|
|
304
424
|
* How many candidate source files `root` holds, when that is past the session-start limit.
|
|
305
425
|
*
|
|
@@ -365,46 +485,6 @@ function readStatusIndexedAt(root) {
|
|
|
365
485
|
return undefined;
|
|
366
486
|
}
|
|
367
487
|
}
|
|
368
|
-
/**
|
|
369
|
-
* The producer body of one automatic indexing job.
|
|
370
|
-
*
|
|
371
|
-
* Both halves of the host's job contract are mandatory: `cancel` aborts the run, and `done` settles
|
|
372
|
-
* even on failure — a rejected `done` breaks the registry's bookkeeping and leaves the job looking
|
|
373
|
-
* stuck forever.
|
|
374
|
-
* @param ctx - the plugin context.
|
|
375
|
-
* @param root - the workspace to index.
|
|
376
|
-
* @param handle - the job's producer face, for the job's live log.
|
|
377
|
-
* @returns the job's cancellation hook and settlement promise.
|
|
378
|
-
*/
|
|
379
|
-
function runAutoIndexJob(ctx, root, config, handle) {
|
|
380
|
-
const controller = new AbortController();
|
|
381
|
-
const done = (async () => {
|
|
382
|
-
try {
|
|
383
|
-
const oversized = await exceedsAutoIndexLimit(root, config, controller.signal);
|
|
384
|
-
if (oversized !== undefined) {
|
|
385
|
-
// Declining here is the whole point of the check: parsing this workspace would exhaust the
|
|
386
|
-
// WASM heap and take the desktop down with it, and an automatic index is a convenience.
|
|
387
|
-
handle.append(`Skipped automatic indexing of ${root}: it holds at least ${String(oversized)} candidate ` +
|
|
388
|
-
`source files, past the ${String(config.autoIndexMaxFiles)}-file session-start limit. ` +
|
|
389
|
-
`Run codegraph_index yourself if you want a graph for it.\n`);
|
|
390
|
-
// `completed`, not `failed`: nothing went wrong. The guard decided this workspace is not a
|
|
391
|
-
// candidate for an automatic run, and a user reading the job list must be able to tell that
|
|
392
|
-
// apart from a build that actually broke.
|
|
393
|
-
return { status: 'completed', detail: 'skipped: workspace too large for automatic indexing' };
|
|
394
|
-
}
|
|
395
|
-
const report = await ctx.codegraph.index(root, controller.signal);
|
|
396
|
-
handle.append(`Indexed ${root}: ${report.filesIndexed} files, ${report.nodeCount} symbols, ` +
|
|
397
|
-
`${report.edgeCount} relationships.\n`);
|
|
398
|
-
return { status: 'completed' };
|
|
399
|
-
}
|
|
400
|
-
catch (error) {
|
|
401
|
-
const message = String(error);
|
|
402
|
-
handle.append(`codegraph auto index failed for ${root}: ${message}\n`);
|
|
403
|
-
return { status: 'failed', detail: message };
|
|
404
|
-
}
|
|
405
|
-
})();
|
|
406
|
-
return { cancel: () => controller.abort(), done };
|
|
407
|
-
}
|
|
408
488
|
/**
|
|
409
489
|
* Run one watch-triggered reindex. Always a full rebuild in this version — `runIndex` has no notion of
|
|
410
490
|
* a file subset — so the watcher's own collected paths are not consulted here.
|
|
@@ -415,8 +495,16 @@ function runAutoIndexJob(ctx, root, config, handle) {
|
|
|
415
495
|
*/
|
|
416
496
|
async function runSync(projectRoot, config, fs) {
|
|
417
497
|
const startedAt = Date.now();
|
|
418
|
-
|
|
419
|
-
|
|
498
|
+
try {
|
|
499
|
+
const report = await runIndex(projectRoot, config, fs);
|
|
500
|
+
return { filesChanged: report.filesIndexed, durationMs: Date.now() - startedAt };
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
// A watch rebuild has no caller to fail: the debounce already returned, so the only trace of a
|
|
504
|
+
// failed refresh used to be one console line in a process nobody reads interactively.
|
|
505
|
+
await writeIndexFailure(projectRoot, 'watch', error);
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
420
508
|
}
|
|
421
509
|
/**
|
|
422
510
|
* One indexing run: walk, parse, resolve, and write.
|
|
@@ -447,6 +535,10 @@ async function runIndex(projectRoot, config, fs, signal) {
|
|
|
447
535
|
await writeGraph(databasePath, {
|
|
448
536
|
files, nodes: graph.nodes, edges: graph.edges, unresolved: graph.unresolved, filesMinified, indexedAt,
|
|
449
537
|
});
|
|
538
|
+
// The graph is back, so the recorded reason for its absence is no longer true. Cleared here rather
|
|
539
|
+
// than in `index()` because the watch path reaches this function without going through it; the
|
|
540
|
+
// deletion tolerates absence, which is the common case on a healthy workspace.
|
|
541
|
+
await rm(join(projectRoot, INDEX_FAILURE_RELATIVE_PATH), { force: true });
|
|
450
542
|
// Only after the graph is in place: the cache records the extractions this run actually committed,
|
|
451
543
|
// so a run that failed before writing its graph leaves the previous cache — and the mtimes it was
|
|
452
544
|
// built from — alone instead of vouching for a graph that was never written.
|
|
@@ -470,6 +562,48 @@ async function runIndex(projectRoot, config, fs, signal) {
|
|
|
470
562
|
languages,
|
|
471
563
|
};
|
|
472
564
|
}
|
|
565
|
+
/**
|
|
566
|
+
* Record why a run failed beside the graph, so a later `codegraph status` can explain an absent index.
|
|
567
|
+
*
|
|
568
|
+
* Best-effort by design: the run has already failed, and a diagnostic that cannot be written must not
|
|
569
|
+
* replace that failure with a different one. `mkdir` is recursive because `.codegraph` itself may not
|
|
570
|
+
* exist yet — a run that failed early never got as far as creating it. The record itself goes through
|
|
571
|
+
* {@link writeJsonAtomically}: `status` reads this file synchronously while another run may be
|
|
572
|
+
* rewriting it, and a reader must never see a half-written record.
|
|
573
|
+
* @param root - absolute workspace path.
|
|
574
|
+
* @param source - which path started the run.
|
|
575
|
+
* @param reason - whatever the run threw, stringified; a non-Error rejection is still worth recording.
|
|
576
|
+
*/
|
|
577
|
+
async function writeIndexFailure(root, source, reason) {
|
|
578
|
+
const failure = { failedAt: Date.now(), message: describeFailure(reason), source };
|
|
579
|
+
try {
|
|
580
|
+
// A root that does not exist is not a workspace: writing the record there would create the very
|
|
581
|
+
// directory the failed run reported as missing. Only the console line covers that case.
|
|
582
|
+
if (!existsSync(root))
|
|
583
|
+
return;
|
|
584
|
+
const target = join(root, INDEX_FAILURE_RELATIVE_PATH);
|
|
585
|
+
await mkdir(dirname(target), { recursive: true });
|
|
586
|
+
await writeJsonAtomically(target, failure);
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
// A read-only workspace, or a file where `.codegraph` belongs. The caller still sees the original
|
|
590
|
+
// failure — this record was only ever a diagnostic, never a precondition for reporting anything —
|
|
591
|
+
// but a diagnostic that could not be written is itself worth one line rather than silence.
|
|
592
|
+
console.error(`codegraph-tree-sitter: could not record the indexing failure for ${root}: ${describeFailure(error)}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* The text of one failed run, as a reader of the record should see it.
|
|
597
|
+
*
|
|
598
|
+
* An `Error`'s own message is the whole diagnosis; its `String()` form adds a `"Error: "` prefix that
|
|
599
|
+
* only makes the sidecar harder to read. A rejection that is not an `Error` — a provider that throws a
|
|
600
|
+
* string — is still worth recording verbatim.
|
|
601
|
+
* @param reason - whatever the run threw.
|
|
602
|
+
* @returns the message to persist.
|
|
603
|
+
*/
|
|
604
|
+
function describeFailure(reason) {
|
|
605
|
+
return reason instanceof Error ? reason.message : String(reason);
|
|
606
|
+
}
|
|
473
607
|
/** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */
|
|
474
608
|
function assertPositiveInteger(field, value) {
|
|
475
609
|
if (!Number.isInteger(value) || value < 1) {
|
package/lib/resolve.js
CHANGED
|
@@ -192,14 +192,60 @@ export function resolveWorkspace(files, now) {
|
|
|
192
192
|
updatedAt: now,
|
|
193
193
|
};
|
|
194
194
|
nodes.push(fileNode);
|
|
195
|
+
// `RawDefinition.key` is `row:column` with no discriminator, and two captured definitions can
|
|
196
|
+
// genuinely share one source position: C/C++'s `struct X { … } y;` captures both the enclosing
|
|
197
|
+
// `declaration`/`field_declaration` (`y`, the whole statement, which starts at the same `struct`
|
|
198
|
+
// keyword) and the nested `struct_specifier` (`X`) there. The second and later definition with the
|
|
199
|
+
// same base id therefore gets a `#N` suffix; the first keeps the bare id, so a file whose keys are
|
|
200
|
+
// all distinct — i.e. essentially every file outside that C/C++ shape — resolves to exactly the
|
|
201
|
+
// same ids (and therefore the same graph) it did before.
|
|
202
|
+
const idCounts = new Map();
|
|
203
|
+
// `keyToId` stays last-write-wins: duplicate keys can only ever be *nested* (a source position
|
|
204
|
+
// identifies exactly one node, so two definitions sharing one must be ancestor and descendant),
|
|
205
|
+
// and the definitions are in DFS pre-order, so the last write is the innermost declaration — which
|
|
206
|
+
// is what a call or heritage reference inside it must attribute to.
|
|
195
207
|
const keyToId = new Map();
|
|
208
|
+
// Parent resolution deliberately does NOT go through `keyToId`: with a shared key, the map's last
|
|
209
|
+
// write would make the inner definition its own `contains` parent (a self-loop on a node with
|
|
210
|
+
// itself as target). The open definitions form a stack instead, so the outer and inner
|
|
211
|
+
// definitions of one position are two distinct nodes and the nesting is what it is in the source.
|
|
212
|
+
const openStack = [];
|
|
196
213
|
const fileNodes = [];
|
|
197
214
|
for (const def of file.extraction.definitions) {
|
|
198
215
|
const qualifiedName = def.container.length === 0
|
|
199
216
|
? `${file.path}::${def.name}`
|
|
200
217
|
: `${file.path}::${[...def.container, def.name].join('.')}`;
|
|
201
|
-
const
|
|
218
|
+
const baseId = `${file.path}:${def.startLine}:${def.startColumn}`;
|
|
219
|
+
const occurrence = (idCounts.get(baseId) ?? 0) + 1;
|
|
220
|
+
idCounts.set(baseId, occurrence);
|
|
221
|
+
const nodeId = occurrence === 1 ? baseId : `${baseId}#${occurrence}`;
|
|
202
222
|
keyToId.set(def.key, nodeId);
|
|
223
|
+
// A top-level definition's parent is the file node, and it also closes every definition still
|
|
224
|
+
// open at that point. Otherwise pop until the definition named by `parentKey` is on top; an
|
|
225
|
+
// unresolvable `parentKey` (a shape `extract.ts` does not produce, since a parent key is taken
|
|
226
|
+
// from the live container stack) falls back to the file node rather than throwing.
|
|
227
|
+
let parentId = id;
|
|
228
|
+
if (def.parentKey === null) {
|
|
229
|
+
openStack.length = 0;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
while (openStack.length > 0 && openStack[openStack.length - 1]?.key !== def.parentKey)
|
|
233
|
+
openStack.pop();
|
|
234
|
+
const parent = openStack[openStack.length - 1];
|
|
235
|
+
if (parent === undefined) {
|
|
236
|
+
// The file node is a true ancestor, so the edge stays usable — but a `parentKey` that is not
|
|
237
|
+
// open means extraction and resolution disagree, and silently reshaping that into a
|
|
238
|
+
// plausible-looking edge is how a nesting bug survives unnoticed. Record it with the exact
|
|
239
|
+
// position, which is what a reader needs to find the declaration.
|
|
240
|
+
console.error(`codegraph-tree-sitter: ${file.path}:${String(def.startLine)}:${String(def.startColumn)} `
|
|
241
|
+
+ `${def.name} names a parentKey (${String(def.parentKey)}) that is not open; `
|
|
242
|
+
+ 'falling back to the file node');
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
parentId = parent.id;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
openStack.push({ key: def.key, id: nodeId });
|
|
203
249
|
fileNodes.push({
|
|
204
250
|
id: nodeId,
|
|
205
251
|
kind: def.kind,
|
|
@@ -217,14 +263,11 @@ export function resolveWorkspace(files, now) {
|
|
|
217
263
|
decorators: def.decorators,
|
|
218
264
|
updatedAt: now,
|
|
219
265
|
});
|
|
266
|
+
edges.push({ source: parentId, target: nodeId, kind: 'contains', provenance: 'tree-sitter' });
|
|
220
267
|
}
|
|
221
268
|
nodes.push(...fileNodes);
|
|
222
269
|
nodesByFile.set(file.path, fileNodes);
|
|
223
270
|
defIdByKey.set(file.path, keyToId);
|
|
224
|
-
for (const def of file.extraction.definitions) {
|
|
225
|
-
const source = def.parentKey === null ? id : mustGet(keyToId, def.parentKey);
|
|
226
|
-
edges.push({ source, target: mustGet(keyToId, def.key), kind: 'contains', provenance: 'tree-sitter' });
|
|
227
|
-
}
|
|
228
271
|
}
|
|
229
272
|
const nameIndex = buildNameIndex(nodesByFile);
|
|
230
273
|
const typeNameIndex = buildTypeNameIndex(nodesByFile);
|
package/lib/schema.d.ts
CHANGED
|
@@ -34,4 +34,18 @@ export interface WriteInput {
|
|
|
34
34
|
* @param input - the run's resolved graph and per-file metadata.
|
|
35
35
|
*/
|
|
36
36
|
export declare function writeGraph(databasePath: string, input: WriteInput): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Replace one JSON document atomically: serialize to a unique staging file beside `target`, then
|
|
39
|
+
* `rename` it over `target`.
|
|
40
|
+
*
|
|
41
|
+
* The staging name is unique (`pid` plus a random UUID) and lives in the target's own directory, for
|
|
42
|
+
* two reasons. Two runs on one workspace are allowed to overlap — a session-start build and an
|
|
43
|
+
* explicit `codegraph_index`, or a watcher rebuild — and a shared fixed-name `.tmp` makes the loser's
|
|
44
|
+
* `rename` fail with ENOENT after the winner moved it, turning a harmless duplicate run into a failed
|
|
45
|
+
* call. Same-directory is what makes the `rename` atomic: it is a metadata operation on one
|
|
46
|
+
* filesystem, so a reader sees either the previous document or the new one, never a half-written one.
|
|
47
|
+
* @param target - absolute path of the file to replace.
|
|
48
|
+
* @param value - the value to serialize as JSON.
|
|
49
|
+
*/
|
|
50
|
+
export declare function writeJsonAtomically(target: string, value: unknown): Promise<void>;
|
|
37
51
|
//# sourceMappingURL=schema.d.ts.map
|
package/lib/schema.js
CHANGED
|
@@ -133,6 +133,24 @@ export async function writeGraph(databasePath, input) {
|
|
|
133
133
|
unresolvedLikelyInternalCount: input.unresolved.filter(ref => !ref.likelyExternal).length,
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Replace one JSON document atomically: serialize to a unique staging file beside `target`, then
|
|
138
|
+
* `rename` it over `target`.
|
|
139
|
+
*
|
|
140
|
+
* The staging name is unique (`pid` plus a random UUID) and lives in the target's own directory, for
|
|
141
|
+
* two reasons. Two runs on one workspace are allowed to overlap — a session-start build and an
|
|
142
|
+
* explicit `codegraph_index`, or a watcher rebuild — and a shared fixed-name `.tmp` makes the loser's
|
|
143
|
+
* `rename` fail with ENOENT after the winner moved it, turning a harmless duplicate run into a failed
|
|
144
|
+
* call. Same-directory is what makes the `rename` atomic: it is a metadata operation on one
|
|
145
|
+
* filesystem, so a reader sees either the previous document or the new one, never a half-written one.
|
|
146
|
+
* @param target - absolute path of the file to replace.
|
|
147
|
+
* @param value - the value to serialize as JSON.
|
|
148
|
+
*/
|
|
149
|
+
export async function writeJsonAtomically(target, value) {
|
|
150
|
+
const temp = `${target}.tmp-${process.pid}-${randomUUID()}`;
|
|
151
|
+
await writeFile(temp, JSON.stringify(value));
|
|
152
|
+
await rename(temp, target);
|
|
153
|
+
}
|
|
136
154
|
/**
|
|
137
155
|
* Write the prompt-time status sidecar beside the graph, replacing it atomically.
|
|
138
156
|
*
|
|
@@ -143,9 +161,6 @@ export async function writeGraph(databasePath, input) {
|
|
|
143
161
|
* @param status - this run's summary.
|
|
144
162
|
*/
|
|
145
163
|
async function writeStatusFile(directory, status) {
|
|
146
|
-
|
|
147
|
-
const temp = `${target}.tmp`;
|
|
148
|
-
await writeFile(temp, JSON.stringify(status));
|
|
149
|
-
await rename(temp, target);
|
|
164
|
+
await writeJsonAtomically(join(directory, basename(INDEX_STATUS_RELATIVE_PATH)), status);
|
|
150
165
|
}
|
|
151
166
|
//# sourceMappingURL=schema.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-codegraph-tree-sitter",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.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.6.
|
|
54
|
+
"dsh-plugin-codegraph-service": "^0.6.2"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"@deepseek-ai/cordis": ">=4.0.1",
|
|
@@ -59,11 +59,11 @@
|
|
|
59
59
|
"@deepseek-ai/dsh-invariants": ">=0.1.0-rc.6"
|
|
60
60
|
},
|
|
61
61
|
"optionalDependencies": {
|
|
62
|
-
"dsh-plugin-codegraph-indexer-darwin-arm64": "0.6.
|
|
63
|
-
"dsh-plugin-codegraph-indexer-darwin-x64": "0.6.
|
|
64
|
-
"dsh-plugin-codegraph-indexer-linux-x64-gnu": "0.6.
|
|
65
|
-
"dsh-plugin-codegraph-indexer-linux-arm64-gnu": "0.6.
|
|
66
|
-
"dsh-plugin-codegraph-indexer-win32-x64-msvc": "0.6.
|
|
62
|
+
"dsh-plugin-codegraph-indexer-darwin-arm64": "0.6.2",
|
|
63
|
+
"dsh-plugin-codegraph-indexer-darwin-x64": "0.6.2",
|
|
64
|
+
"dsh-plugin-codegraph-indexer-linux-x64-gnu": "0.6.2",
|
|
65
|
+
"dsh-plugin-codegraph-indexer-linux-arm64-gnu": "0.6.2",
|
|
66
|
+
"dsh-plugin-codegraph-indexer-win32-x64-msvc": "0.6.2"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@deepseek-ai/cordis": "^4.0.1",
|