socraticode
Version:
SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.
315 lines • 14 kB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import fs from "node:fs";
import path from "node:path";
import ignore from "ignore";
import { logger } from "./logger.js";
const DEFAULT_IGNORE_PATTERNS = [
"node_modules",
".git",
".svn",
".hg",
"dist",
"build",
"out",
".next",
".nuxt",
"__pycache__",
"*.pyc",
".venv",
// Anchored, unlike the rest: `venv` and `env` are a virtualenv's names at the
// root of a project, and ordinary module names anywhere below it. Unanchored
// they matched at any depth and quietly deleted source — `clap_complete/src/env/`
// is compiled by cargo and listed in its dep-info, yet was not even a node of
// the graph, while `pub mod engine;` two lines above it drew its edges; the
// same for `tracing-subscriber/src/filter/env/`. In a 245-crate registry
// sample they are the only two, which is the point: the loss is total for the
// crate it hits and invisible everywhere else.
//
// A virtualenv nested deeper stays out by the two routes that already cover
// it: `.venv`, the common spelling, is still matched at any depth, and a
// checked-in project lists its own in `.gitignore`, which this filter reads.
"/venv",
"/env",
".tox",
"target",
"_build",
"deps",
"bin/Debug",
"bin/Release",
"obj",
".gradle",
".idea",
".vscode",
".vs",
"*.min.js",
"*.min.css",
"*.map",
"*.lock",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"Cargo.lock",
"Gemfile.lock",
"poetry.lock",
"*.log",
"*.tmp",
"*.swp",
"*.swo",
".DS_Store",
"Thumbs.db",
"coverage",
".nyc_output",
".cache",
".parcel-cache",
".turbo",
"vendor",
".dart_tool",
];
/**
* Build an ignore filter for a project directory.
*
* Combines (in order):
* 1. Built-in defaults (node_modules, .git, dist, build, lock files, etc.)
* 2. .gitignore files (root + nested) — unless RESPECT_GITIGNORE=false
* 3. Python and conda environments found by their markers, as literal prefixes
* 4. .socraticodeignore — optional project-specific exclusions
*
* Set env RESPECT_GITIGNORE=false to skip .gitignore processing entirely.
*
* The two status lines below log at debug, not info: context-artifact reads
* build a filter per artifact on every staleness check, so an info line here
* would fire once per artifact per context search.
*/
export function createIgnoreFilter(projectPath) {
const ig = ignore();
const environments = [];
const sources = [];
// Default patterns
ig.add(DEFAULT_IGNORE_PATTERNS);
// .gitignore (unless explicitly disabled)
const respectGitignore = (process.env.RESPECT_GITIGNORE ?? "true").toLowerCase() !== "false";
if (respectGitignore) {
// Root .gitignore
const rootGitignore = path.join(projectPath, ".gitignore");
if (fs.existsSync(rootGitignore)) {
const content = fs.readFileSync(rootGitignore, "utf-8");
ig.add(content);
sources.push({ path: ".gitignore", content });
}
}
else {
logger.debug("Skipping .gitignore processing (RESPECT_GITIGNORE=false)");
}
// The same walk finds the nested .gitignore files and the virtualenvs, and it
// runs whether or not .gitignore is respected: a virtualenv is not a project
// preference, it is a directory of installed libraries that no reading of the
// tree should call source.
scanNestedIgnoreSources(projectPath, projectPath, ig, environments, respectGitignore, sources);
// .socraticodeignore
const socraticodeignorePath = path.join(projectPath, ".socraticodeignore");
if (fs.existsSync(socraticodeignorePath)) {
const content = fs.readFileSync(socraticodeignorePath, "utf-8");
ig.add(content);
sources.push({ path: ".socraticodeignore", content });
logger.debug("Loaded .socraticodeignore rules");
}
return {
ignores: (relativePath) => {
const byPattern = ig.test(relativePath);
if (byPattern.unignored)
return false;
return byPattern.ignored || isUnderAnEnvironment(relativePath, environments);
},
// Normalised here, as `shouldIgnore` normalises for `ignores`: a caller on
// Windows hands over `backend\env`, and the roots are kept with `/`
// (review finding).
isEnvironmentRoot: (relativePath) => {
const normalized = relativePath.split(path.sep).join("/");
return environments.includes(normalized.endsWith("/") ? normalized : `${normalized}/`);
},
sources,
environments,
};
}
/**
* Whether a change at this path can change what the filter answers: it is one
* of the environment markers the walk looks for, or lies inside one.
*
* The rules a filter carries are read off the tree once, when it is built, and
* an environment is recognised by a file or directory that may appear or
* vanish while the tree is being watched. A watcher that ran every event
* through its filter first never saw either: a new `pyvenv.cfg` is not an
* indexable file, and the one being deleted sits under a directory the stale
* filter still excludes (review finding). The name is enough here — the event
* only schedules a reconciliation, and the rebuilt filter reads the shape.
* `conda-meta` is matched as any segment, since the directory's creation
* arrives with the files inside it.
*/
export function isEnvironmentMarker(relativePath) {
const segments = relativePath.split(path.sep).join("/").split("/");
return segments[segments.length - 1] === "pyvenv.cfg" || segments.includes("conda-meta");
}
/**
* Whether the path is one of the discovered environment directories or lies
* beneath one. `environments` holds each as `dir/` relative to the root, so
* `env?/lib/dep.py` and the directory itself, asked as `env?/` or `env?`, all
* answer true, and `env?.d/x.py` does not.
*/
function isUnderAnEnvironment(relativePath, environments) {
return environments.some((dir) => relativePath.startsWith(dir) || relativePath === dir.slice(0, -1));
}
/**
* What the path is, or null when the question cannot be answered.
*
* The `throwIfNoEntry` option only covers a missing entry; an unreadable parent
* directory still throws EACCES, and the `existsSync` this replaced threw for
* nothing at all. A marker we cannot stat is simply not a marker.
*
* `lstat`, not `stat`: a symbolic link is answered as a link, never as what it
* points to. That is the same rule the walk applies one level up — a
* `readdirSync` entry that is a link is not a directory to it, so a linked
* `venv/` is never entered — and the two questions should not disagree. A link
* named `pyvenv.cfg` in a source directory otherwise excluded that whole
* directory (review finding); and no tool writes its marker as a link, so
* nothing real is given up.
*/
function statOrNull(candidate) {
try {
return fs.lstatSync(candidate, { throwIfNoEntry: false }) ?? null;
}
catch {
return null;
}
}
/** Whether the path is a file, answering false for a directory or nothing. */
function isFile(candidate) {
return statOrNull(candidate)?.isFile() ?? false;
}
/** Whether the path is a directory, answering false for a file or nothing. */
function isDirectory(candidate) {
return statOrNull(candidate)?.isDirectory() ?? false;
}
/**
* Whether the directory is an environment: it carries a marker, in the shape
* its tool writes it. Two markers, because two tools build these directories —
* `pyvenv.cfg` for a PEP 405 virtualenv, `conda-meta/` for a conda
* environment. Both hold installed libraries and neither is source.
*
* The shape matters. Merely existing is not enough: a source directory holding
* a file named `conda-meta` would otherwise disappear whole, and a discarded
* source file costs far more than a kept one.
*
* Shared with the watcher, which asks it of a directory that has just
* appeared: an environment moved into place arrives as one event on the
* directory, and nothing on the marker inside it.
*/
export function isEnvironmentDirectory(dirPath) {
return isFile(path.join(dirPath, "pyvenv.cfg")) || isDirectory(path.join(dirPath, "conda-meta"));
}
/**
* Walk the subdirectories once, collecting what the tree itself says should be
* ignored: the rules of every nested .gitignore, and every environment
* directory holding installed libraries.
*
* A virtualenv is recognised by the `pyvenv.cfg` PEP 405 puts at its root, not
* by its directory's name. The name alone cannot do it: `venv` and `env` are
* also ordinary module names, and matching them at any depth deleted real
* source — `clap_complete/src/env/`, which cargo compiles, was not even a node.
* They are matched at the project root only now, and the proof covers what that
* anchoring gives up: a virtualenv nested deeper, whatever it is called.
*
* The extra cost is up to two `lstat` per directory visited — `pyvenv.cfg`,
* then `conda-meta` — in a walk that was already making an `existsSync` for
* `.gitignore`. Measured on a 1,916-directory registry cache: 121 ms for the
* whole filter against 125 ms on `main`, inside the noise.
*/
function scanNestedIgnoreSources(rootPath, currentPath, ig, environments, readGitignores, sources) {
let entries;
try {
entries = fs.readdirSync(currentPath, { withFileTypes: true });
}
catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory())
continue;
const dirName = entry.name;
const dirPath = path.join(currentPath, dirName);
// Checked before the skip list below, which would otherwise walk past a
// `venv/` without ever looking inside it. See `isEnvironmentDirectory` for
// the two markers and why their shape matters.
if (isEnvironmentDirectory(dirPath)) {
const relDir = path.relative(rootPath, dirPath).split(path.sep).join("/");
// Kept as the literal prefix it is, never as a pattern — see
// `IgnoreFilter`. A prefix is anchored by nature: `toolbox/` here is the
// directory under the root and nothing else, where the pattern
// `toolbox/` once matched that name at every depth and deleted
// `packages/app/toolbox/` too, in any language.
if (relDir)
environments.push(`${relDir}/`);
continue;
}
// Skip directories we know should be ignored.
//
// `venv` is not among them by name any more, and that is the same decision
// as anchoring the default patterns rather than a separate one. Skipping it
// wherever it stood stopped this walk at any directory so called, so it never reached
// the marker of a real environment nested under one:
// `crates/venv/backend/env/` had its installed libraries indexed, because
// the walk turned back two levels above the `pyvenv.cfg` that would have
// excluded them, and a nested `.gitignore` below it stopped being read.
//
// `.venv` stays: the default patterns ignore that spelling at every depth,
// so descending into it would look for a marker in a directory already
// gone. The same argument holds for `venv` and `env` directly under the
// root, which `/venv` and `/env` above already exclude — a virtualenv old
// enough to have written no `pyvenv.cfg` would otherwise be walked to its
// `site-packages` reading `.gitignore` files for nothing. Only at the root:
// one level down those names are ordinary modules again.
if (dirName === "node_modules" || dirName === ".git" || dirName === ".svn" ||
dirName === ".hg" || dirName === "dist" || dirName === "build" ||
dirName === "__pycache__" || dirName === ".venv" ||
dirName === "target" || dirName === ".gradle" || dirName === ".next" ||
(currentPath === rootPath && (dirName === "venv" || dirName === "env"))) {
continue;
}
const gitignorePath = path.join(dirPath, ".gitignore");
if (readGitignores && fs.existsSync(gitignorePath)) {
const content = fs.readFileSync(gitignorePath, "utf-8");
const relDir = path.relative(rootPath, dirPath).split(path.sep).join("/");
// Recorded on the read, not on the patterns: a file contributing nothing
// today is still a file whose next line changes what this filter answers.
sources.push({ path: `${relDir}/.gitignore`, content });
// Prefix each pattern with the relative directory
const lines = content.split("\n");
const prefixedPatterns = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#"))
continue;
// Handle negation patterns
if (trimmed.startsWith("!")) {
prefixedPatterns.push(`!${relDir}/${trimmed.slice(1)}`);
}
else {
prefixedPatterns.push(`${relDir}/${trimmed}`);
}
}
if (prefixedPatterns.length > 0) {
ig.add(prefixedPatterns);
}
}
// Recurse into subdirectory
scanNestedIgnoreSources(rootPath, dirPath, ig, environments, readGitignores, sources);
}
}
/**
* Check if a relative path should be ignored.
*/
export function shouldIgnore(ig, relativePath) {
const normalized = relativePath.split(path.sep).join("/");
return ig.ignores(normalized);
}
//# sourceMappingURL=ignore.js.map