dsh-plugin-codegraph-tree-sitter 0.4.2 → 0.5.1

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
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import type { Context } from '@deepseek-ai/cordis';
31
31
  import z from '@deepseek-ai/schemastery';
32
+ import type { EngineMode } from './walk.ts';
32
33
  export { LANGUAGE_TABLE, languageFor } from './languages.ts';
33
34
  export type { DefinitionRule, ImportRule, LanguageSpec } from './languages.ts';
34
35
  export { extractFile } from './extract.ts';
@@ -43,7 +44,9 @@ export { SCHEMA_VERSION, writeGraph } from './schema.ts';
43
44
  export { EXTRACTION_CACHE_RELATIVE_PATH, EXTRACTION_CACHE_VERSION, readParseCache, writeParseCache } from './cache.ts';
44
45
  export type { CachedFile, CacheTarget, CacheTargetInfo, ParseCacheFileSystem } from './cache.ts';
45
46
  export { isExcluded, walkAndExtract } from './walk.ts';
46
- export type { WalkConfig, WalkResult } from './walk.ts';
47
+ export type { EngineMode, WalkConfig, WalkResult } from './walk.ts';
48
+ export { extractWithRust, loadRustEngine, RUST_BATCH_SIZE, RUST_LANGUAGES } from './rust-engine.ts';
49
+ export type { LoadRustEngineOptions, RustEngine, RustExtractInput } from './rust-engine.ts';
47
50
  export { createWatcher } from './watcher.ts';
48
51
  export type { DegradeReason, WatchConfig, WatchDirEntry, WatchFileSystem, WatchTarget, Watcher, } from './watcher.ts';
49
52
  export { decideWatch, readProcVersion } from './watch-policy.ts';
@@ -76,6 +79,8 @@ export declare const DEFAULT_CONCURRENCY = 4;
76
79
  export declare const DEFAULT_WATCH_DEBOUNCE_MS = 2000;
77
80
  /** Default cap on directories watched individually, one watch each, by the fs seam. */
78
81
  export declare const DEFAULT_MAX_WATCHED_DIRECTORIES = 4000;
82
+ /** Default age past which a session-open refreshes an existing index (ten minutes). */
83
+ export declare const DEFAULT_AUTO_REFRESH_AFTER_MS: number;
79
84
  /** Plugin configuration: the indexer's identity and every deployment-varying bound on a run. */
80
85
  export interface Config {
81
86
  /**
@@ -130,6 +135,17 @@ export interface Config {
130
135
  * parse and the tree walk, which dominate the per-file cost.
131
136
  */
132
137
  cacheVerify?: 'stat' | 'hash';
138
+ /**
139
+ * Which extractor parses a file (default `'auto'`): this package's own `extractFile` (`'js'`), the
140
+ * native Rust addon from `crates/indexer-rust` (`'rust'`), or whichever is available (`'auto'`).
141
+ *
142
+ * `'rust'` and `'auto'` hand every TypeScript/JavaScript/JSX/TSX file to the addon in batches and
143
+ * fall back to `extractFile` for anything it does not answer for — a language it does not handle, a
144
+ * file it declines, or a deployment with no built artifact at all. Both paths produce the same
145
+ * extraction, pinned by `rust-parity.spec.ts`, so this setting only trades speed against how the
146
+ * work is scheduled. `'js'` is exactly the behaviour this package had before the addon existed.
147
+ */
148
+ engine?: EngineMode;
133
149
  /**
134
150
  * Watch the workspace for file changes and refresh the index automatically after a successful
135
151
  * `index()` call establishes a baseline (default true). Set `watch: false` to keep indexing purely
@@ -160,6 +176,13 @@ export interface Config {
160
176
  * so a composition without background jobs simply schedules nothing.
161
177
  */
162
178
  autoIndexOnSession?: boolean;
179
+ /**
180
+ * How stale an existing index may be before a new session refreshes it in the background (default
181
+ * 600000, ten minutes). An index older than this is rebuilt on session start using the parse cache,
182
+ * which is milliseconds — and rebuilding is also what restarts the file watcher, since watching
183
+ * only ever begins after a successful index.
184
+ */
185
+ autoRefreshAfterMs?: number;
163
186
  }
164
187
  export declare const Config: z<Config>;
165
188
  /**
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 { existsSync } from 'node:fs';
30
+ import { readFileSync } from 'node:fs';
31
31
  import { stat } from 'node:fs/promises';
32
32
  import { join } from 'node:path';
33
33
  import z from '@deepseek-ai/schemastery';
@@ -47,6 +47,10 @@ export { isMinifiedName, looksMinified, measureText, MINIFIED_AVERAGE_LINE_LENGT
47
47
  export { SCHEMA_VERSION, writeGraph } from "./schema.js";
48
48
  export { EXTRACTION_CACHE_RELATIVE_PATH, EXTRACTION_CACHE_VERSION, readParseCache, writeParseCache } from "./cache.js";
49
49
  export { isExcluded, walkAndExtract } from "./walk.js";
50
+ // The native engine is part of this package's story even though it is optional at runtime: a
51
+ // deployment that wants to know *why* the faster path is not running can call `loadRustEngine()`
52
+ // itself and read the answer, instead of inferring it from timings.
53
+ export { extractWithRust, loadRustEngine, RUST_BATCH_SIZE, RUST_LANGUAGES } from "./rust-engine.js";
50
54
  export { createWatcher } from "./watcher.js";
51
55
  export { decideWatch, readProcVersion } from "./watch-policy.js";
52
56
  // Git diagnostics stay library functions rather than plugin wiring: nothing in `apply` runs git, so
@@ -79,6 +83,8 @@ export const DEFAULT_CONCURRENCY = 4;
79
83
  export const DEFAULT_WATCH_DEBOUNCE_MS = 2_000;
80
84
  /** Default cap on directories watched individually, one watch each, by the fs seam. */
81
85
  export const DEFAULT_MAX_WATCHED_DIRECTORIES = 4_000;
86
+ /** Default age past which a session-open refreshes an existing index (ten minutes). */
87
+ export const DEFAULT_AUTO_REFRESH_AFTER_MS = 10 * 60 * 1000;
82
88
  export const Config = z.object({
83
89
  indexerId: z.string().default(DEFAULT_INDEXER_ID),
84
90
  languages: z.array(z.string()).default([...new Set(LANGUAGE_TABLE.map(spec => spec.language))]),
@@ -90,10 +96,12 @@ export const Config = z.object({
90
96
  concurrency: z.number().default(DEFAULT_CONCURRENCY),
91
97
  reuseParsedCache: z.boolean().default(true),
92
98
  cacheVerify: z.union([z.const('stat'), z.const('hash')]).default('stat'),
99
+ engine: z.union([z.const('auto'), z.const('js'), z.const('rust')]).default('auto'),
93
100
  watch: z.boolean().default(true),
94
101
  watchDebounceMs: z.number().default(DEFAULT_WATCH_DEBOUNCE_MS),
95
102
  maxWatchedDirectories: z.number().default(DEFAULT_MAX_WATCHED_DIRECTORIES),
96
103
  autoIndexOnSession: z.boolean().default(true),
104
+ autoRefreshAfterMs: z.number().default(DEFAULT_AUTO_REFRESH_AFTER_MS),
97
105
  });
98
106
  /**
99
107
  * Register the tree-sitter indexer.
@@ -119,10 +127,11 @@ export function apply(ctx, config) {
119
127
  // cross-plugin relation here for an invariant to describe — just process-local bookkeeping this
120
128
  // effect's cleanup below tears down on unload.
121
129
  const watchers = new Map();
122
- // Roots this plugin instance has already started an automatic index for. The set is only a guard
123
- // against a burst of agents opened on one workspace each scheduling the same rebuild; what a
124
- // previous *process* indexed is recorded by the status sidecar `autoIndexOnSession` reads instead.
125
- const autoIndexed = new Set();
130
+ // When this instance last started an automatic index, keyed by root. This is not a "already done"
131
+ // set: an index goes stale while the process runs, so a later session in the same workspace may
132
+ // legitimately trigger another refresh. The map only keeps a burst of sessions opened at once from
133
+ // queueing the same rebuild repeatedly; the freshness decision itself reads the status sidecar.
134
+ const lastAutoRun = new Map();
126
135
  // `agent/created` is typed by `@deepseek-ai/dsh-agent`'s module augmentation. Reading the event
127
136
  // through a local structural face keeps this package's host dependencies unchanged: it needs one
128
137
  // event name and one payload shape, not the whole agent package.
@@ -187,7 +196,7 @@ export function apply(ctx, config) {
187
196
  if (resolved.autoIndexOnSession) {
188
197
  // Registered inside this effect so the listener follows the plugin's lifetime: unloading the
189
198
  // plugin must stop new sessions from scheduling work this instance can no longer run.
190
- yield agentEvents.on('agent/created', payload => autoIndexOnSession(ctx, autoIndexed, payload));
199
+ yield agentEvents.on('agent/created', payload => autoIndexOnSession(ctx, resolved, lastAutoRun, payload));
191
200
  }
192
201
  yield ctx.codegraph.registerIndexer(indexer);
193
202
  }, 'codegraph-tree-sitter');
@@ -195,30 +204,39 @@ export function apply(ctx, config) {
195
204
  /** Stable job kind of an automatic session-start index, used for retention and diagnostics. */
196
205
  const AUTO_INDEX_JOB_KIND = 'codegraph-index';
197
206
  /**
198
- * Index a new session's workspace in the background, when nothing has indexed it yet.
207
+ * Index a new session's workspace in the background — when nothing has indexed it yet, or when what
208
+ * is there has gone stale.
199
209
  *
200
210
  * Runs synchronously inside `agent/created` dispatch, so it must never block or throw: it only decides
201
211
  * whether a build is warranted and hands one to the host's job registry. A synchronous build here
202
212
  * would stall agent creation for the length of a parse, which is why a composition without background
203
213
  * jobs does nothing instead.
214
+ *
215
+ * Refreshing a stale index is what restarts the file watcher after a host restart: watching only
216
+ * begins once a successful index has established a baseline, so a workspace whose index is minutes
217
+ * old would otherwise stay frozen at whatever the last process left behind.
204
218
  * @param ctx - the plugin context, consulted for the optional job registry.
205
- * @param autoIndexed - roots this process has already started an automatic index for.
219
+ * @param config - the resolved plugin configuration, for the staleness threshold.
220
+ * @param lastAutoRun - when this instance last started an automatic index for each root.
206
221
  * @param payload - the `agent/created` dispatch payload.
207
222
  */
208
- function autoIndexOnSession(ctx, autoIndexed, payload) {
223
+ function autoIndexOnSession(ctx, config, lastAutoRun, payload) {
209
224
  try {
210
225
  const agent = payload.agent;
211
226
  const root = agent?.session?.header?.cwd;
212
227
  // No workspace means no root to claim: an agent created without a `cwd` has nothing to index.
213
228
  if (agent === undefined || !root)
214
229
  return;
215
- // Once per root per process. A second agent opened on the same workspace must not queue a second
216
- // full rebuild of a graph the first one is already building.
217
- if (autoIndexed.has(root))
230
+ const now = Date.now();
231
+ // A burst of sessions opened on one workspace must not queue the same rebuild repeatedly.
232
+ const previous = lastAutoRun.get(root);
233
+ if (previous !== undefined && now - previous < config.autoRefreshAfterMs)
218
234
  return;
219
- // An existing status sidecar means a previous run already indexed this workspace. Checked before
220
- // the job lookup so a composition without background jobs never pays for a filesystem probe.
221
- if (existsSync(join(root, INDEX_STATUS_RELATIVE_PATH)))
235
+ // A fresh sidecar means a recent run is already describing this workspace; an absent, unreadable
236
+ // or old one means the graph is missing or behind. Checked before the job lookup so a composition
237
+ // without background jobs never pays for the probe.
238
+ const indexedAt = readStatusIndexedAt(root);
239
+ if (indexedAt !== undefined && now - indexedAt < config.autoRefreshAfterMs)
222
240
  return;
223
241
  // `ctx.get` rather than an `inject` entry: `jobs` is a host composition choice, and requiring it in
224
242
  // `inject` would hide this whole indexer from a profile that runs no background jobs.
@@ -234,7 +252,7 @@ function autoIndexOnSession(ctx, autoIndexed, payload) {
234
252
  });
235
253
  // Marked only after the registry accepted the job, so a transient refusal leaves the workspace
236
254
  // eligible for the next session that opens in it.
237
- autoIndexed.add(root);
255
+ lastAutoRun.set(root, now);
238
256
  }
239
257
  catch (error) {
240
258
  // The host treats a throwing listener as vetoing publication. Losing an index is a degradation,
@@ -242,6 +260,24 @@ function autoIndexOnSession(ctx, autoIndexed, payload) {
242
260
  console.error(`codegraph-tree-sitter: automatic indexing for a new session failed: ${String(error)}`);
243
261
  }
244
262
  }
263
+ /**
264
+ * When the workspace's sidecar says its index was built.
265
+ * @param root - the workspace root.
266
+ * @returns the epoch-millisecond timestamp, or `undefined` when the sidecar is absent or unreadable —
267
+ * either way the graph cannot be trusted to be current.
268
+ */
269
+ function readStatusIndexedAt(root) {
270
+ try {
271
+ const parsed = JSON.parse(readFileSync(join(root, INDEX_STATUS_RELATIVE_PATH), 'utf8'));
272
+ if (typeof parsed !== 'object' || parsed === null)
273
+ return undefined;
274
+ const indexedAt = parsed.indexedAt;
275
+ return typeof indexedAt === 'number' && Number.isFinite(indexedAt) ? indexedAt : undefined;
276
+ }
277
+ catch {
278
+ return undefined;
279
+ }
280
+ }
245
281
  /**
246
282
  * The producer body of one automatic indexing job.
247
283
  *
@@ -302,6 +338,7 @@ async function runIndex(projectRoot, config, fs, signal) {
302
338
  languages: config.languages,
303
339
  reuseParsedCache: config.reuseParsedCache,
304
340
  cacheVerify: config.cacheVerify,
341
+ engine: config.engine,
305
342
  }, fs, signal);
306
343
  signal?.throwIfAborted();
307
344
  const indexedAt = Date.now();
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The optional Rust extraction engine: a native addon built from `crates/indexer-rust`, used in place
3
+ * of this package's own `extractFile` for the four ECMAScript-family grammars.
4
+ *
5
+ * Everything here is optional by construction. The addon is a build artifact — this package's `files`
6
+ * list does not ship it, and a deployment without a Rust toolchain has no reason to produce one — so a
7
+ * missing artifact, a stale artifact, a thrown call, or an unparsable answer all degrade to "no Rust
8
+ * extraction". The walk then parses those files with `extractFile`, exactly as it would without this
9
+ * module; a slower index is acceptable, a failed one is not.
10
+ *
11
+ * The addon never touches the filesystem: it takes every file's text as an input and answers with
12
+ * JSON. That keeps the seam honest — filesystem policy (mtime, minified skips, cache reuse) stays in
13
+ * `walk.ts`, and this module only turns text into {@link FileExtraction}s.
14
+ * @module dsh-plugin-codegraph-tree-sitter/rust-engine
15
+ */
16
+ import type { FileExtraction } from './extract.ts';
17
+ /** Seam language labels the addon extracts. Every other language is left to `extractFile`. */
18
+ export declare const RUST_LANGUAGES: readonly string[];
19
+ /**
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.
24
+ */
25
+ export declare const RUST_BATCH_SIZE = 200;
26
+ /** One file as the addon takes it. */
27
+ export interface RustExtractInput {
28
+ /** Workspace-relative path, echoed back verbatim in the answer. */
29
+ readonly path: string;
30
+ /** Seam language label — one of {@link RUST_LANGUAGES} to be extracted. */
31
+ readonly language: string;
32
+ /** The file's full text. */
33
+ readonly text: string;
34
+ }
35
+ /**
36
+ * The addon's one exported entry point, as this package uses it. The real artifact also exports
37
+ * `__napiBindingTarget`, which nothing here reads.
38
+ */
39
+ export interface RustEngine {
40
+ /**
41
+ * Extract definitions, calls, imports, and heritage for a batch of files.
42
+ * @param inputs - the files to extract; inputs in an unsupported language are silently skipped.
43
+ * @param rulesJson - the `LanguageSpec` table, as JSON.
44
+ * @returns JSON of `{ results: [{ path, extraction }], supported: string[] }`.
45
+ */
46
+ extractWorkspace(inputs: RustExtractInput[], rulesJson: string): string;
47
+ }
48
+ /** Options for {@link loadRustEngine}. */
49
+ export interface LoadRustEngineOptions {
50
+ /**
51
+ * Load the addon from this path instead of the default artifact under `crates/indexer-rust`. Tests
52
+ * point it at a stand-in module or at a path that does not exist; production never sets it.
53
+ */
54
+ readonly modulePath?: string;
55
+ }
56
+ /**
57
+ * Every place the addon may live, most specific first.
58
+ *
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.
64
+ * @param base - the importing module's URL; parameterized so a test can point at a scratch tree.
65
+ * @returns absolute paths to try, in order.
66
+ */
67
+ export declare function candidateModulePaths(base?: string): string[];
68
+ /**
69
+ * Load the native addon, or report that it is unavailable.
70
+ *
71
+ * Never throws and never repeats a failure: the first failure is logged once (see
72
+ * {@link reportUnavailable}), later calls answer `undefined` without touching the filesystem again.
73
+ * @param options - see {@link LoadRustEngineOptions}; production passes none.
74
+ * @returns the loaded addon, or `undefined` when this deployment has no usable one.
75
+ */
76
+ export declare function loadRustEngine(options?: LoadRustEngineOptions): RustEngine | undefined;
77
+ /**
78
+ * Extract a batch of files with the addon, in at most {@link RUST_BATCH_SIZE} files per call.
79
+ *
80
+ * A file the addon did not answer for is simply absent from the returned map: an unsupported
81
+ * language, a file it declines (a root parse error, whose extraction it will not vouch for), or one
82
+ * lost to a batch whose call failed. That is the contract the caller relies on — **per-path** absence
83
+ * means "extract this one with `extractFile`", not "discard the batch".
84
+ * `engine` is required rather than defaulted to {@link loadRustEngine}: `undefined` has to mean "no
85
+ * addon" here, and a default parameter would silently load one instead of accepting that answer — the
86
+ * caller that already resolved the engine (`walk.ts`) is the one that should decide.
87
+ * @param files - the files to extract, in any language; unsupported ones are dropped here.
88
+ * @param engine - the loaded addon, or `undefined` when this deployment has none.
89
+ * @returns one extraction per path the addon answered for, or `undefined` when no answer can be
90
+ * trusted at all — no addon, a thrown call, or JSON that is not the documented shape.
91
+ */
92
+ export declare function extractWithRust(files: readonly RustExtractInput[], engine: RustEngine | undefined): Map<string, FileExtraction> | undefined;
93
+ /**
94
+ * Install a stand-in for the addon the walk resolves, or restore the real one.
95
+ *
96
+ * This exists for tests, and only for tests: the per-path fallback contract above is a promise about
97
+ * what happens when the addon *declines a file*, which the real artifact only does for its own
98
+ * reasons — a workspace with a syntactically broken file, at the time of writing. A test that needs
99
+ * that answer deterministically installs one here instead of depending on the artifact's current
100
+ * behaviour. Not exported from the package entry point; production code has no reason to call it.
101
+ * @param engine - the double to install, or `undefined` to let the next call load the real artifact.
102
+ */
103
+ export declare function installRustEngineForTests(engine: RustEngine | undefined): void;
104
+ //# sourceMappingURL=rust-engine.d.ts.map
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The optional Rust extraction engine: a native addon built from `crates/indexer-rust`, used in place
3
+ * of this package's own `extractFile` for the four ECMAScript-family grammars.
4
+ *
5
+ * Everything here is optional by construction. The addon is a build artifact — this package's `files`
6
+ * list does not ship it, and a deployment without a Rust toolchain has no reason to produce one — so a
7
+ * missing artifact, a stale artifact, a thrown call, or an unparsable answer all degrade to "no Rust
8
+ * extraction". The walk then parses those files with `extractFile`, exactly as it would without this
9
+ * module; a slower index is acceptable, a failed one is not.
10
+ *
11
+ * The addon never touches the filesystem: it takes every file's text as an input and answers with
12
+ * JSON. That keeps the seam honest — filesystem policy (mtime, minified skips, cache reuse) stays in
13
+ * `walk.ts`, and this module only turns text into {@link FileExtraction}s.
14
+ * @module dsh-plugin-codegraph-tree-sitter/rust-engine
15
+ */
16
+ import { createRequire } from 'node:module';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { LANGUAGE_TABLE } from "./languages.js";
19
+ /** Seam language labels the addon extracts. Every other language is left to `extractFile`. */
20
+ export const RUST_LANGUAGES = ['typescript', 'tsx', 'javascript', 'jsx'];
21
+ /**
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.
26
+ */
27
+ export const RUST_BATCH_SIZE = 200;
28
+ const require = createRequire(import.meta.url);
29
+ /**
30
+ * Every place the addon may live, most specific first.
31
+ *
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.
37
+ * @param base - the importing module's URL; parameterized so a test can point at a scratch tree.
38
+ * @returns absolute paths to try, in order.
39
+ */
40
+ export function candidateModulePaths(base = import.meta.url) {
41
+ const artifact = `codegraph-indexer.${process.platform}-${process.arch}.node`;
42
+ return [
43
+ fileURLToPath(new URL(`../artifacts/${artifact}`, base)),
44
+ fileURLToPath(new URL(`../../../crates/indexer-rust/artifacts/${artifact}`, base)),
45
+ ];
46
+ }
47
+ /** Addons already loaded, keyed by path, so a test's stand-in never shadows the real artifact. */
48
+ const loaded = new Map();
49
+ /**
50
+ * Paths whose load already failed. Kept so a workspace without the artifact pays one failed `require`
51
+ * for the process rather than one per walk.
52
+ */
53
+ const failed = new Set();
54
+ /** Whether this process has already said why the addon is unusable — see {@link reportUnavailable}. */
55
+ let reportedFailure = false;
56
+ /**
57
+ * Say once, per process, why the addon could not be used. Never once per file and never once per
58
+ * walk: a missing optional artifact must not turn into a console flood, but it must not be silent
59
+ * either — otherwise a deployment that expected the faster engine has no way to notice it is not
60
+ * running.
61
+ * @param reason - what went wrong, in one phrase the reader can act on.
62
+ */
63
+ function reportUnavailable(reason) {
64
+ if (reportedFailure)
65
+ return;
66
+ reportedFailure = true;
67
+ console.error(`codegraph-tree-sitter: Rust extraction unavailable (${reason}); falling back to the JavaScript extractor`);
68
+ }
69
+ /** Whether `value` is a plain object, which is all the JSON and module shapes checked here ever are. */
70
+ function isRecord(value) {
71
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
72
+ }
73
+ /**
74
+ * Whether a required module is the addon this package expects. A stale or mismatched artifact is a
75
+ * load failure like any other: it must fall back rather than throw on the first batch call.
76
+ * @param value - whatever `require` returned for the artifact path.
77
+ */
78
+ function isRustEngine(value) {
79
+ return isRecord(value) && typeof value.extractWorkspace === 'function';
80
+ }
81
+ /**
82
+ * Load the native addon, or report that it is unavailable.
83
+ *
84
+ * Never throws and never repeats a failure: the first failure is logged once (see
85
+ * {@link reportUnavailable}), later calls answer `undefined` without touching the filesystem again.
86
+ * @param options - see {@link LoadRustEngineOptions}; production passes none.
87
+ * @returns the loaded addon, or `undefined` when this deployment has no usable one.
88
+ */
89
+ export function loadRustEngine(options) {
90
+ const candidates = options?.modulePath === undefined ? candidateModulePaths() : [options.modulePath];
91
+ // Local, not module-level: a second call whose candidates are all already cached as failed must not
92
+ // resurrect the previous call's reason and try to report it again.
93
+ let lastFailure;
94
+ // Both caches are consulted before any `require`: a successful load is the process's engine, and a
95
+ // failed path is not worth retrying on the next walk.
96
+ for (const modulePath of candidates) {
97
+ const cached = loaded.get(modulePath);
98
+ if (cached !== undefined)
99
+ return cached;
100
+ if (failed.has(modulePath))
101
+ continue;
102
+ let addon;
103
+ try {
104
+ addon = require(modulePath);
105
+ }
106
+ catch (error) {
107
+ failed.add(modulePath);
108
+ lastFailure = `${modulePath}: ${String(error)}`;
109
+ continue;
110
+ }
111
+ if (!isRustEngine(addon)) {
112
+ failed.add(modulePath);
113
+ lastFailure = `${modulePath}: no extractWorkspace export`;
114
+ continue;
115
+ }
116
+ loaded.set(modulePath, addon);
117
+ return addon;
118
+ }
119
+ // Reported only once every candidate has been tried, so an installed package does not complain
120
+ // about the checkout path it was never going to have.
121
+ if (lastFailure !== undefined)
122
+ reportUnavailable(lastFailure);
123
+ return undefined;
124
+ }
125
+ /** The rules the addon extracts by, as JSON — built once from {@link LANGUAGE_TABLE}, then reused. */
126
+ let rulesJson;
127
+ /**
128
+ * The addon's rule table: this package's own `LANGUAGE_TABLE` entries for the languages the addon
129
+ * handles, so the two extractors can never disagree about what a rule matches. The table stays the
130
+ * single source of truth — the crate keeps no copy of it.
131
+ * @returns the `LanguageSpec[]` the addon parses, JSON-encoded.
132
+ */
133
+ function rustRulesJson() {
134
+ rulesJson ??= JSON.stringify(LANGUAGE_TABLE.filter(spec => RUST_LANGUAGES.includes(spec.language)));
135
+ return rulesJson;
136
+ }
137
+ /**
138
+ * Extract a batch of files with the addon, in at most {@link RUST_BATCH_SIZE} files per call.
139
+ *
140
+ * A file the addon did not answer for is simply absent from the returned map: an unsupported
141
+ * language, a file it declines (a root parse error, whose extraction it will not vouch for), or one
142
+ * lost to a batch whose call failed. That is the contract the caller relies on — **per-path** absence
143
+ * means "extract this one with `extractFile`", not "discard the batch".
144
+ * `engine` is required rather than defaulted to {@link loadRustEngine}: `undefined` has to mean "no
145
+ * addon" here, and a default parameter would silently load one instead of accepting that answer — the
146
+ * caller that already resolved the engine (`walk.ts`) is the one that should decide.
147
+ * @param files - the files to extract, in any language; unsupported ones are dropped here.
148
+ * @param engine - the loaded addon, or `undefined` when this deployment has none.
149
+ * @returns one extraction per path the addon answered for, or `undefined` when no answer can be
150
+ * trusted at all — no addon, a thrown call, or JSON that is not the documented shape.
151
+ */
152
+ export function extractWithRust(files, engine) {
153
+ const supported = files.filter(file => RUST_LANGUAGES.includes(file.language));
154
+ if (supported.length === 0)
155
+ return new Map();
156
+ if (engine === undefined)
157
+ return undefined;
158
+ const extractions = new Map();
159
+ try {
160
+ for (let start = 0; start < supported.length; start += RUST_BATCH_SIZE) {
161
+ const batch = supported.slice(start, start + RUST_BATCH_SIZE);
162
+ const answer = JSON.parse(engine.extractWorkspace(batch, rustRulesJson()));
163
+ if (!isRecord(answer) || !Array.isArray(answer.results))
164
+ return undefined;
165
+ for (const entry of answer.results) {
166
+ // A malformed entry is treated as a path the addon did not answer for: the caller falls back
167
+ // for it, and a bad answer for one file never costs the rest of the batch its extraction.
168
+ if (!isRecord(entry) || typeof entry.path !== 'string' || !isRecord(entry.extraction))
169
+ continue;
170
+ // The wire shape is `FileExtraction` field for field — camelCase `Raw*` fields, arrays of the
171
+ // same objects — which `rust-parity.spec.ts` pins with `toStrictEqual` against `extractFile`.
172
+ extractions.set(entry.path, entry.extraction);
173
+ }
174
+ }
175
+ }
176
+ catch (error) {
177
+ reportUnavailable(`extractWorkspace failed: ${String(error)}`);
178
+ return undefined;
179
+ }
180
+ return extractions;
181
+ }
182
+ /**
183
+ * Install a stand-in for the addon the walk resolves, or restore the real one.
184
+ *
185
+ * This exists for tests, and only for tests: the per-path fallback contract above is a promise about
186
+ * what happens when the addon *declines a file*, which the real artifact only does for its own
187
+ * reasons — a workspace with a syntactically broken file, at the time of writing. A test that needs
188
+ * that answer deterministically installs one here instead of depending on the artifact's current
189
+ * behaviour. Not exported from the package entry point; production code has no reason to call it.
190
+ * @param engine - the double to install, or `undefined` to let the next call load the real artifact.
191
+ */
192
+ export function installRustEngineForTests(engine) {
193
+ // Every candidate, so a stand-in covers whichever layout this checkout resolves to.
194
+ for (const modulePath of candidateModulePaths()) {
195
+ if (engine === undefined)
196
+ loaded.delete(modulePath);
197
+ else
198
+ loaded.set(modulePath, engine);
199
+ }
200
+ }
201
+ //# sourceMappingURL=rust-engine.js.map
package/lib/walk.d.ts CHANGED
@@ -22,6 +22,16 @@ import type { ExtractedFile } from './resolve.ts';
22
22
  * @param gitignoreRules - the project's parsed `.gitignore`, or empty when not respected.
23
23
  */
24
24
  export declare function isExcluded(name: string, relativePath: string, isDirectory: boolean, excluded: ReadonlySet<string>, gitignoreRules: readonly GitignoreRule[]): boolean;
25
+ /**
26
+ * Which extractor parses a candidate file.
27
+ *
28
+ * `'js'` is this package's own `extractFile` for everything — the pre-engine behaviour, kept as an
29
+ * exact escape hatch. `'rust'` prefers the native addon. `'auto'` is `'rust'` when the artifact loads
30
+ * and `'js'` otherwise, which is what a deployment wants: the faster path when it exists, never a
31
+ * failure when it does not. In both non-`'js'` modes a language the addon does not handle — and any
32
+ * single file it declines — is still parsed by `extractFile`.
33
+ */
34
+ export type EngineMode = 'auto' | 'js' | 'rust';
25
35
  /** The walk's tunable bounds, mirroring this package's `Config`. */
26
36
  export interface WalkConfig {
27
37
  /** Directory segment names never descended into. */
@@ -34,6 +44,13 @@ export interface WalkConfig {
34
44
  readonly concurrency: number;
35
45
  /** Restrict extraction to these seam language labels when set; every loaded grammar otherwise. */
36
46
  readonly languages?: readonly string[];
47
+ /**
48
+ * Which extractor parses a candidate — see {@link EngineMode}. Absent means `'auto'`. The choice
49
+ * never changes the files that are indexed or the answer's shape: both extractors produce the same
50
+ * {@link FileExtraction}, which `rust-parity.spec.ts` pins, so this only trades speed against
51
+ * leaving the process's own parser out of the run.
52
+ */
53
+ readonly engine?: EngineMode;
37
54
  /**
38
55
  * Leave generated (bundled or minified) artifacts out of the index instead of parsing them. See
39
56
  * `./minified.ts` for the two signals; a workspace that commits its build output is the case this
@@ -88,6 +105,11 @@ export interface WalkResult {
88
105
  * is the identity every cache entry is recorded under — and the only seam-mediated work here is the
89
106
  * parse cache's read. A capability is not a bound, so it does not belong in the config that mirrors
90
107
  * this package's `Config`.
108
+ *
109
+ * Every candidate is read and decided first, then the files needing extraction go to the native
110
+ * engine in batches with `extractFile` as the fallback — see {@link WalkConfig.engine}. The two phases
111
+ * exist so one addon call can cover a whole workspace; neither changes what a per-file decision
112
+ * (size, minified, cache) does.
91
113
  * @param projectRoot - absolute path of the workspace to index.
92
114
  * @param config - the walk's bounds.
93
115
  * @param fs - the filesystem seam the parse cache is read through.
package/lib/walk.js CHANGED
@@ -17,6 +17,7 @@ import { extractFile } from "./extract.js";
17
17
  import { loadGitignore, matchesGitignore } from "./gitignore.js";
18
18
  import { languageFor } from "./languages.js";
19
19
  import { isMinifiedName, looksMinified, measureText } from "./minified.js";
20
+ import { extractWithRust, loadRustEngine } from "./rust-engine.js";
20
21
  /**
21
22
  * Whether one directory entry is excluded from both the index and (for `watcher.ts`) the watch tree —
22
23
  * the single definition both consult, so "what the index covers" and "what gets watched" can never
@@ -97,15 +98,22 @@ function reusedFile(path, cached, stats) {
97
98
  };
98
99
  }
99
100
  /**
100
- * Parse one candidate, or reuse the extraction a previous run cached for it.
101
- * @param candidate - the file to parse.
101
+ * Read one candidate's text and apply every decision that does not need an extractor, or reuse the
102
+ * extraction a previous run cached for it.
103
+ *
104
+ * No parsing happens here on purpose: a fresh file is reported as {@link PendingFile} so that
105
+ * `walkAndExtract` can batch all of them into the native engine at once. That split is why the cache
106
+ * checks, the size bound, and the minified decision must stay in this function — they are the per-file
107
+ * policy the batch above them knows nothing about.
108
+ * @param candidate - the file to read.
102
109
  * @param config - the walk's bounds.
103
110
  * @param cache - the previous run's extractions, keyed by project-relative path.
104
- * @param signal - aborts the parse.
105
- * @returns the extracted file (and whether it came from the cache), `'minified'` when it was skipped
106
- * as generated output, or `'skipped'` when it is over {@link WalkConfig.maxFileBytes}.
111
+ * @param signal - aborts the read.
112
+ * @returns the record a previous run cached for this file, `'minified'` when it was skipped as
113
+ * generated output, `'skipped'` when it is over {@link WalkConfig.maxFileBytes}, or the pending file
114
+ * an extractor still has to handle.
107
115
  */
108
- async function parseCandidate(candidate, config, cache, signal) {
116
+ async function prepareCandidate(candidate, config, cache, signal) {
109
117
  // Checked before the stat and read: a `.min.` name settles it with no I/O at all.
110
118
  if (config.skipMinified && isMinifiedName(candidate.path))
111
119
  return { outcome: 'minified' };
@@ -123,7 +131,7 @@ async function parseCandidate(candidate, config, cache, signal) {
123
131
  if (cached !== undefined) {
124
132
  if (config.cacheVerify === 'stat') {
125
133
  if (cached.modifiedAt === stats.mtimeMs && cached.size === stats.size) {
126
- return { outcome: 'extracted', file: reusedFile(candidate.path, cached, stats), reused: true };
134
+ return { outcome: 'cached', file: reusedFile(candidate.path, cached, stats) };
127
135
  }
128
136
  }
129
137
  else {
@@ -131,41 +139,119 @@ async function parseCandidate(candidate, config, cache, signal) {
131
139
  // the parse below would need — a miss here costs nothing extra.
132
140
  text = await readFile(candidate.absolute, 'utf8');
133
141
  if (createHash('sha256').update(text).digest('hex') === cached.contentHash) {
134
- return { outcome: 'extracted', file: reusedFile(candidate.path, cached, stats), reused: true };
142
+ return { outcome: 'cached', file: reusedFile(candidate.path, cached, stats) };
135
143
  }
136
144
  }
137
145
  }
138
146
  text ??= await readFile(candidate.absolute, 'utf8');
147
+ signal?.throwIfAborted();
139
148
  const shape = measureText(text);
140
149
  if (config.skipMinified && looksMinified(candidate.path, shape))
141
150
  return { outcome: 'minified' };
142
- const parser = await createParser(spec);
151
+ // Everything above is filesystem policy; everything below is extraction, and extraction is the one
152
+ // step that can run on the native engine — so this is exactly where the candidate waits for one.
153
+ return {
154
+ outcome: 'pending',
155
+ file: {
156
+ path: candidate.path,
157
+ language: spec.language,
158
+ spec,
159
+ text,
160
+ size: stats.size,
161
+ modifiedAt: stats.mtimeMs,
162
+ lineCount: shape.lineCount,
163
+ },
164
+ };
165
+ }
166
+ /**
167
+ * Parse one pending file with this package's own extractor.
168
+ * @param file - the file, whose text is already in memory.
169
+ * @param signal - aborts the parse.
170
+ * @returns what the parser found.
171
+ */
172
+ async function extractWithJs(file, signal) {
173
+ const parser = await createParser(file.spec);
143
174
  try {
144
175
  signal?.throwIfAborted();
145
- const tree = parser.parse(text);
176
+ const tree = parser.parse(file.text);
146
177
  /* v8 ignore next 2 -- createParser() always assigns a language before returning; parse() returns
147
178
  * null only when the parser has none. */
148
179
  if (tree === null)
149
- return { outcome: 'skipped' };
150
- const extraction = extractFile(tree, spec);
151
- return {
152
- outcome: 'extracted',
153
- reused: false,
154
- file: {
155
- path: candidate.path,
156
- language: spec.language,
157
- size: stats.size,
158
- modifiedAt: stats.mtimeMs,
159
- contentHash: createHash('sha256').update(text).digest('hex'),
160
- lineCount: shape.lineCount,
161
- extraction,
162
- },
163
- };
180
+ return { definitions: [], calls: [], imports: [], heritage: [] };
181
+ return extractFile(tree, file.spec);
164
182
  }
165
183
  finally {
166
184
  parser.delete();
167
185
  }
168
186
  }
187
+ /**
188
+ * The file record an extraction belongs to: the metadata `prepareCandidate` measured, plus a content
189
+ * hash of the text it read.
190
+ * @param file - the pending file this extraction came from.
191
+ * @param extraction - what an extractor found in it.
192
+ * @returns the file as the resolver, the parse cache, and the graph writer all expect it.
193
+ */
194
+ function extractedFile(file, extraction) {
195
+ return {
196
+ path: file.path,
197
+ language: file.language,
198
+ size: file.size,
199
+ modifiedAt: file.modifiedAt,
200
+ contentHash: createHash('sha256').update(file.text).digest('hex'),
201
+ lineCount: file.lineCount,
202
+ extraction,
203
+ };
204
+ }
205
+ /**
206
+ * Extract every pending file, native engine first and `extractFile` for whatever it did not answer
207
+ * for, returning the results in the order they were given.
208
+ *
209
+ * The native call is one batch per {@link RUST_BATCH_SIZE} files (see `rust-engine.ts`); a file it
210
+ * did not answer for is parsed here instead. That fallback is per path, not per batch: one broken file
211
+ * costs one `extractFile` call, not the whole workspace's fast path.
212
+ * @param pending - the files waiting for an extractor, in discovery order.
213
+ * @param config - the walk's bounds, for the engine choice and the concurrency of the JS fallback.
214
+ * @param signal - aborts the extraction.
215
+ * @returns one record per pending file, in the same order.
216
+ */
217
+ async function extractPending(pending, config, signal) {
218
+ if (pending.length === 0)
219
+ return [];
220
+ // `'js'` never even loads the addon: the mode exists to run exactly the pre-engine code path.
221
+ const engine = config.engine === 'js' ? undefined : loadRustEngine();
222
+ // Every pending file is offered to the engine, including the languages it does not handle: it drops
223
+ // those itself (`RUST_LANGUAGES`), so the walk never keeps a second copy of that list in sync.
224
+ const rusted = engine === undefined
225
+ ? new Map()
226
+ : extractWithRust(pending.map(toRustInput), engine) ?? new Map();
227
+ const extracted = new Array(pending.length);
228
+ const fallback = [];
229
+ for (const [index, file] of pending.entries()) {
230
+ const extraction = rusted.get(file.path);
231
+ // A hit is the addon's extraction; a miss is this one path falling back, for any of the reasons
232
+ // `rust-engine.ts` documents — never a reason to distrust the batch.
233
+ if (extraction === undefined)
234
+ fallback.push({ index, file });
235
+ else
236
+ extracted[index] = extractedFile(file, extraction);
237
+ }
238
+ const parsed = await mapWithConcurrency(fallback, config.concurrency, async (entry) => ({
239
+ index: entry.index,
240
+ file: extractedFile(entry.file, await extractWithJs(entry.file, signal)),
241
+ }));
242
+ for (const result of parsed)
243
+ extracted[result.index] = result.file;
244
+ return extracted;
245
+ }
246
+ /**
247
+ * One pending file as the native engine takes it: only the three fields it reads, so the walk's own
248
+ * bookkeeping (stat data, spec, line count) never crosses the boundary.
249
+ * @param file - the pending file.
250
+ * @returns the addon's input shape.
251
+ */
252
+ function toRustInput(file) {
253
+ return { path: file.path, language: file.language, text: file.text };
254
+ }
169
255
  /** Run `tasks` with at most `concurrency` in flight at once, preserving input order in the result. */
170
256
  async function mapWithConcurrency(items, concurrency, task) {
171
257
  const results = new Array(items.length);
@@ -196,6 +282,11 @@ async function mapWithConcurrency(items, concurrency, task) {
196
282
  * is the identity every cache entry is recorded under — and the only seam-mediated work here is the
197
283
  * parse cache's read. A capability is not a bound, so it does not belong in the config that mirrors
198
284
  * this package's `Config`.
285
+ *
286
+ * Every candidate is read and decided first, then the files needing extraction go to the native
287
+ * engine in batches with `extractFile` as the fallback — see {@link WalkConfig.engine}. The two phases
288
+ * exist so one addon call can cover a whole workspace; neither changes what a per-file decision
289
+ * (size, minified, cache) does.
199
290
  * @param projectRoot - absolute path of the workspace to index.
200
291
  * @param config - the walk's bounds.
201
292
  * @param fs - the filesystem seam the parse cache is read through.
@@ -206,21 +297,38 @@ async function mapWithConcurrency(items, concurrency, task) {
206
297
  export async function walkAndExtract(projectRoot, config, fs, signal) {
207
298
  const { candidates, overflow } = await discover(projectRoot, config, signal);
208
299
  const cache = config.reuseParsedCache ? await readParseCache(fs, projectRoot) : new Map();
209
- const parsed = await mapWithConcurrency(candidates, config.concurrency, candidate => parseCandidate(candidate, config, cache, signal));
210
- const files = [];
300
+ const prepared = await mapWithConcurrency(candidates, config.concurrency, candidate => prepareCandidate(candidate, config, cache, signal));
301
+ const pending = [];
211
302
  let filesSkipped = overflow;
212
303
  let filesMinified = 0;
213
- let filesReused = 0;
214
- for (const result of parsed) {
304
+ for (const result of prepared) {
215
305
  if (result.outcome === 'skipped')
216
306
  filesSkipped++;
217
307
  else if (result.outcome === 'minified')
218
308
  filesMinified++;
219
- else {
220
- files.push(result.file);
221
- if (result.reused)
222
- filesReused++;
309
+ else if (result.outcome === 'pending')
310
+ pending.push(result.file);
311
+ }
312
+ const extracted = await extractPending(pending, config, signal);
313
+ // One pass in discovery order: a cache hit is already a record, a pending file takes the next record
314
+ // `extractPending` produced. Which engine extracted a file therefore never shows in the ordering.
315
+ const files = [];
316
+ let filesReused = 0;
317
+ let next = 0;
318
+ for (const result of prepared) {
319
+ if (result.outcome === 'skipped' || result.outcome === 'minified')
320
+ continue;
321
+ if (result.outcome === 'pending') {
322
+ const file = extracted[next];
323
+ next++;
324
+ /* v8 ignore next 2 -- extractPending() returns exactly one record per pending file, in order. */
325
+ if (file === undefined)
326
+ continue;
327
+ files.push(file);
328
+ continue;
223
329
  }
330
+ files.push(result.file);
331
+ filesReused++;
224
332
  }
225
333
  return { files, filesSkipped, filesMinified, filesReused };
226
334
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-codegraph-tree-sitter",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
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.4.2"
54
+ "dsh-plugin-codegraph-service": "^0.5.1"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@deepseek-ai/cordis": ">=4.0.1",