dsh-plugin-codegraph-tree-sitter 0.3.0 → 0.4.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/cache.d.ts CHANGED
@@ -3,6 +3,12 @@
3
3
  * repeat index of an unchanged workspace can skip the read, the parse, and the tree walk for every
4
4
  * file whose content did not change.
5
5
  *
6
+ * The cache document is read and written through the host's filesystem seam (`ctx.fs`), named here as
7
+ * the structural {@link ParseCacheFileSystem} port — the same discipline `./watcher.ts` applies to
8
+ * `WatchFileSystem`. The seam is what DSH's read policy and sandbox actually gate, and it is the only
9
+ * way this file can follow a backend whose files live in another execution world; a path-based
10
+ * `node:fs` write would silently land somewhere the graph's readers are not looking.
11
+ *
6
12
  * The cache is an accelerator with no authority. It is read only as a hint — anything missing,
7
13
  * unreadable, corrupt, written by another version, or shaped unexpectedly is discarded and the file
8
14
  * is parsed again, so losing it costs time and never correctness. That is what lets
@@ -21,6 +27,67 @@ import type { ExtractedFile } from './resolve.ts';
21
27
  export declare const EXTRACTION_CACHE_VERSION = 1;
22
28
  /** Path of the parse cache, relative to a project root. */
23
29
  export declare const EXTRACTION_CACHE_RELATIVE_PATH = ".codegraph/cache/extractions.json";
30
+ /**
31
+ * One resolved filesystem target — the opaque identity `ctx.fs` hands back and every other call
32
+ * takes. Only `targetKey` is read here: the module never parses it, and a remote backend's key is as
33
+ * valid as a local path.
34
+ */
35
+ export interface CacheTarget {
36
+ /** Opaque backend identity of the target. */
37
+ readonly targetKey: string;
38
+ }
39
+ /**
40
+ * The metadata this module consumes from a target, as `ctx.fs.stat` reports it.
41
+ *
42
+ * Deliberately narrower than the seam's own metadata: a cache document has no version to guard and no
43
+ * mtime to compare (the files it describes carry their own identity inside the document), so presence
44
+ * and kind are the whole decision. Naming the one field it reads rather than the whole `FsInfo` is
45
+ * also what keeps this port assignable to the host's `FileSystem`.
46
+ */
47
+ export interface CacheTargetInfo {
48
+ /** Whether the target is a regular file, a directory, or something else. */
49
+ readonly type: 'file' | 'directory' | 'other';
50
+ }
51
+ /**
52
+ * The narrow slice of `ctx.fs` the parse cache consumes.
53
+ *
54
+ * Deliberately structural rather than an import of the host's `FileSystem`: the tree-sitter package
55
+ * declares no dependency on `@deepseek-ai/dsh-fs`, so it names the four calls it uses instead —
56
+ * resolve a path, ask whether it exists, read it, replace it. That keeps this module compilable and
57
+ * loadable across the peer range the package supports, and keeps the port small enough that a test
58
+ * double is a few lines.
59
+ */
60
+ export interface ParseCacheFileSystem {
61
+ /**
62
+ * Resolve a path into a stable backend target.
63
+ * @param path - the path to resolve; absolute paths are what this module passes.
64
+ * @param opts - optional resolution base for a relative path.
65
+ * @returns the resolved target, resolvable even before the file exists.
66
+ */
67
+ resolve(path: string, opts?: {
68
+ readonly cwd?: string;
69
+ }): Promise<CacheTarget>;
70
+ /**
71
+ * Report a target's metadata.
72
+ * @param target - the resolved target to probe.
73
+ * @returns its metadata, or undefined when nothing is there.
74
+ */
75
+ stat(target: CacheTarget): Promise<CacheTargetInfo | undefined>;
76
+ /**
77
+ * Read the whole file as decoded text.
78
+ * @param target - the resolved target to read.
79
+ * @returns the file's content.
80
+ */
81
+ readText(target: CacheTarget): Promise<string>;
82
+ /**
83
+ * Atomically create or replace the whole file. The outcome is ignored: this module only needs the
84
+ * write to have happened, and the seam's guards are not this call's business.
85
+ * @param target - the resolved target to write.
86
+ * @param content - the full new content.
87
+ * @returns the publication outcome, whose details this module does not read.
88
+ */
89
+ writeText(target: CacheTarget, content: string): Promise<unknown>;
90
+ }
24
91
  /** One file's cached extraction, together with the identity it was computed from. */
25
92
  export interface CachedFile {
26
93
  /** Epoch milliseconds of the file's modification time when its extraction was produced. */
@@ -39,27 +106,37 @@ export interface CachedFile {
39
106
  /**
40
107
  * Load the previous run's extractions for `projectRoot`.
41
108
  *
42
- * Never throws: a cache that cannot be read, parsed, or trusted yields an empty map, which makes the
43
- * caller parse everything. A single malformed entry discards the whole file — an entry is either a
44
- * complete record this package wrote or evidence the document was damaged, and reasoning about
45
- * partial reuse is not worth the risk of a graph built from a half-written entry.
109
+ * Never throws: a cache that cannot be resolved, read, parsed, or trusted yields an empty map, which
110
+ * makes the caller parse everything. A single malformed entry discards the whole file — an entry is
111
+ * either a complete record this package wrote or evidence the document was damaged, and reasoning
112
+ * about partial reuse is not worth the risk of a graph built from a half-written entry.
113
+ * @param fs - the filesystem seam to resolve, probe, and read through.
46
114
  * @param projectRoot - absolute path of the workspace whose cache to load.
47
115
  * @returns the cached extractions keyed by project-relative path, empty when none can be trusted.
48
116
  */
49
- export declare function readParseCache(projectRoot: string): Promise<Map<string, CachedFile>>;
117
+ export declare function readParseCache(fs: ParseCacheFileSystem, projectRoot: string): Promise<Map<string, CachedFile>>;
50
118
  /**
51
119
  * Record this run's extractions as the next run's cache.
52
120
  *
53
- * Written through a sibling temporary name and `rename`d into place, the same way `./schema.ts`
54
- * replaces its status sidecar, so a reader never sees half a document. The temporary name is fixed
55
- * rather than unique: a write interrupted before its rename is overwritten by the next attempt
56
- * instead of accumulating, and a torn leftover is rejected by {@link readParseCache} at the cost of
57
- * one run's reuse.
121
+ * Publication is the backend's contract, not this module's. `writeText` is specified as an atomic
122
+ * create-or-replace — the local backend stages a temporary sibling and renames it into place — so the
123
+ * previous implementation's own `.tmp` write plus `rename` is gone. That is not a shortcut taken for
124
+ * its own sake: a path-based rename has no meaning for a backend whose files live in another
125
+ * execution world, which is exactly the backend this module moved onto the seam to follow. The seam
126
+ * also has no `mkdir`, so this write relies on the backend creating a missing parent directory (the
127
+ * local one does, before staging); a backend that did not would make this write fail, which the
128
+ * swallow below already treats as "no cache this run".
129
+ *
130
+ * Should a backend ever publish non-atomically, or a crash interrupt it, the worst case is a torn
131
+ * document: {@link readParseCache} rejects it as unparseable and the next run parses everything.
132
+ * Losing one run's reuse is the acceptable side of that trade, because the cache has no authority to
133
+ * lose — a corrupted graph would be the unacceptable one, and no torn cache can produce it.
58
134
  *
59
135
  * A failure is swallowed on purpose. A read-only workspace, a full disk, or a locked directory must
60
136
  * still be indexable; the cache is the optimization, never the product.
137
+ * @param fs - the filesystem seam to resolve and write through.
61
138
  * @param projectRoot - absolute path of the workspace to record the cache for.
62
139
  * @param files - every file this run produced an extraction for.
63
140
  */
64
- export declare function writeParseCache(projectRoot: string, files: readonly ExtractedFile[]): Promise<void>;
141
+ export declare function writeParseCache(fs: ParseCacheFileSystem, projectRoot: string, files: readonly ExtractedFile[]): Promise<void>;
65
142
  //# sourceMappingURL=cache.d.ts.map
package/lib/cache.js CHANGED
@@ -3,6 +3,12 @@
3
3
  * repeat index of an unchanged workspace can skip the read, the parse, and the tree walk for every
4
4
  * file whose content did not change.
5
5
  *
6
+ * The cache document is read and written through the host's filesystem seam (`ctx.fs`), named here as
7
+ * the structural {@link ParseCacheFileSystem} port — the same discipline `./watcher.ts` applies to
8
+ * `WatchFileSystem`. The seam is what DSH's read policy and sandbox actually gate, and it is the only
9
+ * way this file can follow a backend whose files live in another execution world; a path-based
10
+ * `node:fs` write would silently land somewhere the graph's readers are not looking.
11
+ *
6
12
  * The cache is an accelerator with no authority. It is read only as a hint — anything missing,
7
13
  * unreadable, corrupt, written by another version, or shaped unexpectedly is discarded and the file
8
14
  * is parsed again, so losing it costs time and never correctness. That is what lets
@@ -10,8 +16,7 @@
10
16
  * process may not write to.
11
17
  * @module dsh-plugin-codegraph-tree-sitter/cache
12
18
  */
13
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
14
- import { dirname, join } from 'node:path';
19
+ import { join } from 'node:path';
15
20
  /**
16
21
  * Version of the extraction semantics this cache records. Bump it by hand whenever `extract.ts` (or
17
22
  * a grammar that feeds it) starts producing a different result for the same source text: a stale
@@ -61,22 +66,31 @@ function isCachedFile(value) {
61
66
  /**
62
67
  * Load the previous run's extractions for `projectRoot`.
63
68
  *
64
- * Never throws: a cache that cannot be read, parsed, or trusted yields an empty map, which makes the
65
- * caller parse everything. A single malformed entry discards the whole file — an entry is either a
66
- * complete record this package wrote or evidence the document was damaged, and reasoning about
67
- * partial reuse is not worth the risk of a graph built from a half-written entry.
69
+ * Never throws: a cache that cannot be resolved, read, parsed, or trusted yields an empty map, which
70
+ * makes the caller parse everything. A single malformed entry discards the whole file — an entry is
71
+ * either a complete record this package wrote or evidence the document was damaged, and reasoning
72
+ * about partial reuse is not worth the risk of a graph built from a half-written entry.
73
+ * @param fs - the filesystem seam to resolve, probe, and read through.
68
74
  * @param projectRoot - absolute path of the workspace whose cache to load.
69
75
  * @returns the cached extractions keyed by project-relative path, empty when none can be trusted.
70
76
  */
71
- export async function readParseCache(projectRoot) {
77
+ export async function readParseCache(fs, projectRoot) {
72
78
  const cachePath = join(projectRoot, EXTRACTION_CACHE_RELATIVE_PATH);
73
79
  let raw;
74
80
  try {
75
- raw = await readFile(cachePath, 'utf8');
81
+ const target = await fs.resolve(cachePath);
82
+ // The seam reports an absent target as `undefined` from `stat` but as a thrown error from
83
+ // `readText`. Probing first keeps the ordinary first run — no cache document written yet — a plain
84
+ // return instead of exception control flow, and rejects a directory or special file that took the
85
+ // document's place before asking a reader to decode it.
86
+ const info = await fs.stat(target);
87
+ if (info === undefined || info.type !== 'file')
88
+ return new Map();
89
+ raw = await fs.readText(target);
76
90
  }
77
91
  catch {
78
- // The ordinary first run has no cache file yet; an unreadable one is the same answer to the
79
- // caller — nothing to reuse — and must not fail an index that can still parse the workspace.
92
+ // An unreadable document is the same answer to the caller as an absent one — nothing to reuse —
93
+ // and must not fail an index that can still parse the workspace.
80
94
  return new Map();
81
95
  }
82
96
  let document;
@@ -104,20 +118,27 @@ export async function readParseCache(projectRoot) {
104
118
  /**
105
119
  * Record this run's extractions as the next run's cache.
106
120
  *
107
- * Written through a sibling temporary name and `rename`d into place, the same way `./schema.ts`
108
- * replaces its status sidecar, so a reader never sees half a document. The temporary name is fixed
109
- * rather than unique: a write interrupted before its rename is overwritten by the next attempt
110
- * instead of accumulating, and a torn leftover is rejected by {@link readParseCache} at the cost of
111
- * one run's reuse.
121
+ * Publication is the backend's contract, not this module's. `writeText` is specified as an atomic
122
+ * create-or-replace — the local backend stages a temporary sibling and renames it into place — so the
123
+ * previous implementation's own `.tmp` write plus `rename` is gone. That is not a shortcut taken for
124
+ * its own sake: a path-based rename has no meaning for a backend whose files live in another
125
+ * execution world, which is exactly the backend this module moved onto the seam to follow. The seam
126
+ * also has no `mkdir`, so this write relies on the backend creating a missing parent directory (the
127
+ * local one does, before staging); a backend that did not would make this write fail, which the
128
+ * swallow below already treats as "no cache this run".
129
+ *
130
+ * Should a backend ever publish non-atomically, or a crash interrupt it, the worst case is a torn
131
+ * document: {@link readParseCache} rejects it as unparseable and the next run parses everything.
132
+ * Losing one run's reuse is the acceptable side of that trade, because the cache has no authority to
133
+ * lose — a corrupted graph would be the unacceptable one, and no torn cache can produce it.
112
134
  *
113
135
  * A failure is swallowed on purpose. A read-only workspace, a full disk, or a locked directory must
114
136
  * still be indexable; the cache is the optimization, never the product.
137
+ * @param fs - the filesystem seam to resolve and write through.
115
138
  * @param projectRoot - absolute path of the workspace to record the cache for.
116
139
  * @param files - every file this run produced an extraction for.
117
140
  */
118
- export async function writeParseCache(projectRoot, files) {
119
- const cachePath = join(projectRoot, EXTRACTION_CACHE_RELATIVE_PATH);
120
- const tempPath = `${cachePath}.tmp`;
141
+ export async function writeParseCache(fs, projectRoot, files) {
121
142
  try {
122
143
  const entries = {};
123
144
  for (const file of files) {
@@ -131,9 +152,8 @@ export async function writeParseCache(projectRoot, files) {
131
152
  };
132
153
  }
133
154
  const document = { cacheVersion: EXTRACTION_CACHE_VERSION, files: entries };
134
- await mkdir(dirname(cachePath), { recursive: true });
135
- await writeFile(tempPath, JSON.stringify(document));
136
- await rename(tempPath, cachePath);
155
+ const target = await fs.resolve(join(projectRoot, EXTRACTION_CACHE_RELATIVE_PATH));
156
+ await fs.writeText(target, JSON.stringify(document));
137
157
  }
138
158
  catch {
139
159
  // See above: an unusable cache location only means the next run parses everything again.
package/lib/index.d.ts CHANGED
@@ -41,7 +41,7 @@ export { isMinifiedName, looksMinified, measureText, MINIFIED_AVERAGE_LINE_LENGT
41
41
  export type { TextShape } from './minified.ts';
42
42
  export { SCHEMA_VERSION, writeGraph } from './schema.ts';
43
43
  export { EXTRACTION_CACHE_RELATIVE_PATH, EXTRACTION_CACHE_VERSION, readParseCache, writeParseCache } from './cache.ts';
44
- export type { CachedFile } from './cache.ts';
44
+ export type { CachedFile, CacheTarget, CacheTargetInfo, ParseCacheFileSystem } from './cache.ts';
45
45
  export { isExcluded, walkAndExtract } from './walk.ts';
46
46
  export type { WalkConfig, WalkResult } from './walk.ts';
47
47
  export { createWatcher } from './watcher.ts';
@@ -49,7 +49,7 @@ export type { DegradeReason, WatchConfig, WatchDirEntry, WatchFileSystem, WatchT
49
49
  export { decideWatch, readProcVersion } from './watch-policy.ts';
50
50
  export type { WatchDecision, WatchPolicyInput } from './watch-policy.ts';
51
51
  export { detectWorktree, execGit } from './worktree.ts';
52
- export type { GitExec, WorktreeInfo } from './worktree.ts';
52
+ export type { GitExec, GitSpawnHandle, GitSpawnOutcome, GitSpawnPort, GitSpawnReader, GitSpawnSpec, WorktreeInfo, } from './worktree.ts';
53
53
  export { HOOK_MARKER_BEGIN, HOOK_MARKER_END, installGitHooks, uninstallGitHooks } from './git-hooks.ts';
54
54
  export type { GitHooksOptions } from './git-hooks.ts';
55
55
  /** Cordis plugin name for loader diagnostics. */
package/lib/index.js CHANGED
@@ -49,6 +49,10 @@ export { EXTRACTION_CACHE_RELATIVE_PATH, EXTRACTION_CACHE_VERSION, readParseCach
49
49
  export { isExcluded, walkAndExtract } from "./walk.js";
50
50
  export { createWatcher } from "./watcher.js";
51
51
  export { decideWatch, readProcVersion } from "./watch-policy.js";
52
+ // Git diagnostics stay library functions rather than plugin wiring: nothing in `apply` runs git, so
53
+ // `inject` deliberately omits `subprocess` — requiring it would hide this whole indexer from a
54
+ // composition that provides none. A caller that wants worktree diagnostics passes `ctx.subprocess`
55
+ // to `execGit` itself; installing the git hooks touches no subprocess at all.
52
56
  export { detectWorktree, execGit } from "./worktree.js";
53
57
  export { HOOK_MARKER_BEGIN, HOOK_MARKER_END, installGitHooks, uninstallGitHooks } from "./git-hooks.js";
54
58
  /** Cordis plugin name for loader diagnostics. */
@@ -103,12 +107,13 @@ export function apply(ctx, config) {
103
107
  assertPositiveInteger('concurrency', resolved.concurrency);
104
108
  assertPositiveInteger('watchDebounceMs', resolved.watchDebounceMs);
105
109
  assertPositiveInteger('maxWatchedDirectories', resolved.maxWatchedDirectories);
106
- // Watching goes through the host's filesystem seam rather than `node:fs`, so it follows the same
107
- // execution world the rest of the harness reads: a remote or confined backend watches its own paths.
108
- // The `fs` service is a declared dependency (see `inject`), so it is present whenever `apply` runs.
109
- // It is read structurally instead of importing `@deepseek-ai/dsh-fs`: that package is not a
110
- // dependency here, and its `watch` method only exists from host 0.2 onward, while this package
111
- // supports older peers that have no such member to import.
110
+ // Watching and the parse cache both go through the host's filesystem seam rather than `node:fs`, so
111
+ // they follow the same execution world the rest of the harness reads: a remote or confined backend
112
+ // watches and caches on its own side, and the cache's read/write pass through the same policy the
113
+ // graph's readers do. The `fs` service is a declared dependency (see `inject`), so it is present
114
+ // whenever `apply` runs. Both ports are read structurally instead of importing
115
+ // `@deepseek-ai/dsh-fs`: that package is not a dependency here, and its `watch` method only exists
116
+ // from host 0.2 onward, while this package supports older peers that have no such member to import.
112
117
  const fileSystem = ctx.fs;
113
118
  // Keyed by project root, private to this plugin instance: never registered on `ctx`, so there is no
114
119
  // cross-plugin relation here for an invariant to describe — just process-local bookkeeping this
@@ -141,7 +146,7 @@ export function apply(ctx, config) {
141
146
  respectGitignore: resolved.respectGitignore,
142
147
  debounceMs: resolved.watchDebounceMs,
143
148
  maxWatchedDirectories: resolved.maxWatchedDirectories,
144
- sync: () => runSync(projectRoot, resolved),
149
+ sync: () => runSync(projectRoot, resolved, fileSystem),
145
150
  // No diagnostics service is wired into this package's dependencies; this is the floor for
146
151
  // "degradation must be visible" until one is, per NOTES.local.md.
147
152
  onDegraded: (reason) => {
@@ -166,7 +171,7 @@ export function apply(ctx, config) {
166
171
  }
167
172
  },
168
173
  async index(projectRoot, signal) {
169
- const report = await runIndex(projectRoot, resolved, signal);
174
+ const report = await runIndex(projectRoot, resolved, fileSystem, signal);
170
175
  // Watching starts only after a successful index establishes the baseline it refreshes — never
171
176
  // from a canIndex() probe or a failed run.
172
177
  ensureWatching(projectRoot);
@@ -270,21 +275,23 @@ function runAutoIndexJob(ctx, root, handle) {
270
275
  * a file subset — so the watcher's own collected paths are not consulted here.
271
276
  * @param projectRoot - absolute path of the workspace to refresh.
272
277
  * @param config - the resolved plugin configuration.
278
+ * @param fs - the filesystem seam the parse cache is read and written through.
273
279
  * @returns how many files the rebuild indexed, and how long it took.
274
280
  */
275
- async function runSync(projectRoot, config) {
281
+ async function runSync(projectRoot, config, fs) {
276
282
  const startedAt = Date.now();
277
- const report = await runIndex(projectRoot, config);
283
+ const report = await runIndex(projectRoot, config, fs);
278
284
  return { filesChanged: report.filesIndexed, durationMs: Date.now() - startedAt };
279
285
  }
280
286
  /**
281
287
  * One indexing run: walk, parse, resolve, and write.
282
288
  * @param projectRoot - absolute path of the workspace to index.
283
289
  * @param config - the resolved plugin configuration.
290
+ * @param fs - the filesystem seam the parse cache is read and written through.
284
291
  * @param signal - aborts the run.
285
292
  * @returns the run's report.
286
293
  */
287
- async function runIndex(projectRoot, config, signal) {
294
+ async function runIndex(projectRoot, config, fs, signal) {
288
295
  const { files, filesSkipped, filesMinified, filesReused } = await walkAndExtract(projectRoot, {
289
296
  exclude: config.exclude,
290
297
  respectGitignore: config.respectGitignore,
@@ -295,7 +302,7 @@ async function runIndex(projectRoot, config, signal) {
295
302
  languages: config.languages,
296
303
  reuseParsedCache: config.reuseParsedCache,
297
304
  cacheVerify: config.cacheVerify,
298
- }, signal);
305
+ }, fs, signal);
299
306
  signal?.throwIfAborted();
300
307
  const indexedAt = Date.now();
301
308
  const graph = resolveWorkspace(files, indexedAt);
@@ -307,7 +314,7 @@ async function runIndex(projectRoot, config, signal) {
307
314
  // Only after the graph is in place: the cache records the extractions this run actually committed,
308
315
  // so a run that failed before writing its graph leaves the previous cache — and the mtimes it was
309
316
  // built from — alone instead of vouching for a graph that was never written.
310
- await writeParseCache(projectRoot, files);
317
+ await writeParseCache(fs, projectRoot, files);
311
318
  const languageCounts = new Map();
312
319
  for (const file of files)
313
320
  languageCounts.set(file.language, (languageCounts.get(file.language) ?? 0) + 1);
package/lib/walk.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * package's `Config` exposes.
9
9
  * @module dsh-plugin-codegraph-tree-sitter/walk
10
10
  */
11
+ import type { ParseCacheFileSystem } from './cache.ts';
11
12
  import type { GitignoreRule } from './gitignore.ts';
12
13
  import type { ExtractedFile } from './resolve.ts';
13
14
  /**
@@ -48,9 +49,10 @@ export interface WalkConfig {
48
49
  readonly respectGitignore: boolean;
49
50
  /**
50
51
  * Reuse the extraction a previous run cached for every file whose content is unchanged, instead of
51
- * reading and parsing it again. The cache lives under `<projectRoot>/.codegraph/cache` and is
52
- * written by the indexer after a run succeeds; a missing or unusable cache simply means every file
53
- * is parsed.
52
+ * reading and parsing it again. The cache lives under `<projectRoot>/.codegraph/cache`, is read
53
+ * through the host's filesystem seam (the `fs` argument to {@link walkAndExtract}), and is written
54
+ * by the indexer after a run succeeds; a missing or unusable cache simply means every file is
55
+ * parsed.
54
56
  */
55
57
  readonly reuseParsedCache: boolean;
56
58
  /**
@@ -80,11 +82,18 @@ export interface WalkResult {
80
82
  /**
81
83
  * Walk `projectRoot` and parse every candidate file it finds — or reuse the extraction a previous
82
84
  * run cached for it.
85
+ *
86
+ * `fs` is a parameter rather than a `WalkConfig` field on purpose: the walk's own directory scan and
87
+ * file reads still go straight to `node:fs` — they compare the mtime and size it stats itself, which
88
+ * is the identity every cache entry is recorded under — and the only seam-mediated work here is the
89
+ * parse cache's read. A capability is not a bound, so it does not belong in the config that mirrors
90
+ * this package's `Config`.
83
91
  * @param projectRoot - absolute path of the workspace to index.
84
92
  * @param config - the walk's bounds.
93
+ * @param fs - the filesystem seam the parse cache is read through.
85
94
  * @param signal - aborts the walk.
86
95
  * @returns the parsed files, how many candidates were skipped (over size, or past `maxFiles`), and
87
96
  * how many files reused a cached extraction.
88
97
  */
89
- export declare function walkAndExtract(projectRoot: string, config: WalkConfig, signal?: AbortSignal): Promise<WalkResult>;
98
+ export declare function walkAndExtract(projectRoot: string, config: WalkConfig, fs: ParseCacheFileSystem, signal?: AbortSignal): Promise<WalkResult>;
90
99
  //# sourceMappingURL=walk.d.ts.map
package/lib/walk.js CHANGED
@@ -190,15 +190,22 @@ async function mapWithConcurrency(items, concurrency, task) {
190
190
  /**
191
191
  * Walk `projectRoot` and parse every candidate file it finds — or reuse the extraction a previous
192
192
  * run cached for it.
193
+ *
194
+ * `fs` is a parameter rather than a `WalkConfig` field on purpose: the walk's own directory scan and
195
+ * file reads still go straight to `node:fs` — they compare the mtime and size it stats itself, which
196
+ * is the identity every cache entry is recorded under — and the only seam-mediated work here is the
197
+ * parse cache's read. A capability is not a bound, so it does not belong in the config that mirrors
198
+ * this package's `Config`.
193
199
  * @param projectRoot - absolute path of the workspace to index.
194
200
  * @param config - the walk's bounds.
201
+ * @param fs - the filesystem seam the parse cache is read through.
195
202
  * @param signal - aborts the walk.
196
203
  * @returns the parsed files, how many candidates were skipped (over size, or past `maxFiles`), and
197
204
  * how many files reused a cached extraction.
198
205
  */
199
- export async function walkAndExtract(projectRoot, config, signal) {
206
+ export async function walkAndExtract(projectRoot, config, fs, signal) {
200
207
  const { candidates, overflow } = await discover(projectRoot, config, signal);
201
- const cache = config.reuseParsedCache ? await readParseCache(projectRoot) : new Map();
208
+ const cache = config.reuseParsedCache ? await readParseCache(fs, projectRoot) : new Map();
202
209
  const parsed = await mapWithConcurrency(candidates, config.concurrency, candidate => parseCandidate(candidate, config, cache, signal));
203
210
  const files = [];
204
211
  let filesSkipped = overflow;
package/lib/worktree.d.ts CHANGED
@@ -9,15 +9,115 @@
9
9
  * never looks outside the `projectRoot` it was given. If a caller ever *does* end up pointed at the
10
10
  * wrong root, the bug is almost certainly in how the host resolves a session's working directory, not
11
11
  * in this package — this module exists to make that distinction diagnosable, not to route around it.
12
+ *
13
+ * Git runs through the host's subprocess seam (`ctx.subprocess`) instead of `node:child_process`.
14
+ * That seam is where executable resolution, environment scrubbing, bounded output collection,
15
+ * termination grace, timeouts, and sandbox containment live: a child started here inherits a parent
16
+ * environment with ambient credentials and `DSH_*` deployment facts removed, its stdout/stderr are
17
+ * capped before they can exhaust this process, and the host may satisfy the request by running the
18
+ * command in another execution world entirely. `execFileSync` would bypass all of that, so the module
19
+ * takes the seam as a structural port and the caller supplies `execGit(ctx.subprocess)`.
12
20
  * @module dsh-plugin-codegraph-tree-sitter/worktree
13
21
  */
22
+ /** Exit facts a spawned child settles with, as `handle.done` reports them. */
23
+ export interface GitSpawnOutcome {
24
+ /** Exit status of the direct child; `null` when a signal terminated it instead. */
25
+ readonly exitCode: number | null;
26
+ /** Name of the signal that terminated the child; `null` when it exited on its own. */
27
+ readonly signal: string | null;
28
+ }
29
+ /** One collected output stream, read in whole-stream byte offsets. */
30
+ export interface GitSpawnReader {
31
+ /**
32
+ * Read everything the collector still retains from `fromByte` onward.
33
+ * @param fromByte - whole-stream byte offset to resume from; `0` for a first read.
34
+ * @returns the collected text, and whether bytes before `fromByte` had already been dropped.
35
+ */
36
+ readFrom(fromByte: number): {
37
+ readonly text: string;
38
+ readonly lossy: boolean;
39
+ };
40
+ }
41
+ /** One live child returned by {@link GitSpawnPort.spawn}. */
42
+ export interface GitSpawnHandle {
43
+ /**
44
+ * Settles with the direct child's exit facts. Rejects when the child never started — a missing
45
+ * executable, a refused spawn, or a failing provider — and carries no collected output then.
46
+ */
47
+ readonly done: Promise<GitSpawnOutcome>;
48
+ /** Buffered output, one entry per stream the spec asked to have collected. */
49
+ readonly collected: {
50
+ /** Collected stdout, present because {@link GitSpawnSpec.stdio} always asks for it. */
51
+ readonly stdout?: GitSpawnReader;
52
+ /** Collected stderr, present because {@link GitSpawnSpec.stdio} always asks for it. */
53
+ readonly stderr?: GitSpawnReader;
54
+ };
55
+ }
56
+ /** How one child is launched: fully explicit, never shell-interpreted. */
57
+ export interface GitSpawnSpec {
58
+ /** Program and arguments; `argv[0]` is the program itself, never a shell command line. */
59
+ readonly argv: readonly string[];
60
+ /** Working directory the child starts in. */
61
+ readonly cwd: string;
62
+ /** One disposition per standard stream. */
63
+ readonly stdio: {
64
+ /** stdin stays closed: none of these probes wants input, and a prompt would hang the run. */
65
+ readonly stdin: 'ignore';
66
+ /** Collect stdout, retaining at most this many bytes. */
67
+ readonly stdout: {
68
+ readonly maxBytes: number;
69
+ };
70
+ /** Collect stderr, retaining at most this many bytes. */
71
+ readonly stderr: {
72
+ readonly maxBytes: number;
73
+ };
74
+ };
75
+ /** Milliseconds the host may take to terminate the child once termination has started. */
76
+ readonly graceMs: number;
77
+ /** Cancels the child; aborting runs the host's own termination procedure, not a bare kill. */
78
+ readonly signal: AbortSignal;
79
+ /** Environment overrides, merged over the host's scrubbed parent environment. */
80
+ readonly env: {
81
+ readonly [name: string]: string;
82
+ };
83
+ }
14
84
  /**
15
- * Runs one git command and returns its stdout, throwing on any non-zero exit, missing `git` binary,
16
- * or timeout. Indirection over `execFileSync` so tests never fork a real process.
85
+ * The narrow slice of `ctx.subprocess` this module consumes.
86
+ *
87
+ * Deliberately structural rather than an import of `@deepseek-ai/dsh-subprocess`: the tree-sitter
88
+ * package declares no dependency on the subprocess family, and only three members of a spawn are
89
+ * read here (argv, stdio, exit facts). Naming those keeps the port small enough that a test double
90
+ * is a few lines, and keeps the module loadable across the host versions this package supports.
91
+ */
92
+ export interface GitSpawnPort {
93
+ /**
94
+ * Start one managed child.
95
+ * @param spec - program, arguments, cwd, stdio, termination grace, cancellation, environment.
96
+ * @returns the live handle whose `done` reports the child's exit facts.
97
+ */
98
+ spawn(spec: GitSpawnSpec): GitSpawnHandle;
99
+ }
100
+ /**
101
+ * Runs one git command and returns its stdout, rejecting on any non-zero exit, timeout, output
102
+ * overflow, or spawn failure.
103
+ *
104
+ * Async, because the host owns the child's lifetime: a synchronous exec cannot express the seam's
105
+ * cancellation, and blocking this process for the length of a git probe is exactly what the
106
+ * subprocess service exists to avoid. Callers inject this instead of forking a real process.
107
+ */
108
+ export type GitExec = (args: readonly string[], cwd: string) => Promise<string>;
109
+ /**
110
+ * The real `git`-backed {@link GitExec}, running through the host's subprocess port.
111
+ *
112
+ * Every failure is a rejection, never an empty string: the callers here parse git's output
113
+ * positionally, so a silent empty answer would be read as "truncated output" rather than "the probe
114
+ * failed", and the module's "a failed probe is a negative answer" contract lives above this layer.
115
+ * Environment overrides are git's own hardening — no user config, no terminal prompt, no optional
116
+ * locks — copied from the host's own git consumer so both behave identically.
117
+ * @param port - the spawn port; normally the host's `ctx.subprocess`.
118
+ * @returns a git runner whose rejections describe the command and git's stderr tail.
17
119
  */
18
- export type GitExec = (args: readonly string[], cwd: string) => string;
19
- /** The real `git`-backed {@link GitExec}. Array arguments, no shell, a hard timeout. */
20
- export declare const execGit: GitExec;
120
+ export declare function execGit(port: GitSpawnPort): GitExec;
21
121
  /** What `detectWorktree` learned about one root. */
22
122
  export interface WorktreeInfo {
23
123
  /** Whether `root` is a linked worktree rather than the main working tree (or a lone clone). */
@@ -34,8 +134,8 @@ export interface WorktreeInfo {
34
134
  * way as "not a worktree" — `undefined`, never a thrown error — matching this package's existing
35
135
  * `canIndex`/`indexes` idiom of "a failed probe is a negative answer, not a failure to report."
36
136
  * @param root - absolute path to probe.
37
- * @param exec - the git-invoking primitive; defaults to the real `git` binary.
137
+ * @param exec - the git-invoking primitive; pass `execGit(ctx.subprocess)` for a real probe.
38
138
  * @returns worktree diagnostics, or `undefined` when `root` isn't inside a (usable) git repository.
39
139
  */
40
- export declare function detectWorktree(root: string, exec?: GitExec): WorktreeInfo | undefined;
140
+ export declare function detectWorktree(root: string, exec: GitExec): Promise<WorktreeInfo | undefined>;
41
141
  //# sourceMappingURL=worktree.d.ts.map
package/lib/worktree.js CHANGED
@@ -9,13 +9,86 @@
9
9
  * never looks outside the `projectRoot` it was given. If a caller ever *does* end up pointed at the
10
10
  * wrong root, the bug is almost certainly in how the host resolves a session's working directory, not
11
11
  * in this package — this module exists to make that distinction diagnosable, not to route around it.
12
+ *
13
+ * Git runs through the host's subprocess seam (`ctx.subprocess`) instead of `node:child_process`.
14
+ * That seam is where executable resolution, environment scrubbing, bounded output collection,
15
+ * termination grace, timeouts, and sandbox containment live: a child started here inherits a parent
16
+ * environment with ambient credentials and `DSH_*` deployment facts removed, its stdout/stderr are
17
+ * capped before they can exhaust this process, and the host may satisfy the request by running the
18
+ * command in another execution world entirely. `execFileSync` would bypass all of that, so the module
19
+ * takes the seam as a structural port and the caller supplies `execGit(ctx.subprocess)`.
12
20
  * @module dsh-plugin-codegraph-tree-sitter/worktree
13
21
  */
14
- import { execFileSync } from 'node:child_process';
15
22
  import { dirname, resolve } from 'node:path';
16
- /** The real `git`-backed {@link GitExec}. Array arguments, no shell, a hard timeout. */
17
- export const execGit = (args, cwd) => execFileSync('git', [...args], { cwd, encoding: 'utf8', timeout: 2_000 });
18
- /** Memoized per root: git diagnostics are cheap to answer wrong (a stale worktree list) but not free. */
23
+ /** Bound on one git probe's lifetime; the same ceiling the sync implementation used. */
24
+ const GIT_TIMEOUT_MS = 2_000;
25
+ /**
26
+ * Ceiling on collected stdout. `git rev-parse` answers in tens of bytes; the limit exists so a
27
+ * pathological repository cannot push unbounded output through this call, not because an answer
28
+ * of this size is expected.
29
+ */
30
+ const GIT_MAX_OUTPUT_BYTES = 64 * 1024;
31
+ /**
32
+ * The real `git`-backed {@link GitExec}, running through the host's subprocess port.
33
+ *
34
+ * Every failure is a rejection, never an empty string: the callers here parse git's output
35
+ * positionally, so a silent empty answer would be read as "truncated output" rather than "the probe
36
+ * failed", and the module's "a failed probe is a negative answer" contract lives above this layer.
37
+ * Environment overrides are git's own hardening — no user config, no terminal prompt, no optional
38
+ * locks — copied from the host's own git consumer so both behave identically.
39
+ * @param port - the spawn port; normally the host's `ctx.subprocess`.
40
+ * @returns a git runner whose rejections describe the command and git's stderr tail.
41
+ */
42
+ export function execGit(port) {
43
+ return async (args, cwd) => {
44
+ const handle = port.spawn({
45
+ argv: ['git', ...args],
46
+ cwd,
47
+ stdio: {
48
+ stdin: 'ignore',
49
+ stdout: { maxBytes: GIT_MAX_OUTPUT_BYTES },
50
+ stderr: { maxBytes: GIT_MAX_OUTPUT_BYTES },
51
+ },
52
+ graceMs: GIT_TIMEOUT_MS,
53
+ // The host drives termination from this signal, so a hung git is stopped the same way a
54
+ // cancelled tool call is, and `done` settles with a signal exit instead of blocking forever.
55
+ signal: AbortSignal.timeout(GIT_TIMEOUT_MS),
56
+ env: {
57
+ GIT_CONFIG_COUNT: '0',
58
+ GIT_TERMINAL_PROMPT: '0',
59
+ GIT_OPTIONAL_LOCKS: '0',
60
+ LC_ALL: 'C',
61
+ },
62
+ });
63
+ const outcome = await handle.done;
64
+ if (outcome.exitCode !== 0) {
65
+ const stderr = handle.collected.stderr;
66
+ const detail = stderr === undefined ? '' : stderr.readFrom(0).text.trim();
67
+ // A signal exit carries no code, so name the signal instead of printing `null`.
68
+ const how = outcome.exitCode === null
69
+ ? `was terminated${outcome.signal === null ? '' : ` by ${outcome.signal}`}`
70
+ : `exited with code ${outcome.exitCode}`;
71
+ throw new Error(`git ${args.join(' ')} ${how}${detail === '' ? '' : `: ${detail}`}`);
72
+ }
73
+ const stdout = handle.collected.stdout;
74
+ if (stdout === undefined)
75
+ throw new Error(`git ${args.join(' ')} produced no collected stdout`);
76
+ const collected = stdout.readFrom(0);
77
+ // A lossy first read means the child wrote past the collection limit before exiting. What
78
+ // survived is a tail, not an answer — and a first line read off it would be a fragment of the
79
+ // real path or hash, so this is a failure rather than a truncated success.
80
+ if (collected.lossy) {
81
+ throw new Error(`git ${args.join(' ')} wrote more than ${GIT_MAX_OUTPUT_BYTES} bytes of stdout`);
82
+ }
83
+ return collected.text;
84
+ };
85
+ }
86
+ /**
87
+ * Memoized per root: git diagnostics are cheap to answer wrong (a stale worktree list) but not free.
88
+ * The promise itself is cached so two concurrent probes of one root share a single child process
89
+ * instead of racing two; `probeWorktree` resolves every failure to `undefined`, so this map never
90
+ * holds a rejection.
91
+ */
19
92
  const cache = new Map();
20
93
  /**
21
94
  * Whether `root` sits inside a linked `git worktree`, and the main repository's root when it does.
@@ -24,22 +97,23 @@ const cache = new Map();
24
97
  * way as "not a worktree" — `undefined`, never a thrown error — matching this package's existing
25
98
  * `canIndex`/`indexes` idiom of "a failed probe is a negative answer, not a failure to report."
26
99
  * @param root - absolute path to probe.
27
- * @param exec - the git-invoking primitive; defaults to the real `git` binary.
100
+ * @param exec - the git-invoking primitive; pass `execGit(ctx.subprocess)` for a real probe.
28
101
  * @returns worktree diagnostics, or `undefined` when `root` isn't inside a (usable) git repository.
29
102
  */
30
- export function detectWorktree(root, exec = execGit) {
31
- if (cache.has(root))
32
- return cache.get(root);
33
- const info = probeWorktree(root, exec);
34
- cache.set(root, info);
35
- return info;
103
+ export function detectWorktree(root, exec) {
104
+ const cached = cache.get(root);
105
+ if (cached !== undefined)
106
+ return cached;
107
+ const pending = probeWorktree(root, exec);
108
+ cache.set(root, pending);
109
+ return pending;
36
110
  }
37
- function probeWorktree(root, exec) {
111
+ async function probeWorktree(root, exec) {
38
112
  let output;
39
113
  try {
40
114
  // One call, not three: `git rev-parse` answers every query in the order given, so a single
41
115
  // process fork either answers all three questions or fails all three the same way.
42
- output = exec(['rev-parse', '--show-toplevel', '--git-dir', '--git-common-dir'], root);
116
+ output = await exec(['rev-parse', '--show-toplevel', '--git-dir', '--git-common-dir'], root);
43
117
  }
44
118
  catch {
45
119
  return undefined;
@@ -48,8 +122,9 @@ function probeWorktree(root, exec) {
48
122
  const toplevelLine = lines[0];
49
123
  const gitDirLine = lines[1];
50
124
  const commonDirLine = lines[2];
51
- if (toplevelLine === undefined || gitDirLine === undefined || commonDirLine === undefined)
125
+ if (toplevelLine === undefined || gitDirLine === undefined || commonDirLine === undefined) {
52
126
  return undefined;
127
+ }
53
128
  // `git rev-parse` reports these relative to `root` (the cwd it ran in) unless they point outside
54
129
  // that tree entirely, as `--git-common-dir` does from inside a linked worktree — `resolve` is a
55
130
  // no-op on an already-absolute path either way.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-codegraph-tree-sitter",
3
- "version": "0.3.0",
3
+ "version": "0.4.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.3.0"
54
+ "dsh-plugin-codegraph-service": "^0.4.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@deepseek-ai/cordis": ">=4.0.1",