Files
dsh-recall/lib/walk.js
T
NotExpectedYet de4d020dcb feat: index extra source roots beside the workspace, for the code you write against
The workspace index cannot answer questions about an installed framework or
SDK, and in plugin development that is where most of the reading goes: 64 of
80 tool calls in one real session went to the harness checkout under
/usr/local/lib/node_modules to look up the API being implemented.

A root is entered whatever it is called, and its dependencies are not: a
path ending in node_modules/... is the normal way to name a package tree,
while a node_modules below the root is somebody else's code. A result
carries the label that says which tree it came from and an absolute
sourcePath, the only form a follow-up read can resolve.

The tool description now also claims the move this replaces - searching
before reading files to find out what a module contains.
2026-09-27 07:16:21 +01:00

220 lines
7.3 KiB
JavaScript

/**
* Workspace file discovery.
*
* Two rules shape everything here.
*
* **The walk never follows a symlink.** A symlink loop turns a bounded walk into
* an unbounded one, and a symlink out of the workspace turns "index this
* project" into "index this machine". Both are worse than missing a file.
*
* **The walk is deterministic.** Candidates come back in sorted path order, so
* a partially-completed cold index covers the same prefix on the next run. A
* filesystem-ordered walk would re-embed a different subset every time and
* never converge on a warm index.
*/
import { readdir } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
/**
* Directory names skipped at any depth.
*
* These are dependency stores and build output: large, machine-generated, and
* never the answer to a question about this codebase. Indexing them would
* multiply the embedding cost of a cold start by an order of magnitude and
* return vendored copies of code the user does not own.
*/
export const DEFAULT_SKIP_DIRS = Object.freeze([
'node_modules',
'.git',
'.hg',
'.svn',
'dist',
'build',
'out',
'output',
'target',
'coverage',
'.next',
'.nuxt',
'.svelte-kit',
'.output',
'.turbo',
'.cache',
'.parcel-cache',
'.venv',
'venv',
'__pycache__',
'.mypy_cache',
'.pytest_cache',
'.ruff_cache',
'vendor',
'Pods',
'.idea',
'.vscode',
'.gradle',
'.terraform',
'tmp',
'.tmp',
])
/**
* Extensions indexed by default.
*
* Markup and prose are deliberately absent. A question that names a symbol wants
* the code that defines it, and a README that mentions the symbol is a worse
* answer that still costs tokens. Deployments that want documentation indexed
* add the extension through `extensions` in the config.
*/
export const DEFAULT_EXTENSIONS = Object.freeze([
'ts', 'tsx', 'mts', 'cts',
'js', 'jsx', 'mjs', 'cjs',
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'scala', 'swift',
'c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh', 'cs',
'php', 'pl', 'lua', 'sh', 'bash', 'zsh',
'sql', 'graphql', 'proto', 'vue', 'svelte',
])
/**
* Translate a glob into an anchored regular expression.
*
* Supports `**` (any depth, including none), `*` (within one path segment), `?`
* (one non-separator character) and a trailing `/` (directory and everything
* under it). Written out rather than pulled from a dependency because the plugin
* ships with no build step and no install-time code.
*
* @param {string} pattern glob, using `/` as the separator on every platform
* @returns {RegExp} anchored matcher over a `/`-separated relative path
*/
export function globToRegExp(pattern) {
const normalized = pattern.trim().replace(/\\/g, '/').replace(/^\.\//, '')
let source = ''
for (let at = 0; at < normalized.length; at += 1) {
const char = normalized[at]
if (char === '*') {
const doubled = normalized[at + 1] === '*'
if (doubled) {
// `**/` consumes any number of segments, so `**/foo` also matches `foo`.
if (normalized[at + 2] === '/') {
source += '(?:[^/]*/)*'
at += 2
} else {
source += '.*'
at += 1
}
} else {
source += '[^/]*'
}
} else if (char === '?') {
source += '[^/]'
} else if (char === '/') {
source += '/'
} else {
source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&')
}
}
// A trailing `/` names a directory: match it and everything beneath it.
if (source.endsWith('/')) source += '.*'
return new RegExp(`^(?:${source})$`)
}
/** Whether a `/`-separated relative path is matched by any of `patterns`. */
export function matchesAny(relPath, patterns) {
for (const pattern of patterns) {
if (pattern === '') continue
if (globToRegExp(pattern).test(relPath)) return true
}
return false
}
/** The extension of a path, lowercased, without the dot. */
export function extensionOf(path) {
const name = path.slice(path.lastIndexOf('/') + 1)
const dot = name.lastIndexOf('.')
return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
}
/**
* List indexable files beneath `root`.
*
* @param {string} root absolute directory to walk
* @param {object} [options]
* @param {readonly string[]} [options.extensions] allowed extensions, without dots
* @param {readonly string[]} [options.skipDirs] directory names skipped at any depth
* @param {readonly string[]} [options.exclude] glob patterns over the relative path
* @param {(depth: number, name: string, absolute: string) => boolean} [options.isSkipped]
* decides directory traversal, replacing the `skipDirs` membership test. This is
* the seam an extra index root uses: its top level is a package-manager
* `node_modules` tree, which the built-in list exists to skip, so it must opt out
* for the root and only the root. Depth 0 is the root's own entries.
* @param {number} [options.maxFiles] stop after this many candidates
* @param {number} [options.maxDepth] deepest directory level to enter
* @param {AbortSignal} [options.signal]
* @returns {Promise<{files: {path: string, absolute: string}[], truncated: boolean, skippedFiles: number}>}
*/
export async function listCodeFiles(root, options = {}) {
const {
extensions = DEFAULT_EXTENSIONS,
skipDirs = DEFAULT_SKIP_DIRS,
exclude = [],
isSkipped,
maxFiles = 20_000,
maxDepth = 24,
signal,
} = options
const wanted = new Set(extensions.map((value) => String(value).toLowerCase()))
const skip = new Set(skipDirs)
// Materialised once per walk rather than tested with `isSkipped ?? default` at
// every directory: the hot loop should not carry a branch that cannot change.
const shouldSkip =
isSkipped === undefined
? (depth, name) => skip.has(name)
: (depth, name, absolute) => isSkipped(depth, name, absolute)
const files = []
let truncated = false
let skippedFiles = 0
/** Depth-first, pushing directories in reverse so the pop order is alphabetical. */
const stack = [{ dir: root, depth: 0 }]
while (stack.length > 0) {
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
const { dir, depth } = stack.pop()
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
// An unreadable directory is a gap in the index, not a failure of it.
continue
}
for (const entry of entries) {
// Never traverse a symlink: `dirent.isDirectory()` is false for one, so a
// link to a directory is skipped rather than followed.
if (entry.isSymbolicLink()) {
skippedFiles += 1
continue
}
const absolute = join(dir, entry.name)
const relPath = relative(root, absolute).split(sep).join('/')
if (entry.isDirectory()) {
if (shouldSkip(depth, entry.name, absolute)) continue
if (depth >= maxDepth) continue
if (matchesAny(relPath, exclude)) continue
stack.push({ dir: absolute, depth: depth + 1 })
continue
}
if (!entry.isFile()) continue
if (!wanted.has(extensionOf(entry.name))) continue
if (matchesAny(relPath, exclude)) continue
if (files.length >= maxFiles) {
truncated = true
continue
}
files.push({ path: relPath, absolute })
}
}
files.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
return { files, truncated, skippedFiles }
}