dsh-plugin-codegraph-tree-sitter 0.6.1 → 0.6.3

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
@@ -67,6 +67,14 @@ export declare const DEFAULT_INDEXER_ID = "codegraph-tree-sitter";
67
67
  * Directory segments never descended into by default. Includes this package's own `.codegraph`
68
68
  * output — without it, a watcher would treat its own `writeGraph()` as an in-scope change and
69
69
  * rebuild forever.
70
+ *
71
+ * `target` is here for a reason that cost real file descriptors to learn: this plugin is developed
72
+ * in its own repository, and `crates/indexer-rust/target/` holds thousands of cargo artefacts
73
+ * across ~1000 directories. Without the exclusion the walk descends into every one of them, and on
74
+ * macOS each watched file pins a descriptor — a single workspace accumulated 9.7k open handles,
75
+ * which is what makes a host's `spawn()` fail with `EBADF` long before any real limit is reached.
76
+ * `.scratch` and `vendor` cover the same shape of accident for working copies and dependency
77
+ * checkouts.
70
78
  */
71
79
  export declare const DEFAULT_EXCLUDE: string[];
72
80
  /** Default ceiling on a single file's size before it is skipped. */
@@ -92,6 +100,16 @@ export declare const DEFAULT_WATCH_DEBOUNCE_MS = 2000;
92
100
  export declare const DEFAULT_MAX_WATCHED_DIRECTORIES = 4000;
93
101
  /** Default age past which a session-open refreshes an existing index (ten minutes). */
94
102
  export declare const DEFAULT_AUTO_REFRESH_AFTER_MS: number;
103
+ /**
104
+ * Default ceiling on one session-start automatic index (fifteen minutes).
105
+ *
106
+ * An automatic run nobody asked for holds its workspace in `autoIndexInFlight`, which is what keeps a
107
+ * burst of sessions from racing two builds over the same files. A run that never settles — a wedged
108
+ * parse, a backend whose abort is ignored — would therefore silence every later session in that
109
+ * workspace for the life of the process. The deadline is the way out: the run is aborted, the root is
110
+ * released, and the reason is recorded next to the graph for whoever asks next.
111
+ */
112
+ export declare const DEFAULT_AUTO_INDEX_TIMEOUT_MS: number;
95
113
  /** Plugin configuration: the indexer's identity and every deployment-varying bound on a run. */
96
114
  export interface Config {
97
115
  /**
@@ -183,8 +201,10 @@ export interface Config {
183
201
  * when its status sidecar already exists, so this cannot rebuild a graph that is already there.
184
202
  * Set false to keep every build explicitly requested.
185
203
  *
186
- * Requires the host's `jobs` service: the build is never run synchronously inside session creation,
187
- * so a composition without background jobs simply schedules nothing.
204
+ * Does not need the host's `jobs` service: the build never runs synchronously inside session
205
+ * creation, it runs as a plain background promise, so a composition without background jobs still
206
+ * indexes. A failure is reported through `console.error` and recorded in
207
+ * `.codegraph/last-error.json` for the `status` operation to explain.
188
208
  */
189
209
  /** Cap on candidate source files a session-start scan may find before it declines to index. */
190
210
  autoIndexMaxFiles?: number;
@@ -196,6 +216,12 @@ export interface Config {
196
216
  * only ever begins after a successful index.
197
217
  */
198
218
  autoRefreshAfterMs?: number;
219
+ /**
220
+ * How long one session-start automatic index may run before it is aborted and its workspace
221
+ * released (default 900000, fifteen minutes). See {@link DEFAULT_AUTO_INDEX_TIMEOUT_MS} for why an
222
+ * unanswered run needs a deadline at all.
223
+ */
224
+ autoIndexTimeoutMs?: number;
199
225
  }
200
226
  export declare const Config: z<Config>;
201
227
  /**
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 { stat } from 'node:fs/promises';
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";
@@ -72,8 +71,26 @@ export const DEFAULT_INDEXER_ID = 'codegraph-tree-sitter';
72
71
  * Directory segments never descended into by default. Includes this package's own `.codegraph`
73
72
  * output — without it, a watcher would treat its own `writeGraph()` as an in-scope change and
74
73
  * rebuild forever.
74
+ *
75
+ * `target` is here for a reason that cost real file descriptors to learn: this plugin is developed
76
+ * in its own repository, and `crates/indexer-rust/target/` holds thousands of cargo artefacts
77
+ * across ~1000 directories. Without the exclusion the walk descends into every one of them, and on
78
+ * macOS each watched file pins a descriptor — a single workspace accumulated 9.7k open handles,
79
+ * which is what makes a host's `spawn()` fail with `EBADF` long before any real limit is reached.
80
+ * `.scratch` and `vendor` cover the same shape of accident for working copies and dependency
81
+ * checkouts.
75
82
  */
76
- export const DEFAULT_EXCLUDE = ['node_modules', 'dist', 'build', 'coverage', '.git', '.codegraph'];
83
+ export const DEFAULT_EXCLUDE = [
84
+ 'node_modules',
85
+ 'dist',
86
+ 'build',
87
+ 'coverage',
88
+ '.git',
89
+ '.codegraph',
90
+ 'target',
91
+ '.scratch',
92
+ 'vendor',
93
+ ];
77
94
  /** Default ceiling on a single file's size before it is skipped. */
78
95
  export const DEFAULT_MAX_FILE_BYTES = 2_000_000;
79
96
  /** Default ceiling on how many files one run indexes. */
@@ -97,6 +114,16 @@ export const DEFAULT_WATCH_DEBOUNCE_MS = 2_000;
97
114
  export const DEFAULT_MAX_WATCHED_DIRECTORIES = 4_000;
98
115
  /** Default age past which a session-open refreshes an existing index (ten minutes). */
99
116
  export const DEFAULT_AUTO_REFRESH_AFTER_MS = 10 * 60 * 1000;
117
+ /**
118
+ * Default ceiling on one session-start automatic index (fifteen minutes).
119
+ *
120
+ * An automatic run nobody asked for holds its workspace in `autoIndexInFlight`, which is what keeps a
121
+ * burst of sessions from racing two builds over the same files. A run that never settles — a wedged
122
+ * parse, a backend whose abort is ignored — would therefore silence every later session in that
123
+ * workspace for the life of the process. The deadline is the way out: the run is aborted, the root is
124
+ * released, and the reason is recorded next to the graph for whoever asks next.
125
+ */
126
+ export const DEFAULT_AUTO_INDEX_TIMEOUT_MS = 15 * 60 * 1000;
100
127
  export const Config = z.object({
101
128
  indexerId: z.string().default(DEFAULT_INDEXER_ID),
102
129
  languages: z.array(z.string()).default([...new Set(LANGUAGE_TABLE.map(spec => spec.language))]),
@@ -116,6 +143,7 @@ export const Config = z.object({
116
143
  autoIndexMaxFiles: z.number().default(DEFAULT_AUTO_INDEX_MAX_FILES),
117
144
  autoIndexOnSession: z.boolean().default(true),
118
145
  autoRefreshAfterMs: z.number().default(DEFAULT_AUTO_REFRESH_AFTER_MS),
146
+ autoIndexTimeoutMs: z.number().default(DEFAULT_AUTO_INDEX_TIMEOUT_MS),
119
147
  });
120
148
  /**
121
149
  * Register the tree-sitter indexer.
@@ -129,6 +157,7 @@ export function apply(ctx, config) {
129
157
  assertPositiveInteger('concurrency', resolved.concurrency);
130
158
  assertPositiveInteger('watchDebounceMs', resolved.watchDebounceMs);
131
159
  assertPositiveInteger('maxWatchedDirectories', resolved.maxWatchedDirectories);
160
+ assertPositiveInteger('autoIndexTimeoutMs', resolved.autoIndexTimeoutMs);
132
161
  // Watching and the parse cache both go through the host's filesystem seam rather than `node:fs`, so
133
162
  // they follow the same execution world the rest of the harness reads: a remote or confined backend
134
163
  // watches and caches on its own side, and the cache's read/write pass through the same policy the
@@ -150,6 +179,10 @@ export function apply(ctx, config) {
150
179
  // window: a first build can outlive the window, and two builds on one root would only race the same
151
180
  // index files.
152
181
  const autoIndexInFlight = new Set();
182
+ // Controllers of the automatic runs still executing. Kept so the plugin's own unload can abort them
183
+ // rather than leaving a parse writing into a graph nobody is listening for any more; each run removes
184
+ // its own controller in `finally`.
185
+ const autoIndexControllers = new Set();
153
186
  // `agent/created` is typed by `@deepseek-ai/dsh-agent`'s module augmentation. Reading the event
154
187
  // through a local structural face keeps this package's host dependencies unchanged: it needs one
155
188
  // event name and one payload shape, not the whole agent package.
@@ -198,15 +231,34 @@ export function apply(ctx, config) {
198
231
  }
199
232
  },
200
233
  async index(projectRoot, signal) {
201
- const report = await runIndex(projectRoot, resolved, fileSystem, signal);
202
- // Watching starts only after a successful index establishes the baseline it refreshes — never
203
- // from a canIndex() probe or a failed run.
204
- ensureWatching(projectRoot);
205
- return report;
234
+ try {
235
+ const report = await runIndex(projectRoot, resolved, fileSystem, signal);
236
+ // Watching starts only after a successful index establishes the baseline it refreshes — never
237
+ // from a canIndex() probe or a failed run.
238
+ ensureWatching(projectRoot);
239
+ return report;
240
+ }
241
+ catch (error) {
242
+ // One entry point for every explicit build: `codegraph_index` and the `/codegraph index`
243
+ // command both reach the seam here. An automatic run passes through too, but records its own
244
+ // 'auto' label afterwards, so the final record names the path that actually asked.
245
+ // An abort is not a failure of the workspace: the tool's cancel and the plugin's unload both
246
+ // abort this signal, and a record saying "This operation was aborted" would outlive the
247
+ // process that wrote it and be read back as a diagnosis.
248
+ if (signal?.aborted !== true)
249
+ await writeIndexFailure(projectRoot, 'tool', error);
250
+ throw error;
251
+ }
206
252
  },
207
253
  };
208
254
  ctx.effect(function* () {
209
255
  yield () => {
256
+ // Aborting goes first: a run still writing when this plugin unloads would otherwise keep the
257
+ // parse alive past the index it serves, and leave a graph half-written by an instance nobody
258
+ // asked for any more. Each run's own `finally` drops it from the set.
259
+ for (const controller of autoIndexControllers)
260
+ controller.abort();
261
+ autoIndexControllers.clear();
210
262
  for (const watcher of watchers.values())
211
263
  watcher.stop();
212
264
  watchers.clear();
@@ -214,7 +266,7 @@ export function apply(ctx, config) {
214
266
  if (resolved.autoIndexOnSession) {
215
267
  // Registered inside this effect so the listener follows the plugin's lifetime: unloading the
216
268
  // plugin must stop new sessions from scheduling work this instance can no longer run.
217
- yield agentEvents.on('agent/created', payload => autoIndexOnSession(ctx, resolved, lastAutoRun, autoIndexInFlight, payload, ensureWatching));
269
+ yield agentEvents.on('agent/created', payload => autoIndexOnSession(ctx, resolved, lastAutoRun, autoIndexInFlight, autoIndexControllers, payload, ensureWatching));
218
270
  }
219
271
  yield ctx.codegraph.registerIndexer(indexer);
220
272
  }, 'codegraph-tree-sitter');
@@ -255,10 +307,12 @@ function isHarnessHome(root) {
255
307
  * @param lastAutoRun - when this instance last started an automatic index for each root.
256
308
  * @param autoIndexInFlight - roots whose background run is still executing, so a slow build cannot be
257
309
  * started twice by sessions that open past the freshness window.
310
+ * @param autoIndexControllers - controllers of the automatic runs this instance has in flight, so the
311
+ * plugin's unload can abort them.
258
312
  * @param payload - the `agent/created` dispatch payload.
259
313
  * @param ensureWatching - starts file watching for a workspace that is already current.
260
314
  */
261
- function autoIndexOnSession(ctx, config, lastAutoRun, autoIndexInFlight, payload, ensureWatching) {
315
+ function autoIndexOnSession(ctx, config, lastAutoRun, autoIndexInFlight, autoIndexControllers, payload, ensureWatching) {
262
316
  try {
263
317
  const agent = payload.agent;
264
318
  const root = agent?.session?.header?.cwd;
@@ -294,12 +348,17 @@ function autoIndexOnSession(ctx, config, lastAutoRun, autoIndexInFlight, payload
294
348
  }
295
349
  lastAutoRun.set(root, now);
296
350
  autoIndexInFlight.add(root);
297
- // Never rejects: a lost index is a degradation, not an error the session can act on.
298
- void runAutomaticIndex(ctx, root, config).finally(() => autoIndexInFlight.delete(root));
351
+ // Never rejects: a lost index is a degradation, not an error the session can act on. The in-flight
352
+ // entry is released by the run itself rather than here, because only that run knows whether a
353
+ // deadline has already replaced it — see `runAutomaticIndex`.
354
+ void runAutomaticIndex(ctx, root, config, autoIndexInFlight, autoIndexControllers);
299
355
  }
300
356
  catch (error) {
301
357
  // The host treats a throwing listener as vetoing publication. Losing an index is a degradation,
302
358
  // never a reason to stop a session from starting.
359
+ // Nothing in the block above throws for a payload-shaped agent: the only ways here are the host's
360
+ // own event bus or a path helper, which a unit test cannot arrange without mocking `node:fs`.
361
+ /* v8 ignore next */
303
362
  console.error(`codegraph-tree-sitter: automatic indexing for a new session failed: ${String(error)}`);
304
363
  }
305
364
  }
@@ -309,12 +368,41 @@ function autoIndexOnSession(ctx, config, lastAutoRun, autoIndexInFlight, payload
309
368
  * The size guard stays: parsing a workspace past `autoIndexMaxFiles` would exhaust the WASM heap and
310
369
  * take the desktop down with it, and an automatic index is a convenience. Skipping it is a decision,
311
370
  * not a failure, so it is logged once and the session continues untouched.
371
+ *
372
+ * A run that fails now also leaves {@link INDEX_FAILURE_RELATIVE_PATH} beside the graph, and a run that
373
+ * exceeds {@link Config.autoIndexTimeoutMs} is aborted and recorded as such. Both exist for the same
374
+ * reason: the user's only view of an automatic run is `codegraph status`, and "no index" without a
375
+ * reason sent them looking for a bug in their workspace rather than in the run.
312
376
  * @param ctx - the plugin context.
313
377
  * @param root - absolute workspace path.
314
378
  * @param config - the resolved plugin configuration.
379
+ * @param autoIndexInFlight - roots whose background run is still executing; a timed-out run releases
380
+ * its own entry because a run that ignores its signal may never settle.
381
+ * @param autoIndexControllers - controllers of the automatic runs in flight, for the plugin's unload
382
+ * to abort.
315
383
  */
316
- async function runAutomaticIndex(ctx, root, config) {
384
+ async function runAutomaticIndex(ctx, root, config, autoIndexInFlight, autoIndexControllers) {
317
385
  const controller = new AbortController();
386
+ autoIndexControllers.add(controller);
387
+ const timeoutMs = config.autoIndexTimeoutMs;
388
+ let timedOut = false;
389
+ const timer = setTimeout(() => {
390
+ timedOut = true;
391
+ // The record is written here rather than left to the `catch` below: a run that ignores its signal
392
+ // may never settle, and then the failure this deadline is describing would never be recorded.
393
+ void writeIndexFailure(root, 'auto', `automatic index timed out after ${String(timeoutMs)}ms`);
394
+ // Released here for the same reason: the promise's own `finally` is exactly what a run that never
395
+ // settles never reaches, and every later session in this workspace would skip indexing forever.
396
+ autoIndexInFlight.delete(root);
397
+ // The controller is dropped here too: `finally` is what would normally release it, and this is the
398
+ // path a run that ignores its signal never reaches. Leaving it in the set would make the plugin's
399
+ // own unload abort a run that already gave up — and keep it alive, with the workspace it holds, for
400
+ // the life of the process.
401
+ autoIndexControllers.delete(controller);
402
+ controller.abort();
403
+ }, timeoutMs);
404
+ // A pending deadline must not be the reason a short-lived host process stays up.
405
+ timer.unref();
318
406
  try {
319
407
  const oversized = await exceedsAutoIndexLimit(root, config, controller.signal);
320
408
  if (oversized !== undefined) {
@@ -327,7 +415,27 @@ async function runAutomaticIndex(ctx, root, config) {
327
415
  await ctx.codegraph.index(root, controller.signal);
328
416
  }
329
417
  catch (error) {
330
- console.error(`codegraph-tree-sitter: automatic index for ${root} failed: ${String(error)}`);
418
+ // An abort that did not come from the deadline is the plugin unloading: nobody is waiting for this
419
+ // graph any more, so it is not a workspace failure and gets neither a log line nor a record.
420
+ if (controller.signal.aborted && !timedOut)
421
+ return;
422
+ // One message either way, but "timed out" and "the indexer threw" are different diagnoses: the
423
+ // first says the workspace is too large or too slow, the second names what actually broke.
424
+ const message = timedOut
425
+ ? `automatic index timed out after ${String(timeoutMs)}ms`
426
+ : describeFailure(error);
427
+ console.error(`codegraph-tree-sitter: automatic index for ${root} failed: ${message}`);
428
+ await writeIndexFailure(root, 'auto', message);
429
+ }
430
+ finally {
431
+ clearTimeout(timer);
432
+ autoIndexControllers.delete(controller);
433
+ // Only while this run still owns the entry. A run that passed its deadline released the root in the
434
+ // timer callback — that release is what lets a later session retry at all, because a run that
435
+ // ignores its signal may never reach this line — and deleting here by root alone would then take
436
+ // away whichever newer run has claimed the same workspace since.
437
+ if (!timedOut)
438
+ autoIndexInFlight.delete(root);
331
439
  }
332
440
  }
333
441
  /**
@@ -405,8 +513,16 @@ function readStatusIndexedAt(root) {
405
513
  */
406
514
  async function runSync(projectRoot, config, fs) {
407
515
  const startedAt = Date.now();
408
- const report = await runIndex(projectRoot, config, fs);
409
- return { filesChanged: report.filesIndexed, durationMs: Date.now() - startedAt };
516
+ try {
517
+ const report = await runIndex(projectRoot, config, fs);
518
+ return { filesChanged: report.filesIndexed, durationMs: Date.now() - startedAt };
519
+ }
520
+ catch (error) {
521
+ // A watch rebuild has no caller to fail: the debounce already returned, so the only trace of a
522
+ // failed refresh used to be one console line in a process nobody reads interactively.
523
+ await writeIndexFailure(projectRoot, 'watch', error);
524
+ throw error;
525
+ }
410
526
  }
411
527
  /**
412
528
  * One indexing run: walk, parse, resolve, and write.
@@ -437,6 +553,10 @@ async function runIndex(projectRoot, config, fs, signal) {
437
553
  await writeGraph(databasePath, {
438
554
  files, nodes: graph.nodes, edges: graph.edges, unresolved: graph.unresolved, filesMinified, indexedAt,
439
555
  });
556
+ // The graph is back, so the recorded reason for its absence is no longer true. Cleared here rather
557
+ // than in `index()` because the watch path reaches this function without going through it; the
558
+ // deletion tolerates absence, which is the common case on a healthy workspace.
559
+ await rm(join(projectRoot, INDEX_FAILURE_RELATIVE_PATH), { force: true });
440
560
  // Only after the graph is in place: the cache records the extractions this run actually committed,
441
561
  // so a run that failed before writing its graph leaves the previous cache — and the mtimes it was
442
562
  // built from — alone instead of vouching for a graph that was never written.
@@ -460,6 +580,48 @@ async function runIndex(projectRoot, config, fs, signal) {
460
580
  languages,
461
581
  };
462
582
  }
583
+ /**
584
+ * Record why a run failed beside the graph, so a later `codegraph status` can explain an absent index.
585
+ *
586
+ * Best-effort by design: the run has already failed, and a diagnostic that cannot be written must not
587
+ * replace that failure with a different one. `mkdir` is recursive because `.codegraph` itself may not
588
+ * exist yet — a run that failed early never got as far as creating it. The record itself goes through
589
+ * {@link writeJsonAtomically}: `status` reads this file synchronously while another run may be
590
+ * rewriting it, and a reader must never see a half-written record.
591
+ * @param root - absolute workspace path.
592
+ * @param source - which path started the run.
593
+ * @param reason - whatever the run threw, stringified; a non-Error rejection is still worth recording.
594
+ */
595
+ async function writeIndexFailure(root, source, reason) {
596
+ const failure = { failedAt: Date.now(), message: describeFailure(reason), source };
597
+ try {
598
+ // A root that does not exist is not a workspace: writing the record there would create the very
599
+ // directory the failed run reported as missing. Only the console line covers that case.
600
+ if (!existsSync(root))
601
+ return;
602
+ const target = join(root, INDEX_FAILURE_RELATIVE_PATH);
603
+ await mkdir(dirname(target), { recursive: true });
604
+ await writeJsonAtomically(target, failure);
605
+ }
606
+ catch (error) {
607
+ // A read-only workspace, or a file where `.codegraph` belongs. The caller still sees the original
608
+ // failure — this record was only ever a diagnostic, never a precondition for reporting anything —
609
+ // but a diagnostic that could not be written is itself worth one line rather than silence.
610
+ console.error(`codegraph-tree-sitter: could not record the indexing failure for ${root}: ${describeFailure(error)}`);
611
+ }
612
+ }
613
+ /**
614
+ * The text of one failed run, as a reader of the record should see it.
615
+ *
616
+ * An `Error`'s own message is the whole diagnosis; its `String()` form adds a `"Error: "` prefix that
617
+ * only makes the sidecar harder to read. A rejection that is not an `Error` — a provider that throws a
618
+ * string — is still worth recording verbatim.
619
+ * @param reason - whatever the run threw.
620
+ * @returns the message to persist.
621
+ */
622
+ function describeFailure(reason) {
623
+ return reason instanceof Error ? reason.message : String(reason);
624
+ }
463
625
  /** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */
464
626
  function assertPositiveInteger(field, value) {
465
627
  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 nodeId = `${file.path}:${def.startLine}:${def.startColumn}`;
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,14 +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
- const target = join(directory, basename(INDEX_STATUS_RELATIVE_PATH));
147
- // Unique like the graph's own staging name, and for the same reason: two runs on one workspace are
148
- // allowed to overlap (a session-start build and an explicit `codegraph_index`, or a watcher rebuild),
149
- // and a shared fixed-name `.tmp` makes the loser's `rename` fail with ENOENT after the winner moved
150
- // it — which turned a harmless duplicate run into a failed tool call. Last rename wins; both graphs
151
- // are complete.
152
- const temp = `${target}.tmp-${process.pid}-${randomUUID()}`;
153
- await writeFile(temp, JSON.stringify(status));
154
- await rename(temp, target);
164
+ await writeJsonAtomically(join(directory, basename(INDEX_STATUS_RELATIVE_PATH)), status);
155
165
  }
156
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.1",
3
+ "version": "0.6.3",
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.1"
54
+ "dsh-plugin-codegraph-service": "^0.6.3"
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.1",
63
- "dsh-plugin-codegraph-indexer-darwin-x64": "0.6.1",
64
- "dsh-plugin-codegraph-indexer-linux-x64-gnu": "0.6.1",
65
- "dsh-plugin-codegraph-indexer-linux-arm64-gnu": "0.6.1",
66
- "dsh-plugin-codegraph-indexer-win32-x64-msvc": "0.6.1"
62
+ "dsh-plugin-codegraph-indexer-darwin-arm64": "0.6.3",
63
+ "dsh-plugin-codegraph-indexer-darwin-x64": "0.6.3",
64
+ "dsh-plugin-codegraph-indexer-linux-x64-gnu": "0.6.3",
65
+ "dsh-plugin-codegraph-indexer-linux-arm64-gnu": "0.6.3",
66
+ "dsh-plugin-codegraph-indexer-win32-x64-msvc": "0.6.3"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@deepseek-ai/cordis": "^4.0.1",