dsh-plugin-codegraph-tree-sitter 0.4.2 → 0.5.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.d.ts +15 -1
- package/lib/index.js +6 -0
- package/lib/rust-engine.d.ts +104 -0
- package/lib/rust-engine.js +201 -0
- package/lib/walk.d.ts +22 -0
- package/lib/walk.js +141 -33
- package/package.json +2 -2
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';
|
|
@@ -130,6 +133,17 @@ export interface Config {
|
|
|
130
133
|
* parse and the tree walk, which dominate the per-file cost.
|
|
131
134
|
*/
|
|
132
135
|
cacheVerify?: 'stat' | 'hash';
|
|
136
|
+
/**
|
|
137
|
+
* Which extractor parses a file (default `'auto'`): this package's own `extractFile` (`'js'`), the
|
|
138
|
+
* native Rust addon from `crates/indexer-rust` (`'rust'`), or whichever is available (`'auto'`).
|
|
139
|
+
*
|
|
140
|
+
* `'rust'` and `'auto'` hand every TypeScript/JavaScript/JSX/TSX file to the addon in batches and
|
|
141
|
+
* fall back to `extractFile` for anything it does not answer for — a language it does not handle, a
|
|
142
|
+
* file it declines, or a deployment with no built artifact at all. Both paths produce the same
|
|
143
|
+
* extraction, pinned by `rust-parity.spec.ts`, so this setting only trades speed against how the
|
|
144
|
+
* work is scheduled. `'js'` is exactly the behaviour this package had before the addon existed.
|
|
145
|
+
*/
|
|
146
|
+
engine?: EngineMode;
|
|
133
147
|
/**
|
|
134
148
|
* Watch the workspace for file changes and refresh the index automatically after a successful
|
|
135
149
|
* `index()` call establishes a baseline (default true). Set `watch: false` to keep indexing purely
|
package/lib/index.js
CHANGED
|
@@ -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
|
|
@@ -90,6 +94,7 @@ export const Config = z.object({
|
|
|
90
94
|
concurrency: z.number().default(DEFAULT_CONCURRENCY),
|
|
91
95
|
reuseParsedCache: z.boolean().default(true),
|
|
92
96
|
cacheVerify: z.union([z.const('stat'), z.const('hash')]).default('stat'),
|
|
97
|
+
engine: z.union([z.const('auto'), z.const('js'), z.const('rust')]).default('auto'),
|
|
93
98
|
watch: z.boolean().default(true),
|
|
94
99
|
watchDebounceMs: z.number().default(DEFAULT_WATCH_DEBOUNCE_MS),
|
|
95
100
|
maxWatchedDirectories: z.number().default(DEFAULT_MAX_WATCHED_DIRECTORIES),
|
|
@@ -302,6 +307,7 @@ async function runIndex(projectRoot, config, fs, signal) {
|
|
|
302
307
|
languages: config.languages,
|
|
303
308
|
reuseParsedCache: config.reuseParsedCache,
|
|
304
309
|
cacheVerify: config.cacheVerify,
|
|
310
|
+
engine: config.engine,
|
|
305
311
|
}, fs, signal);
|
|
306
312
|
signal?.throwIfAborted();
|
|
307
313
|
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
|
-
*
|
|
101
|
-
*
|
|
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
|
|
105
|
-
* @returns the
|
|
106
|
-
*
|
|
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
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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 {
|
|
150
|
-
|
|
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
|
|
210
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.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,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.
|
|
54
|
+
"dsh-plugin-codegraph-service": "^0.5.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"@deepseek-ai/cordis": ">=4.0.1",
|