dsh-plugin-codegraph-tree-sitter 0.1.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/walk.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Workspace directory walk: find every file this package can extract from, parse it, and hand back
3
+ * one {@link ExtractedFile} per file that was actually indexed.
4
+ *
5
+ * The walk is deliberately simple: `exclude` entries name directory segments to never descend into
6
+ * (not wildcard globs — the default set needs none), and every bound (`maxFileBytes`, `maxFiles`) is
7
+ * a config field the deployment can retune rather than a constant, per the same-name entries this
8
+ * package's `Config` exposes.
9
+ * @module dsh-plugin-codegraph-tree-sitter/walk
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { readdir, readFile, stat } from 'node:fs/promises';
13
+ import { posix } from 'node:path';
14
+ import { createParser } from "./grammar.js";
15
+ import { extractFile } from "./extract.js";
16
+ import { loadGitignore, matchesGitignore } from "./gitignore.js";
17
+ import { languageFor } from "./languages.js";
18
+ /**
19
+ * Whether one directory entry is excluded from both the index and (for `watcher.ts`) the watch tree —
20
+ * the single definition both consult, so "what the index covers" and "what gets watched" can never
21
+ * drift apart.
22
+ * @param name - the entry's own name (not a path), matched against `excluded` verbatim.
23
+ * @param relativePath - the entry's project-relative path, matched against the `.gitignore` rules.
24
+ * @param isDirectory - whether the entry is a directory, for `.gitignore`'s directory-only rules.
25
+ * @param excluded - directory/file segment names never descended into.
26
+ * @param gitignoreRules - the project's parsed `.gitignore`, or empty when not respected.
27
+ */
28
+ export function isExcluded(name, relativePath, isDirectory, excluded, gitignoreRules) {
29
+ return excluded.has(name) || matchesGitignore(gitignoreRules, relativePath, isDirectory);
30
+ }
31
+ /**
32
+ * Recursively list every candidate file under `projectRoot`, honoring `exclude` and `maxFiles`.
33
+ * @param projectRoot - absolute path of the root to scan.
34
+ * @param config - the walk's bounds.
35
+ * @param signal - aborts the scan.
36
+ * @returns the candidates found, and how many further candidates `maxFiles` cut off.
37
+ */
38
+ async function discover(projectRoot, config, signal) {
39
+ const excluded = new Set(config.exclude);
40
+ const gitignoreRules = config.respectGitignore ? await loadGitignore(projectRoot) : [];
41
+ const candidates = [];
42
+ let overflow = 0;
43
+ async function scan(relativeDir) {
44
+ signal?.throwIfAborted();
45
+ const entries = await readdir(posix.join(projectRoot, relativeDir), { withFileTypes: true });
46
+ for (const entry of entries) {
47
+ const relativePath = relativeDir === '' ? entry.name : `${relativeDir}/${entry.name}`;
48
+ if (isExcluded(entry.name, relativePath, entry.isDirectory(), excluded, gitignoreRules))
49
+ continue;
50
+ if (entry.isDirectory()) {
51
+ await scan(relativePath);
52
+ continue;
53
+ }
54
+ if (!entry.isFile())
55
+ continue;
56
+ const extension = posix.extname(entry.name);
57
+ const spec = languageFor(extension);
58
+ if (spec === undefined)
59
+ continue;
60
+ if (config.languages !== undefined && !config.languages.includes(spec.language))
61
+ continue;
62
+ if (candidates.length >= config.maxFiles) {
63
+ overflow++;
64
+ continue;
65
+ }
66
+ candidates.push({ path: relativePath, absolute: posix.join(projectRoot, relativePath), language: spec.language });
67
+ }
68
+ }
69
+ await scan('');
70
+ return { candidates, overflow };
71
+ }
72
+ /** Parse one candidate, or return `undefined` when it is over {@link WalkConfig.maxFileBytes}. */
73
+ async function parseCandidate(candidate, config, signal) {
74
+ const stats = await stat(candidate.absolute);
75
+ if (stats.size > config.maxFileBytes)
76
+ return undefined;
77
+ const spec = languageFor(posix.extname(candidate.path));
78
+ /* v8 ignore next 2 -- discover() only ever produces a candidate whose extension resolved to a spec. */
79
+ if (spec === undefined)
80
+ return undefined;
81
+ const text = await readFile(candidate.absolute, 'utf8');
82
+ const parser = await createParser(spec);
83
+ try {
84
+ signal?.throwIfAborted();
85
+ const tree = parser.parse(text);
86
+ /* v8 ignore next 2 -- createParser() always assigns a language before returning; parse() returns
87
+ * null only when the parser has none. */
88
+ if (tree === null)
89
+ return undefined;
90
+ const extraction = extractFile(tree, spec);
91
+ return {
92
+ path: candidate.path,
93
+ language: spec.language,
94
+ size: stats.size,
95
+ modifiedAt: stats.mtimeMs,
96
+ contentHash: createHash('sha256').update(text).digest('hex'),
97
+ lineCount: text.split('\n').length,
98
+ extraction,
99
+ };
100
+ }
101
+ finally {
102
+ parser.delete();
103
+ }
104
+ }
105
+ /** Run `tasks` with at most `concurrency` in flight at once, preserving input order in the result. */
106
+ async function mapWithConcurrency(items, concurrency, task) {
107
+ const results = new Array(items.length);
108
+ let next = 0;
109
+ async function worker() {
110
+ for (;;) {
111
+ const index = next++;
112
+ if (index >= items.length)
113
+ return;
114
+ // `index < items.length` above guarantees a defined element; `noUncheckedIndexedAccess` just
115
+ // cannot express that.
116
+ const item = items[index];
117
+ /* v8 ignore next */
118
+ if (item === undefined)
119
+ continue;
120
+ results[index] = await task(item);
121
+ }
122
+ }
123
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
124
+ return results;
125
+ }
126
+ /**
127
+ * Walk `projectRoot` and parse every candidate file it finds.
128
+ * @param projectRoot - absolute path of the workspace to index.
129
+ * @param config - the walk's bounds.
130
+ * @param signal - aborts the walk.
131
+ * @returns the parsed files and how many candidates were skipped (over size, or past `maxFiles`).
132
+ */
133
+ export async function walkAndExtract(projectRoot, config, signal) {
134
+ const { candidates, overflow } = await discover(projectRoot, config, signal);
135
+ const parsed = await mapWithConcurrency(candidates, config.concurrency, candidate => parseCandidate(candidate, config, signal));
136
+ const files = [];
137
+ let filesSkipped = overflow;
138
+ for (const file of parsed) {
139
+ if (file === undefined)
140
+ filesSkipped++;
141
+ else
142
+ files.push(file);
143
+ }
144
+ return { files, filesSkipped };
145
+ }
146
+ //# sourceMappingURL=walk.js.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Whether to actually start watching a root, on top of whether `Config.watch` asked for it.
3
+ *
4
+ * WSL2 mounts a Windows drive over 9P (`/mnt/<drive>/...`), and that filesystem does not deliver
5
+ * inotify events reliably — a watcher that looks alive there silently misses edits, which is worse
6
+ * than not watching at all (the caller believes the index stays fresh and never falls back to a
7
+ * manual `codegraph_index`). This module decides, from injected environment/platform/proc-version
8
+ * probes only, whether to override the user's own `watch: true` for that one case.
9
+ * @module dsh-plugin-codegraph-tree-sitter/watch-policy
10
+ */
11
+ /** Whether watching should actually start, and why it was overridden when it should not. */
12
+ export type WatchDecision = {
13
+ readonly enabled: true;
14
+ } | {
15
+ readonly enabled: false;
16
+ readonly reason: 'ENV_DISABLED' | 'WSL2_MOUNT';
17
+ };
18
+ /** Everything {@link decideWatch} needs, all injected so no test touches the real environment. */
19
+ export interface WatchPolicyInput {
20
+ /** The already-resolved `Config.watch` value; a policy override never turns this on when it is off. */
21
+ readonly configuredWatch: boolean;
22
+ /** Absolute workspace root being considered for watching. */
23
+ readonly root: string;
24
+ /** Process environment to read `CODEGRAPH_NO_WATCH`/`CODEGRAPH_FORCE_WATCH` from. */
25
+ readonly env: NodeJS.ProcessEnv;
26
+ /** `process.platform`, injected so the WSL2 check is only ever exercised via a fake `'linux'`. */
27
+ readonly platform: NodeJS.Platform;
28
+ /** Reads `/proc/version` (or an equivalent), returning `undefined` on any failure. */
29
+ readonly procVersion: () => string | undefined;
30
+ }
31
+ /**
32
+ * Decide whether to start watching `input.root`.
33
+ *
34
+ * Precedence: `CODEGRAPH_NO_WATCH=1` is a total kill switch that never lets any root watch, regardless
35
+ * of `configuredWatch` or the WSL2 heuristic. Otherwise, watching stays off unless `configuredWatch` is
36
+ * `true` — this function only ever narrows an on request, never widens an off one. With watching
37
+ * requested, a WSL2 kernel watching a path under a `/mnt/<drive>` 9P mount is disabled by default,
38
+ * since inotify there does not reliably deliver events; `CODEGRAPH_FORCE_WATCH=1` is the escape hatch
39
+ * for a caller who has verified their own WSL2 setup delivers events anyway.
40
+ */
41
+ export declare function decideWatch(input: WatchPolicyInput): WatchDecision;
42
+ /**
43
+ * The real {@link WatchPolicyInput.procVersion}: reads `/proc/version`, `undefined` on any failure.
44
+ * @param path - overridable only so tests can force both the success and failure path deterministically
45
+ * on any OS — whether the real `/proc/version` exists depends on the platform running the test, which
46
+ * would otherwise make one of these two branches uncovered depending on where CI happens to run.
47
+ */
48
+ export declare function readProcVersion(path?: string): string | undefined;
49
+ //# sourceMappingURL=watch-policy.d.ts.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Whether to actually start watching a root, on top of whether `Config.watch` asked for it.
3
+ *
4
+ * WSL2 mounts a Windows drive over 9P (`/mnt/<drive>/...`), and that filesystem does not deliver
5
+ * inotify events reliably — a watcher that looks alive there silently misses edits, which is worse
6
+ * than not watching at all (the caller believes the index stays fresh and never falls back to a
7
+ * manual `codegraph_index`). This module decides, from injected environment/platform/proc-version
8
+ * probes only, whether to override the user's own `watch: true` for that one case.
9
+ * @module dsh-plugin-codegraph-tree-sitter/watch-policy
10
+ */
11
+ import { readFileSync } from 'node:fs';
12
+ import { posix, sep } from 'node:path';
13
+ /** Matches the "Microsoft"/"microsoft-standard" marker WSL2 kernels put in `/proc/version`. */
14
+ const WSL_KERNEL_MARKER = /microsoft/i;
15
+ /**
16
+ * Decide whether to start watching `input.root`.
17
+ *
18
+ * Precedence: `CODEGRAPH_NO_WATCH=1` is a total kill switch that never lets any root watch, regardless
19
+ * of `configuredWatch` or the WSL2 heuristic. Otherwise, watching stays off unless `configuredWatch` is
20
+ * `true` — this function only ever narrows an on request, never widens an off one. With watching
21
+ * requested, a WSL2 kernel watching a path under a `/mnt/<drive>` 9P mount is disabled by default,
22
+ * since inotify there does not reliably deliver events; `CODEGRAPH_FORCE_WATCH=1` is the escape hatch
23
+ * for a caller who has verified their own WSL2 setup delivers events anyway.
24
+ */
25
+ export function decideWatch(input) {
26
+ if (input.env.CODEGRAPH_NO_WATCH === '1')
27
+ return { enabled: false, reason: 'ENV_DISABLED' };
28
+ if (!input.configuredWatch)
29
+ return { enabled: false, reason: 'ENV_DISABLED' };
30
+ if (input.env.CODEGRAPH_FORCE_WATCH === '1')
31
+ return { enabled: true };
32
+ if (isWsl2MountedRoot(input))
33
+ return { enabled: false, reason: 'WSL2_MOUNT' };
34
+ return { enabled: true };
35
+ }
36
+ /** Whether `input.root` is a WSL2 process watching a path mounted in from the Windows host. */
37
+ function isWsl2MountedRoot(input) {
38
+ if (input.platform !== 'linux')
39
+ return false;
40
+ const version = input.procVersion();
41
+ if (version === undefined || !WSL_KERNEL_MARKER.test(version))
42
+ return false;
43
+ return isDrvfsMountPath(input.root);
44
+ }
45
+ /** Whether an absolute path sits under WSL2's `/mnt/<single-letter-drive>/...` DrvFs mount convention. */
46
+ function isDrvfsMountPath(root) {
47
+ const normalized = root.split(sep).join(posix.sep);
48
+ return /^\/mnt\/[a-z](\/|$)/i.test(normalized);
49
+ }
50
+ /**
51
+ * The real {@link WatchPolicyInput.procVersion}: reads `/proc/version`, `undefined` on any failure.
52
+ * @param path - overridable only so tests can force both the success and failure path deterministically
53
+ * on any OS — whether the real `/proc/version` exists depends on the platform running the test, which
54
+ * would otherwise make one of these two branches uncovered depending on where CI happens to run.
55
+ */
56
+ export function readProcVersion(path = '/proc/version') {
57
+ try {
58
+ return readFileSync(path, 'utf8');
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ //# sourceMappingURL=watch-policy.js.map
@@ -0,0 +1,95 @@
1
+ /**
2
+ * File-watch-driven reindexing: debounce a workspace's file-change events into a bounded number of
3
+ * `sync` calls, using a per-platform strategy chosen to keep the open-descriptor / kernel-watch cost
4
+ * BOUNDED rather than growing with the number of files:
5
+ *
6
+ * - macOS / Windows: a SINGLE recursive `fs.watch(root, {recursive:true})`. This is the fix for the
7
+ * macOS file-table exhaustion a reference implementation hit watching one file descriptor per
8
+ * watched file (tens of thousands of REG fds), which exhausted `kern.maxfiles` and crashed
9
+ * unrelated processes system-wide (see NOTES.local.md). One recursive stream costs O(1)
10
+ * descriptors no matter how large the tree.
11
+ * - Linux: recursive `fs.watch` is unsupported, so one inotify watch is installed per (non-excluded)
12
+ * directory — O(directories), NOT O(files). New directories are picked up dynamically as events
13
+ * reveal them, and {@link WatchConfig.maxWatchedDirectories} bounds inotify usage on pathological
14
+ * monorepos.
15
+ *
16
+ * `sync` is always a full rebuild in this version: the `paths` a debounce window collected are passed
17
+ * through but never consulted by the caller supplied here (`runIndex` reruns `walkAndExtract` over the
18
+ * whole tree regardless). That keeps this file's only new correctness surface confined to "when do we
19
+ * call sync", not "what does sync do with a subset of files" — the latter is deliberately deferred
20
+ * until a real incremental reindex is built (see NOTES.local.md). Because sync is always full, a
21
+ * directory delete or a `mkdir` immediately followed by writes inside it never needs special-casing:
22
+ * the parent directory's own create event is enough to schedule a rebuild that discovers (or removes)
23
+ * everything under the path from scratch, watched or not.
24
+ * @module dsh-plugin-codegraph-tree-sitter/watcher
25
+ */
26
+ /** One raw watch event, in the shape `node:fs`'s `fs.watch` listener callback receives. */
27
+ export type WatchEventListener = (eventType: 'rename' | 'change', filename: string | null) => void;
28
+ /** A watch error, in the shape `fs.watch`'s `'error'` event delivers. */
29
+ export type WatchErrorListener = (error: unknown) => void;
30
+ /** A live OS-level watch, closeable independent of anything else this module tracks. */
31
+ export interface WatchHandle {
32
+ close(): void;
33
+ }
34
+ /**
35
+ * Indirection over `fs.watch` so tests can inject a fake that emits events/errors deterministically —
36
+ * `fs.watch` is a non-configurable module property that can't be spied, and provoking real EMFILE/
37
+ * ENOSPC exhaustion in a test run isn't reliable. Production always uses {@link nodeWatch}.
38
+ */
39
+ export type WatchPrimitive = (path: string, options: {
40
+ readonly recursive: boolean;
41
+ }, onEvent: WatchEventListener, onError: WatchErrorListener) => WatchHandle;
42
+ /** The real `fs.watch`-backed primitive. */
43
+ export declare const nodeWatch: WatchPrimitive;
44
+ /** Why live watching was permanently disabled for this watcher instance. */
45
+ export type DegradeReason = {
46
+ readonly code: 'RESOURCE_EXHAUSTED';
47
+ readonly cause: NodeJS.ErrnoException;
48
+ } | {
49
+ readonly code: 'WATCH_LIMIT_EXCEEDED';
50
+ } | {
51
+ readonly code: 'SYNC_FAILURE_LIMIT';
52
+ readonly cause: unknown;
53
+ };
54
+ /** Configuration a watcher needs to run: scope, timing, and the sync it drives. */
55
+ export interface WatchConfig {
56
+ /** Absolute path of the workspace root to watch. */
57
+ readonly root: string;
58
+ /** Directory/file segment names never watched, mirroring the indexer's own exclusion. */
59
+ readonly exclude: readonly string[];
60
+ /** Also exclude whatever the project root's `.gitignore` names, matching the indexer's own scope. */
61
+ readonly respectGitignore: boolean;
62
+ /** Milliseconds of quiet after the last change before `sync` runs. */
63
+ readonly debounceMs: number;
64
+ /** Hard cap on directories watched individually (Linux only); exceeding it degrades the watcher. */
65
+ readonly maxWatchedDirectories: number;
66
+ /**
67
+ * Run one reindex. `paths` are the project-relative paths a debounce window collected, offered for
68
+ * a future incremental sync to consult; this version's caller always ignores them and rebuilds the
69
+ * whole graph.
70
+ */
71
+ readonly sync: (paths?: readonly string[]) => Promise<{
72
+ filesChanged: number;
73
+ durationMs: number;
74
+ }>;
75
+ /** Fired once, the first time live watching degrades permanently. */
76
+ readonly onDegraded?: (reason: DegradeReason) => void;
77
+ }
78
+ /** A running (or stopped) file watcher for one workspace root. */
79
+ export interface Watcher {
80
+ /** Begin watching. Idempotent while already running; clears a prior degradation and retries. */
81
+ start(): void;
82
+ /** Stop watching and release every OS-level watch. Does not itself count as degradation. */
83
+ stop(): void;
84
+ /** Whether live watching has permanently degraded (until the next {@link start}). */
85
+ isDegraded(): boolean;
86
+ /** Project-relative paths seen since the last debounce window fired a sync. */
87
+ getPendingFiles(): readonly string[];
88
+ }
89
+ /**
90
+ * Build a watcher over one workspace root. Nothing runs until {@link Watcher.start} is called.
91
+ * @param config - scope, timing, and the sync callback to drive.
92
+ * @param primitive - the watch primitive to use; defaults to the real `fs.watch`.
93
+ */
94
+ export declare function createWatcher(config: WatchConfig, primitive?: WatchPrimitive): Watcher;
95
+ //# sourceMappingURL=watcher.d.ts.map
package/lib/watcher.js ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * File-watch-driven reindexing: debounce a workspace's file-change events into a bounded number of
3
+ * `sync` calls, using a per-platform strategy chosen to keep the open-descriptor / kernel-watch cost
4
+ * BOUNDED rather than growing with the number of files:
5
+ *
6
+ * - macOS / Windows: a SINGLE recursive `fs.watch(root, {recursive:true})`. This is the fix for the
7
+ * macOS file-table exhaustion a reference implementation hit watching one file descriptor per
8
+ * watched file (tens of thousands of REG fds), which exhausted `kern.maxfiles` and crashed
9
+ * unrelated processes system-wide (see NOTES.local.md). One recursive stream costs O(1)
10
+ * descriptors no matter how large the tree.
11
+ * - Linux: recursive `fs.watch` is unsupported, so one inotify watch is installed per (non-excluded)
12
+ * directory — O(directories), NOT O(files). New directories are picked up dynamically as events
13
+ * reveal them, and {@link WatchConfig.maxWatchedDirectories} bounds inotify usage on pathological
14
+ * monorepos.
15
+ *
16
+ * `sync` is always a full rebuild in this version: the `paths` a debounce window collected are passed
17
+ * through but never consulted by the caller supplied here (`runIndex` reruns `walkAndExtract` over the
18
+ * whole tree regardless). That keeps this file's only new correctness surface confined to "when do we
19
+ * call sync", not "what does sync do with a subset of files" — the latter is deliberately deferred
20
+ * until a real incremental reindex is built (see NOTES.local.md). Because sync is always full, a
21
+ * directory delete or a `mkdir` immediately followed by writes inside it never needs special-casing:
22
+ * the parent directory's own create event is enough to schedule a rebuild that discovers (or removes)
23
+ * everything under the path from scratch, watched or not.
24
+ * @module dsh-plugin-codegraph-tree-sitter/watcher
25
+ */
26
+ import { readdir, stat } from 'node:fs/promises';
27
+ import { join, sep } from 'node:path';
28
+ import * as fs from 'node:fs';
29
+ import { loadGitignore } from "./gitignore.js";
30
+ import { isExcluded } from "./walk.js";
31
+ /** The real `fs.watch`-backed primitive. */
32
+ export const nodeWatch = (path, options, onEvent, onError) => {
33
+ const watcher = fs.watch(path, { recursive: options.recursive, persistent: true }, onEvent);
34
+ watcher.on('error', onError);
35
+ return { close: () => watcher.close() };
36
+ };
37
+ /** Error codes that mean "the OS ran out of watch resources", across the platforms that report them. */
38
+ const RESOURCE_EXHAUSTION_CODES = new Set(['EMFILE', 'ENFILE', 'ENOSPC']);
39
+ /** Consecutive sync failures tolerated before a watcher gives up and degrades. */
40
+ const SYNC_FAILURE_LIMIT = 3;
41
+ /**
42
+ * Build a watcher over one workspace root. Nothing runs until {@link Watcher.start} is called.
43
+ * @param config - scope, timing, and the sync callback to drive.
44
+ * @param primitive - the watch primitive to use; defaults to the real `fs.watch`.
45
+ */
46
+ export function createWatcher(config, primitive = nodeWatch) {
47
+ const excluded = new Set(config.exclude);
48
+ let gitignoreRules = [];
49
+ const watchedDirs = new Set();
50
+ const handles = [];
51
+ const pendingPaths = new Set();
52
+ let running = false;
53
+ let degraded = false;
54
+ let debounceTimer;
55
+ let syncing = false;
56
+ let rerunNeeded = false;
57
+ let syncFailureCount = 0;
58
+ /** One-way: once degraded, only a fresh `start()` clears it. */
59
+ function degrade(reason) {
60
+ if (degraded)
61
+ return;
62
+ degraded = true;
63
+ teardown();
64
+ config.onDegraded?.(reason);
65
+ }
66
+ /** Close every live watch and clear the debounce timer, without touching `degraded`. */
67
+ function teardown() {
68
+ running = false;
69
+ for (const handle of handles.splice(0))
70
+ handle.close();
71
+ watchedDirs.clear();
72
+ if (debounceTimer !== undefined) {
73
+ clearTimeout(debounceTimer);
74
+ debounceTimer = undefined;
75
+ }
76
+ }
77
+ function errorCode(error) {
78
+ return typeof error === 'object' && error !== null && 'code' in error
79
+ ? String(error.code)
80
+ : undefined;
81
+ }
82
+ function handleWatchError(error) {
83
+ if (!running || degraded)
84
+ return;
85
+ const code = errorCode(error);
86
+ if (code !== undefined && RESOURCE_EXHAUSTION_CODES.has(code)) {
87
+ degrade({ code: 'RESOURCE_EXHAUSTED', cause: error });
88
+ }
89
+ // An error this watcher doesn't recognize as resource exhaustion is left to the OS-level watch's
90
+ // own reporting; it doesn't by itself prove live watching can no longer make progress.
91
+ }
92
+ function scheduleSync() {
93
+ if (debounceTimer !== undefined)
94
+ clearTimeout(debounceTimer);
95
+ debounceTimer = setTimeout(runSync, config.debounceMs);
96
+ }
97
+ function runSync() {
98
+ // Also called directly (not just as the timer callback) when a queued rerun fires immediately;
99
+ // clear any timer a later event armed in the meantime so it can't fire a redundant, empty sync.
100
+ if (debounceTimer !== undefined) {
101
+ clearTimeout(debounceTimer);
102
+ debounceTimer = undefined;
103
+ }
104
+ if (syncing) {
105
+ // A sync already in flight will miss whatever just fired this timer; ask for one more round
106
+ // once it settles rather than starting a second sync concurrently.
107
+ rerunNeeded = true;
108
+ return;
109
+ }
110
+ const batch = [...pendingPaths];
111
+ pendingPaths.clear();
112
+ syncing = true;
113
+ // `batch` is never actually empty here: the only two ways into this function are the debounce
114
+ // timer (armed exclusively by handleChange(), right after it adds to pendingPaths) and a queued
115
+ // rerun (only set when handleChange() re-armed the timer while a sync was already in flight,
116
+ // which itself added to pendingPaths). The `undefined` branch is API contract, not reachable code.
117
+ /* v8 ignore next */
118
+ config.sync(batch.length === 0 ? undefined : batch)
119
+ .then(() => {
120
+ syncFailureCount = 0;
121
+ })
122
+ .catch((cause) => {
123
+ // The sync never committed anything for this batch — put it back so the next run retries it,
124
+ // exactly like a fresh set of events for the same paths would.
125
+ for (const path of batch)
126
+ pendingPaths.add(path);
127
+ syncFailureCount++;
128
+ if (syncFailureCount >= SYNC_FAILURE_LIMIT)
129
+ degrade({ code: 'SYNC_FAILURE_LIMIT', cause });
130
+ })
131
+ .finally(() => {
132
+ syncing = false;
133
+ if (rerunNeeded) {
134
+ rerunNeeded = false;
135
+ // Immediate, not another scheduleSync(): the paths behind this round already sat through a
136
+ // full debounce window once (that's what set rerunNeeded), so making them wait out a second
137
+ // one would only delay a sync that's already due.
138
+ runSync();
139
+ }
140
+ });
141
+ }
142
+ /**
143
+ * Whether one path segment, already known to sit inside in-scope ancestors, is itself in scope.
144
+ * Used while recursing the initial watch tree, where every ancestor was already checked on the way
145
+ * down — checking only the leaf segment here is enough, and cheaper than re-testing the whole path.
146
+ */
147
+ function segmentInScope(name, relativePath, isDirectory) {
148
+ return !isExcluded(name, relativePath, isDirectory, excluded, gitignoreRules);
149
+ }
150
+ /**
151
+ * Whether a path an `fs.watch` event named — possibly several segments deep, with no guarantee any
152
+ * ancestor was checked first (the recursive macOS/Windows watch reports the whole subtree in one
153
+ * stream) — is in scope. Walks every prefix from the root down, exactly like the initial directory
154
+ * walk would if it descended this far, so `node_modules/dep/index.js` is excluded by its
155
+ * `node_modules` ancestor even though the leaf name itself is unremarkable.
156
+ */
157
+ function pathInScope(relativePath, leafIsDirectory) {
158
+ const segments = relativePath.split('/');
159
+ let prefix = '';
160
+ for (const [index, name] of segments.entries()) {
161
+ prefix = index === 0 ? name : `${prefix}/${name}`;
162
+ const isDirectory = index < segments.length - 1 || leafIsDirectory;
163
+ if (!segmentInScope(name, prefix, isDirectory))
164
+ return false;
165
+ }
166
+ return true;
167
+ }
168
+ /**
169
+ * Record one in-scope change and arm the debounce. Also picks up a directory created after startup
170
+ * so Linux's per-directory watch tree grows with the workspace instead of only covering what existed
171
+ * at `start()`.
172
+ */
173
+ function handleChange(relativePath) {
174
+ if (!running || degraded)
175
+ return;
176
+ if (relativePath === '')
177
+ return;
178
+ if (!pathInScope(relativePath, false))
179
+ return;
180
+ pendingPaths.add(relativePath);
181
+ scheduleSync();
182
+ if (process.platform !== 'darwin' && process.platform !== 'win32' && !watchedDirs.has(relativePath)) {
183
+ // Fire-and-forget: a directory that turns out not to exist (a file event, or something already
184
+ // removed again) simply fails the stat and watchDirectory() is never called.
185
+ void maybeWatchNewDirectory(relativePath);
186
+ }
187
+ }
188
+ async function maybeWatchNewDirectory(relativePath) {
189
+ let isDirectory;
190
+ try {
191
+ isDirectory = (await stat(join(config.root, relativePath))).isDirectory();
192
+ }
193
+ catch {
194
+ return;
195
+ }
196
+ // `running`/`degraded` are re-checked inside watchDirectory() itself; no need to duplicate that
197
+ // check here too.
198
+ if (!isDirectory || watchedDirs.has(relativePath) || !pathInScope(relativePath, true))
199
+ return;
200
+ await watchDirectory(relativePath);
201
+ }
202
+ /** Normalize an `fs.watch` filename (native separators, possibly nested under `recursive`) to posix. */
203
+ function toRelativePosix(filename) {
204
+ return filename.split(sep).join('/');
205
+ }
206
+ function startRecursive() {
207
+ try {
208
+ const handle = primitive(config.root, { recursive: true }, (_eventType, filename) => {
209
+ if (filename === null)
210
+ return;
211
+ handleChange(toRelativePosix(filename));
212
+ }, handleWatchError);
213
+ handles.push(handle);
214
+ }
215
+ catch (error) {
216
+ handleWatchError(error);
217
+ // A platform that claims to support recursive watch but rejects this call outright (not just an
218
+ // ENOSYS at the fs.watch layer, which never reaches here) has nothing more this watcher can try.
219
+ }
220
+ }
221
+ /**
222
+ * Install one inotify watch on `relativePath` (project-relative; `''` is the root) and recurse into
223
+ * its non-excluded subdirectories. Degrades the whole watcher, rather than silently covering only
224
+ * part of the tree, once {@link WatchConfig.maxWatchedDirectories} is reached.
225
+ */
226
+ async function watchDirectory(relativePath) {
227
+ if (!running || degraded || watchedDirs.has(relativePath))
228
+ return;
229
+ if (watchedDirs.size >= config.maxWatchedDirectories) {
230
+ degrade({ code: 'WATCH_LIMIT_EXCEEDED' });
231
+ return;
232
+ }
233
+ const absolute = join(config.root, relativePath);
234
+ let handle;
235
+ try {
236
+ handle = primitive(absolute, { recursive: false }, (_eventType, filename) => {
237
+ if (filename === null)
238
+ return;
239
+ const childPosix = toRelativePosix(filename);
240
+ handleChange(relativePath === '' ? childPosix : `${relativePath}/${childPosix}`);
241
+ }, handleWatchError);
242
+ }
243
+ catch (error) {
244
+ handleWatchError(error);
245
+ return;
246
+ }
247
+ handles.push(handle);
248
+ watchedDirs.add(relativePath);
249
+ let entries;
250
+ try {
251
+ entries = await readdir(absolute, { withFileTypes: true });
252
+ }
253
+ catch {
254
+ // The directory disappeared between being discovered and being listed; its own watch (or the
255
+ // parent's) already reported enough to trigger a sync that reconciles this.
256
+ return;
257
+ }
258
+ for (const entry of entries) {
259
+ // Not re-checking `running`/`degraded` here: a stop/degrade mid-loop just means the recursive
260
+ // watchDirectory() call below no-ops immediately (same check, at the top of that call).
261
+ if (!entry.isDirectory())
262
+ continue;
263
+ const childPath = relativePath === '' ? entry.name : `${relativePath}/${entry.name}`;
264
+ if (!segmentInScope(entry.name, childPath, true))
265
+ continue;
266
+ await watchDirectory(childPath);
267
+ }
268
+ }
269
+ return {
270
+ start() {
271
+ if (running)
272
+ return;
273
+ running = true;
274
+ degraded = false;
275
+ syncFailureCount = 0;
276
+ pendingPaths.clear();
277
+ if (process.platform === 'darwin' || process.platform === 'win32') {
278
+ startRecursive();
279
+ return;
280
+ }
281
+ void (async () => {
282
+ gitignoreRules = config.respectGitignore ? await loadGitignore(config.root) : [];
283
+ if (!running)
284
+ return;
285
+ await watchDirectory('');
286
+ })();
287
+ },
288
+ stop() {
289
+ teardown();
290
+ },
291
+ isDegraded() {
292
+ return degraded;
293
+ },
294
+ getPendingFiles() {
295
+ return [...pendingPaths];
296
+ },
297
+ };
298
+ }
299
+ //# sourceMappingURL=watcher.js.map