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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -27,7 +27,7 @@
27
27
  * this package ships with.
28
28
  * @module dsh-plugin-codegraph-tree-sitter
29
29
  */
30
- import { readFileSync } from 'node:fs';
30
+ import { existsSync, readFileSync } from 'node:fs';
31
31
  import { readdir } from 'node:fs/promises';
32
32
  import { stat } from 'node:fs/promises';
33
33
  import { join, posix } from 'node:path';
@@ -210,13 +210,26 @@ export function apply(ctx, config) {
210
210
  if (resolved.autoIndexOnSession) {
211
211
  // Registered inside this effect so the listener follows the plugin's lifetime: unloading the
212
212
  // 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));
213
+ yield agentEvents.on('agent/created', payload => autoIndexOnSession(ctx, resolved, lastAutoRun, payload, ensureWatching));
214
214
  }
215
215
  yield ctx.codegraph.registerIndexer(indexer);
216
216
  }, 'codegraph-tree-sitter');
217
217
  }
218
218
  /** Stable job kind of an automatic session-start index, used for retention and diagnostics. */
219
219
  const AUTO_INDEX_JOB_KIND = 'codegraph-index';
220
+ /**
221
+ * Whether a directory is the harness's own home rather than a project checkout.
222
+ *
223
+ * `sessions/` and `profiles/` together are what makes it one: either alone is a plausible folder
224
+ * name inside an ordinary repository, and requiring both keeps a real workspace eligible. The check
225
+ * exists because a DSH home is by construction tens of thousands of files — indexing it can only
226
+ * ever hit the session-start size guard, so queueing the job produces a failure and nothing else.
227
+ * @param root - the absolute workspace path.
228
+ * @returns true when the directory holds the harness's own state.
229
+ */
230
+ function isHarnessHome(root) {
231
+ return existsSync(join(root, 'sessions')) && existsSync(join(root, 'profiles'));
232
+ }
220
233
  /**
221
234
  * Index a new session's workspace in the background — when nothing has indexed it yet, or when what
222
235
  * is there has gone stale.
@@ -234,13 +247,20 @@ const AUTO_INDEX_JOB_KIND = 'codegraph-index';
234
247
  * @param lastAutoRun - when this instance last started an automatic index for each root.
235
248
  * @param payload - the `agent/created` dispatch payload.
236
249
  */
237
- function autoIndexOnSession(ctx, config, lastAutoRun, payload) {
250
+ function autoIndexOnSession(ctx, config, lastAutoRun, payload, ensureWatching) {
238
251
  try {
239
252
  const agent = payload.agent;
240
253
  const root = agent?.session?.header?.cwd;
241
254
  // No workspace means no root to claim: an agent created without a `cwd` has nothing to index.
242
255
  if (agent === undefined || !root)
243
256
  return;
257
+ // DSH's own home directory is not a code repository — `sessions/`, `attachments/`, the index
258
+ // databases and the installed plugins below it are a program's state. It is also tens of
259
+ // thousands of files, so every session opened there queued a job whose only possible outcome was
260
+ // the size guard failing. Skipping here keeps the job list honest: what is not a candidate never
261
+ // becomes a failure the user cannot act on.
262
+ if (isHarnessHome(root))
263
+ return;
244
264
  const now = Date.now();
245
265
  // A burst of sessions opened on one workspace must not queue the same rebuild repeatedly.
246
266
  const previous = lastAutoRun.get(root);
@@ -250,8 +270,14 @@ function autoIndexOnSession(ctx, config, lastAutoRun, payload) {
250
270
  // or old one means the graph is missing or behind. Checked before the job lookup so a composition
251
271
  // without background jobs never pays for the probe.
252
272
  const indexedAt = readStatusIndexedAt(root);
253
- if (indexedAt !== undefined && now - indexedAt < config.autoRefreshAfterMs)
273
+ if (indexedAt !== undefined && now - indexedAt < config.autoRefreshAfterMs) {
274
+ // The graph is current, so no build is warranted — but watching is process-local state and
275
+ // this is the first time this process has seen this workspace. Without this call, a restart
276
+ // followed by a session in an up-to-date workspace would leave it unwatched until the sidecar
277
+ // aged out, and every edit until then would silently not reach the graph.
278
+ ensureWatching(root);
254
279
  return;
280
+ }
255
281
  // `ctx.get` rather than an `inject` entry: `jobs` is a host composition choice, and requiring it in
256
282
  // `inject` would hide this whole indexer from a profile that runs no background jobs.
257
283
  const jobs = ctx.get('jobs');
@@ -361,7 +387,10 @@ function runAutoIndexJob(ctx, root, config, handle) {
361
387
  handle.append(`Skipped automatic indexing of ${root}: it holds at least ${String(oversized)} candidate ` +
362
388
  `source files, past the ${String(config.autoIndexMaxFiles)}-file session-start limit. ` +
363
389
  `Run codegraph_index yourself if you want a graph for it.\n`);
364
- return { status: 'failed', detail: 'workspace too large for automatic indexing' };
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' };
365
394
  }
366
395
  const report = await ctx.codegraph.index(root, controller.signal);
367
396
  handle.append(`Indexed ${root}: ${report.filesIndexed} files, ${report.nodeCount} symbols, ` +
@@ -18,9 +18,9 @@ import type { FileExtraction } from './extract.ts';
18
18
  export declare const RUST_LANGUAGES: readonly string[];
19
19
  /**
20
20
  * Files handed to one addon call. The addon parses a batch across every core it can, so a large batch
21
- * is what makes it fast — but the call itself blocks the event loop for its whole duration, so the
22
- * batch is bounded to keep one call short enough that a watcher rebuild or a host request is not
23
- * stalled behind it.
21
+ * is what makes it fast. The call runs on libuv's thread pool (see {@link RustEngine}), so a batch no
22
+ * longer stalls the event loop; the bound now keeps one promise's lifetime — and the file text it
23
+ * holds in flight — reasonable, instead of capping a blocking call.
24
24
  */
25
25
  export declare const RUST_BATCH_SIZE = 200;
26
26
  /** One file as the addon takes it. */
@@ -39,11 +39,16 @@ export interface RustExtractInput {
39
39
  export interface RustEngine {
40
40
  /**
41
41
  * Extract definitions, calls, imports, and heritage for a batch of files.
42
+ *
43
+ * Answers a promise because the addon's body runs on libuv's thread pool: a synchronous native call
44
+ * blocked the host's event loop for the whole batch, which is what froze the desktop when automatic
45
+ * indexing ran inside the renderer. Callers `await` it; an older addon that still returns a string
46
+ * keeps working, because `await` on a non-promise is the value itself.
42
47
  * @param inputs - the files to extract; inputs in an unsupported language are silently skipped.
43
48
  * @param rulesJson - the `LanguageSpec` table, as JSON.
44
- * @returns JSON of `{ results: [{ path, extraction }], supported: string[] }`.
49
+ * @returns a promise of JSON `{ results: [{ path, extraction }], supported: string[] }`.
45
50
  */
46
- extractWorkspace(inputs: RustExtractInput[], rulesJson: string): string;
51
+ extractWorkspace(inputs: RustExtractInput[], rulesJson: string): Promise<string>;
47
52
  }
48
53
  /** Options for {@link loadRustEngine}. */
49
54
  export interface LoadRustEngineOptions {
@@ -53,14 +58,47 @@ export interface LoadRustEngineOptions {
53
58
  */
54
59
  readonly modulePath?: string;
55
60
  }
61
+ /**
62
+ * Where the native package for this platform lives, when npm installed one.
63
+ *
64
+ * The addon ships as one optional dependency per platform (`optionalDependencies` in this package's
65
+ * manifest), so a `pnpm add` pulls exactly one binary — the one the running machine can `dlopen` —
66
+ * instead of every platform's. npm's `os`/`cpu` fields do the selection; a platform this project does
67
+ * not build for simply never installs one and the walk falls back to JS.
68
+ * @param platform - `process.platform`.
69
+ * @param arch - `process.arch`.
70
+ * @returns the package name, or `undefined` for a platform with no published build.
71
+ */
72
+ export declare function nativePackageName(platform: string, arch: string): string | undefined;
73
+ /**
74
+ * Resolve this platform's optional native package to its `.node` entry, if it is installed.
75
+ *
76
+ * `require.resolve` is the only thing that knows where a dependency actually landed — pnpm's store,
77
+ * npm's flat tree, a hoisted profile — so the path is asked for rather than constructed. A missing
78
+ * package, or one whose `main` does not resolve, is `undefined`: that is the documented "no addon"
79
+ * answer, not an error.
80
+ * @param platform - `process.platform`.
81
+ * @param arch - `process.arch`.
82
+ * @param resolve - module resolution, injectable so a test does not depend on what this machine has
83
+ * installed.
84
+ * @returns the absolute path to the addon, or `undefined`.
85
+ */
86
+ export declare function nativePackageModulePath(platform: string, arch: string, resolve?: (name: string) => string): string | undefined;
56
87
  /**
57
88
  * Every place the addon may live, most specific first.
58
89
  *
59
- * Two layouts matter: a package that ships the binary puts it in `artifacts/` beside `lib/` (the
60
- * published shape, and what a profile install gets when someone copies it in), while a repository
61
- * checkout builds it under `crates/indexer-rust/artifacts/`, three directories up from both `src/`
62
- * and `lib/` since those sit at the same depth. Trying both means one module works whether it was
63
- * installed or checked out.
90
+ * Three layouts matter: a package that ships the binary puts it in `artifacts/` beside `lib/` (the
91
+ * shape an older hand-copied install has), a repository checkout builds it under
92
+ * `crates/indexer-rust/artifacts/` (three directories up from both `src/` and `lib/`, which sit at the
93
+ * same depth), and an npm install carries it in the platform's optional dependency. Trying all three
94
+ * means one module works whether it was installed, copied in, or checked out.
95
+ * @param base - the importing module's URL; parameterized so a test can point at a scratch tree.
96
+ * @param fromPackage - this platform's optional-dependency path, or `undefined` when there is none.
97
+ * @returns absolute paths to try, in order.
98
+ */
99
+ export declare function modulePathsFor(base: string, fromPackage: string | undefined): string[];
100
+ /**
101
+ * The paths production should try, with this platform's optional package worked out for it.
64
102
  * @param base - the importing module's URL; parameterized so a test can point at a scratch tree.
65
103
  * @returns absolute paths to try, in order.
66
104
  */
@@ -89,7 +127,7 @@ export declare function loadRustEngine(options?: LoadRustEngineOptions): RustEng
89
127
  * @returns one extraction per path the addon answered for, or `undefined` when no answer can be
90
128
  * trusted at all — no addon, a thrown call, or JSON that is not the documented shape.
91
129
  */
92
- export declare function extractWithRust(files: readonly RustExtractInput[], engine: RustEngine | undefined): Map<string, FileExtraction> | undefined;
130
+ export declare function extractWithRust(files: readonly RustExtractInput[], engine: RustEngine | undefined): Promise<Map<string, FileExtraction> | undefined>;
93
131
  /**
94
132
  * Install a stand-in for the addon the walk resolves, or restore the real one.
95
133
  *
@@ -20,30 +20,93 @@ import { LANGUAGE_TABLE } from "./languages.js";
20
20
  export const RUST_LANGUAGES = ['typescript', 'tsx', 'javascript', 'jsx'];
21
21
  /**
22
22
  * Files handed to one addon call. The addon parses a batch across every core it can, so a large batch
23
- * is what makes it fast — but the call itself blocks the event loop for its whole duration, so the
24
- * batch is bounded to keep one call short enough that a watcher rebuild or a host request is not
25
- * stalled behind it.
23
+ * is what makes it fast. The call runs on libuv's thread pool (see {@link RustEngine}), so a batch no
24
+ * longer stalls the event loop; the bound now keeps one promise's lifetime — and the file text it
25
+ * holds in flight — reasonable, instead of capping a blocking call.
26
26
  */
27
27
  export const RUST_BATCH_SIZE = 200;
28
28
  const require = createRequire(import.meta.url);
29
+ /**
30
+ * Where the native package for this platform lives, when npm installed one.
31
+ *
32
+ * The addon ships as one optional dependency per platform (`optionalDependencies` in this package's
33
+ * manifest), so a `pnpm add` pulls exactly one binary — the one the running machine can `dlopen` —
34
+ * instead of every platform's. npm's `os`/`cpu` fields do the selection; a platform this project does
35
+ * not build for simply never installs one and the walk falls back to JS.
36
+ * @param platform - `process.platform`.
37
+ * @param arch - `process.arch`.
38
+ * @returns the package name, or `undefined` for a platform with no published build.
39
+ */
40
+ export function nativePackageName(platform, arch) {
41
+ const suffix = NATIVE_PACKAGES[`${platform}-${arch}`];
42
+ return suffix === undefined ? undefined : `dsh-plugin-codegraph-indexer-${suffix}`;
43
+ }
44
+ /**
45
+ * The suffix each published platform package carries, keyed by `platform-arch`.
46
+ *
47
+ * A suffix is not mechanical: the Linux builds are `-gnu` because a musl one would have to be a
48
+ * separate artifact, while Windows spells its ABI out. Keeping the table explicit is what stops this
49
+ * from silently computing a package name nobody publishes.
50
+ */
51
+ const NATIVE_PACKAGES = {
52
+ 'darwin-arm64': 'darwin-arm64',
53
+ 'darwin-x64': 'darwin-x64',
54
+ 'linux-x64': 'linux-x64-gnu',
55
+ 'linux-arm64': 'linux-arm64-gnu',
56
+ 'win32-x64': 'win32-x64-msvc',
57
+ };
58
+ /**
59
+ * Resolve this platform's optional native package to its `.node` entry, if it is installed.
60
+ *
61
+ * `require.resolve` is the only thing that knows where a dependency actually landed — pnpm's store,
62
+ * npm's flat tree, a hoisted profile — so the path is asked for rather than constructed. A missing
63
+ * package, or one whose `main` does not resolve, is `undefined`: that is the documented "no addon"
64
+ * answer, not an error.
65
+ * @param platform - `process.platform`.
66
+ * @param arch - `process.arch`.
67
+ * @param resolve - module resolution, injectable so a test does not depend on what this machine has
68
+ * installed.
69
+ * @returns the absolute path to the addon, or `undefined`.
70
+ */
71
+ export function nativePackageModulePath(platform, arch, resolve = require.resolve) {
72
+ const name = nativePackageName(platform, arch);
73
+ if (name === undefined)
74
+ return undefined;
75
+ try {
76
+ return resolve(name);
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
29
82
  /**
30
83
  * Every place the addon may live, most specific first.
31
84
  *
32
- * Two layouts matter: a package that ships the binary puts it in `artifacts/` beside `lib/` (the
33
- * published shape, and what a profile install gets when someone copies it in), while a repository
34
- * checkout builds it under `crates/indexer-rust/artifacts/`, three directories up from both `src/`
35
- * and `lib/` since those sit at the same depth. Trying both means one module works whether it was
36
- * installed or checked out.
85
+ * Three layouts matter: a package that ships the binary puts it in `artifacts/` beside `lib/` (the
86
+ * shape an older hand-copied install has), a repository checkout builds it under
87
+ * `crates/indexer-rust/artifacts/` (three directories up from both `src/` and `lib/`, which sit at the
88
+ * same depth), and an npm install carries it in the platform's optional dependency. Trying all three
89
+ * means one module works whether it was installed, copied in, or checked out.
37
90
  * @param base - the importing module's URL; parameterized so a test can point at a scratch tree.
91
+ * @param fromPackage - this platform's optional-dependency path, or `undefined` when there is none.
38
92
  * @returns absolute paths to try, in order.
39
93
  */
40
- export function candidateModulePaths(base = import.meta.url) {
94
+ export function modulePathsFor(base, fromPackage) {
41
95
  const artifact = `codegraph-indexer.${process.platform}-${process.arch}.node`;
42
96
  return [
43
97
  fileURLToPath(new URL(`../artifacts/${artifact}`, base)),
44
98
  fileURLToPath(new URL(`../../../crates/indexer-rust/artifacts/${artifact}`, base)),
99
+ ...fromPackage === undefined ? [] : [fromPackage],
45
100
  ];
46
101
  }
102
+ /**
103
+ * The paths production should try, with this platform's optional package worked out for it.
104
+ * @param base - the importing module's URL; parameterized so a test can point at a scratch tree.
105
+ * @returns absolute paths to try, in order.
106
+ */
107
+ export function candidateModulePaths(base = import.meta.url) {
108
+ return modulePathsFor(base, nativePackageModulePath(process.platform, process.arch));
109
+ }
47
110
  /** Addons already loaded, keyed by path, so a test's stand-in never shadows the real artifact. */
48
111
  const loaded = new Map();
49
112
  /**
@@ -149,7 +212,7 @@ function rustRulesJson() {
149
212
  * @returns one extraction per path the addon answered for, or `undefined` when no answer can be
150
213
  * trusted at all — no addon, a thrown call, or JSON that is not the documented shape.
151
214
  */
152
- export function extractWithRust(files, engine) {
215
+ export async function extractWithRust(files, engine) {
153
216
  const supported = files.filter(file => RUST_LANGUAGES.includes(file.language));
154
217
  if (supported.length === 0)
155
218
  return new Map();
@@ -159,7 +222,10 @@ export function extractWithRust(files, engine) {
159
222
  try {
160
223
  for (let start = 0; start < supported.length; start += RUST_BATCH_SIZE) {
161
224
  const batch = supported.slice(start, start + RUST_BATCH_SIZE);
162
- const answer = JSON.parse(engine.extractWorkspace(batch, rustRulesJson()));
225
+ // Awaited one batch at a time: the addon already parses a batch across every core, so
226
+ // overlapping batches would only compete for the same threads while multiplying the text held
227
+ // in flight.
228
+ const answer = JSON.parse(await engine.extractWorkspace(batch, rustRulesJson()));
163
229
  if (!isRecord(answer) || !Array.isArray(answer.results))
164
230
  return undefined;
165
231
  for (const entry of answer.results) {
package/lib/walk.js CHANGED
@@ -223,7 +223,7 @@ async function extractPending(pending, config, signal) {
223
223
  // those itself (`RUST_LANGUAGES`), so the walk never keeps a second copy of that list in sync.
224
224
  const rusted = engine === undefined
225
225
  ? new Map()
226
- : extractWithRust(pending.map(toRustInput), engine) ?? new Map();
226
+ : await extractWithRust(pending.map(toRustInput), engine) ?? new Map();
227
227
  const extracted = new Array(pending.length);
228
228
  const fallback = [];
229
229
  for (const [index, file] of pending.entries()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-codegraph-tree-sitter",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
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,13 +51,20 @@
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.2"
54
+ "dsh-plugin-codegraph-service": "^0.6.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@deepseek-ai/cordis": ">=4.0.1",
58
58
  "@deepseek-ai/dsh-fs": ">=0.1.0-rc.6",
59
59
  "@deepseek-ai/dsh-invariants": ">=0.1.0-rc.6"
60
60
  },
61
+ "optionalDependencies": {
62
+ "dsh-plugin-codegraph-indexer-darwin-arm64": "0.6.0",
63
+ "dsh-plugin-codegraph-indexer-darwin-x64": "0.6.0",
64
+ "dsh-plugin-codegraph-indexer-linux-x64-gnu": "0.6.0",
65
+ "dsh-plugin-codegraph-indexer-linux-arm64-gnu": "0.6.0",
66
+ "dsh-plugin-codegraph-indexer-win32-x64-msvc": "0.6.0"
67
+ },
61
68
  "devDependencies": {
62
69
  "@deepseek-ai/cordis": "^4.0.1",
63
70
  "@deepseek-ai/dsh-invariants": "0.1.0-rc.6"