socraticode
Version:
SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.
542 lines • 24.6 kB
JavaScript
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { EXTENSION_LANGUAGE_MAP, hashContent, indexExtensionlessEnabled, MAX_GRAPH_FILE_BYTES, SOCRATICODE_VERSION, toForwardSlash, } from "../constants.js";
import { detectionHeadWindow, readFileHead } from "./extensionless.js";
import { createIgnoreFilter, shouldIgnore } from "./ignore.js";
// ── What the graph was built from ────────────────────────────────────────
/**
* The shape version of a persisted {@link GraphInputRecord}.
*
* A record carrying any other version is treated exactly as a missing one: one
* full rebuild, which writes the current shape back. Nothing migrates, nothing
* is re-indexed, and no operator action is involved.
*/
export const GRAPH_INPUTS_VERSION = 1;
/** Sorts record keys into one deterministic order, wherever they are written. */
const byKey = ([a], [b]) => a < b ? -1 : a > b ? 1 : 0;
const sortedRecord = (entries) => Object.fromEntries([...entries].sort(byKey));
/**
* Hash of the inputs the build reads from configuration rather than from the
* tree: which extra extensions count as source, which extensions are mapped to
* another language's grammar, which configured Python roots are searched and
* in what order, whether dotfiles and `.gitignore` are honoured, and how large
* a file the graph will still read.
*/
/**
* Which parsers were available to the build, as one hash.
*
* Names only, never the error text of a failed load: the text carries paths
* and messages that differ between hosts for reasons that have nothing to do
* with what the build could parse, and would make the fingerprint unstable in
* exactly the situation it exists to detect.
*/
export function graphCapabilitiesHash(input) {
return hashContent(JSON.stringify({
loadedGrammars: [...input.loadedGrammars].sort(),
gdscript: input.gdscript,
elixirTemplates: input.elixirTemplates,
}));
}
export function graphSettingsHash(extraExtensions, pythonRoots = []) {
return hashContent(JSON.stringify({
extraExtensions: [...extraExtensions].sort(),
// Preserve the pre-pythonRoots fingerprint when the feature is unused,
// so an upgrade alone does not rebuild every existing project's graph.
// Order is significant when roots are configured because they are
// searched in declared order.
...(pythonRoots.length > 0 ? { pythonRoots: [...pythonRoots] } : {}),
// Decides whether an extensionless file can become a node at all, so
// toggling it moves the node set with no file on disk changing.
indexExtensionless: indexExtensionlessEnabled(),
extensionLanguageMap: [...EXTENSION_LANGUAGE_MAP.entries()].sort(byKey),
includeDotFiles: (process.env.INCLUDE_DOT_FILES ?? "false").toLowerCase() === "true",
respectGitignore: (process.env.RESPECT_GITIGNORE ?? "true").toLowerCase() !== "false",
maxGraphFileBytes: MAX_GRAPH_FILE_BYTES,
}));
}
const hashEntryNames = (names) => hashContent(names.sort().join("\n"));
const entryName = (entry) => entry.isDirectory() ? `${entry.name}/` : entry.name;
/**
* Apply the ignore filter to one directory's entries, and hash what it saw.
*
* The single place the filter is applied to a listing: the discovery walk
* iterates the `kept` entries this returns rather than filtering again, and
* the rebuild check re-reads the directory through this same function. Neither
* can drift from the other about which entries counted, because neither has
* its own opinion.
*/
export function scanDirectory(ig, projectPath, dir, entries) {
const kept = [];
for (const entry of entries) {
const relPath = toForwardSlash(path.relative(projectPath, path.join(dir, entry.name)));
if (shouldIgnore(ig, entry.isDirectory() ? `${relPath}/` : relPath))
continue;
kept.push(entry);
}
return {
kept,
listing: {
all: hashEntryNames(entries.map(entryName)),
kept: hashEntryNames(kept.map(entryName)),
},
};
}
/**
* Raised by {@link GraphInputRecorder.finish} when the build observed one file
* two irreconcilable ways.
*
* The build reads some sources twice — resolver setup builds its namespace,
* FQCN, module and `class_name` maps before the main loop parses the same
* files — so a file edited in between leaves resolver output describing one
* version while nodes, imports and symbols describe another. That graph is
* internally mixed, and a record of either version would let the next update
* call it current. Neither half is worth persisting, so the build is abandoned
* and retried from a fresh scan instead.
*/
export class GraphInputsMovedDuringBuild extends Error {
/** Project-relative path → what disagreed about it. */
conflicts;
constructor(conflicts) {
const [first] = [...conflicts];
super(`${conflicts.size} graph input(s) changed while the graph was being built` +
(first ? ` — ${first[0]}: ${first[1]}` : ""));
this.name = "GraphInputsMovedDuringBuild";
this.conflicts = conflicts;
}
}
export function createGraphInputRecorder(projectRoot) {
const root = path.resolve(projectRoot);
const files = new Map();
const heads = new Map();
const presence = new Map();
const realpaths = new Map();
const unreadable = new Set();
const unreadableDirectories = new Set();
const directories = new Map();
/**
* Head-window hash of every file read whole, so a head-read arriving after
* one can be checked against the same bytes rather than guessed at. Never
* persisted — the content hash it belongs to already covers it.
*/
const windowOfRead = new Map();
const conflicts = new Map();
const rel = (absolutePath) => toForwardSlash(path.relative(root, path.resolve(absolutePath))) || ".";
/** First conflict per path wins, for the same reason first observations do. */
const conflict = (key, detail) => {
if (!conflicts.has(key))
conflicts.set(key, detail);
};
return {
read(absolutePath, content) {
const key = rel(absolutePath);
const hash = hashContent(content);
const alreadyRead = files.get(key);
if (alreadyRead !== undefined) {
// Identical repeat: the resolver pass and the build loop saw one file.
if (alreadyRead !== hash)
conflict(key, "read twice, with different content each time");
return;
}
const headSeen = heads.get(key);
if (headSeen !== undefined) {
// Discovery head-read it, and now it has been read whole. Valid only if
// the window detection scored is still the same bytes. The full hash is
// what gets kept: it subsumes the window — a change inside the window
// changes the whole-file hash too — where keeping the head would leave
// the record blind to everything past it.
if (headSeen !== hashContent(detectionHeadWindow(content))) {
conflict(key, "its detection head no longer matches the file read after it");
return;
}
heads.delete(key);
}
if (unreadable.has(key)) {
conflict(key, "could not be read, then read");
return;
}
files.set(key, hash);
windowOfRead.set(key, hashContent(detectionHeadWindow(content)));
// A whole-file read supersedes a sighting that never opened it.
presence.delete(key);
},
head(absolutePath, headText) {
const key = rel(absolutePath);
const hash = hashContent(headText);
const windowRead = windowOfRead.get(key);
if (windowRead !== undefined) {
if (windowRead !== hash) {
conflict(key, "its detection head no longer matches the file read before it");
}
return;
}
const headSeen = heads.get(key);
if (headSeen !== undefined) {
if (headSeen !== hash)
conflict(key, "head-read twice, with different content each time");
return;
}
if (unreadable.has(key)) {
conflict(key, "could not be read, then head-read");
return;
}
heads.set(key, hash);
},
present(absolutePath, size) {
const key = rel(absolutePath);
if (!files.has(key) && !heads.has(key))
presence.set(key, size ?? -1);
},
realpath(absolutePath, canonicalPath) {
const key = rel(absolutePath);
const hash = canonicalPath === null ? null : hashContent(toForwardSlash(canonicalPath));
if (realpaths.has(key) && realpaths.get(key) !== hash) {
conflict(key, "resolved twice, with different targets each time");
return;
}
realpaths.set(key, hash);
},
unreadable(absolutePath) {
const key = rel(absolutePath);
// A file the build already read and then could not read is the same mixed
// snapshot the other way round: whatever was built from those bytes
// stands on content this record would no longer describe.
if (files.has(key) || heads.has(key)) {
conflict(key, "read, then could not be read");
return;
}
unreadable.add(key);
},
directory(absolutePath, listing) {
const key = rel(absolutePath);
directories.set(key, listing);
unreadableDirectories.delete(key);
},
unreadableDirectory(absolutePath) {
const key = rel(absolutePath);
if (!directories.has(key))
unreadableDirectories.add(key);
},
finish(extraExtensions, capabilities, pythonRoots = []) {
if (conflicts.size > 0)
throw new GraphInputsMovedDuringBuild(new Map(conflicts));
return {
version: GRAPH_INPUTS_VERSION,
builtByVersion: SOCRATICODE_VERSION,
capabilities,
files: sortedRecord(files),
heads: sortedRecord(heads),
presence: sortedRecord(presence),
...(realpaths.size > 0 ? { realpaths: sortedRecord(realpaths) } : {}),
unreadable: [...unreadable].sort(),
unreadableDirectories: [...unreadableDirectories].sort(),
directories: sortedRecord(directories),
settings: graphSettingsHash(extraExtensions, pythonRoots),
};
},
};
}
const isStringMap = (value) => value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.values(value).every((v) => typeof v === "string");
/**
* A record whose shape this build understands, or null.
*
* Persisted as a JSON string beside the graph, so the string form is what
* comes back and is accepted here; anything that fails to parse, or parses to
* something that is not this shape, is null — which the caller reads the same
* way as no record at all.
*/
export function parseGraphInputRecord(stored) {
let candidate = stored;
if (typeof candidate === "string") {
try {
candidate = JSON.parse(candidate);
}
catch {
return null;
}
}
if (candidate === null || typeof candidate !== "object")
return null;
const record = candidate;
if (record.version !== GRAPH_INPUTS_VERSION)
return null;
if (typeof record.builtByVersion !== "string")
return null;
if (typeof record.settings !== "string")
return null;
if (typeof record.capabilities !== "string")
return null;
if (!isStringMap(record.files))
return null;
if (!isStringMap(record.heads))
return null;
if (record.realpaths !== undefined && (record.realpaths === null || typeof record.realpaths !== "object" || Array.isArray(record.realpaths)
|| Object.values(record.realpaths).some((target) => target !== null && typeof target !== "string")))
return null;
if (record.directories === null ||
typeof record.directories !== "object" ||
Array.isArray(record.directories) ||
Object.values(record.directories).some((listing) => listing === null ||
typeof listing !== "object" ||
typeof listing.all !== "string" ||
typeof listing.kept !== "string")) {
return null;
}
if (!Array.isArray(record.unreadable) || record.unreadable.some((p) => typeof p !== "string")) {
return null;
}
if (!Array.isArray(record.unreadableDirectories) ||
record.unreadableDirectories.some((p) => typeof p !== "string")) {
return null;
}
if (record.presence === null ||
typeof record.presence !== "object" ||
Array.isArray(record.presence) ||
Object.values(record.presence).some((size) => typeof size !== "number")) {
return null;
}
return record;
}
/** Files hashed per round when the record has inputs the index does not cover. */
const HASH_BATCH = 32;
/** Presence-only inputs stat'd per round. */
const STAT_BATCH = 64;
/** Directories listed per round. */
const LISTING_BATCH = 64;
const rebuildFor = (reason) => ({
rebuild: true,
reason,
graphExists: true,
});
async function inBatches(items, size, run, inspect) {
for (let i = 0; i < items.length; i += size) {
const batch = items.slice(i, i + size);
const results = await Promise.all(batch.map(run));
for (let j = 0; j < batch.length; j++) {
const decision = inspect(batch[j], results[j]);
if (decision)
return decision;
}
}
return null;
}
/**
* Whether the graph has to be rebuilt for this change.
*
* Conservative in every direction that is not proven: an unreadable or
* unrecognised record, a version or settings change, any addition, and any
* recorded input that changed, vanished, or cannot be read all rebuild. The
* skip is reached only by checking every recorded input and finding all of
* them unchanged.
*
* Ordered cheapest first — the checks that need no I/O, then stats, then head
* reads, then whole-file hashes, and last the directory listings, which are
* the only check that walks anything.
*/
export async function decideGraphRebuild(projectRoot, stored, change, extraExtensions, capabilities, pythonRoots = []) {
const record = parseGraphInputRecord(stored);
if (!record) {
// A graph is there; what is missing is any account of what it was built
// from. That is the legacy case, and it rebuilds once and writes one
// however quiet the update was — `graphExists` is what says so.
return rebuildFor("no usable record of what the graph was built from");
}
if (record.capabilities !== capabilities) {
// Same version, different parsers: the optional grammars and the GDScript
// addon ship platform-specific prebuilds, so a graph built where one was
// missing holds none of the edges it would have found.
return rebuildFor("the parsers available to the build changed");
}
if (record.builtByVersion !== SOCRATICODE_VERSION) {
// A persisted graph is served unchanged across upgrades, so one cut before
// a resolver shipped keeps answering as if that resolver did not exist —
// the reasoning already recorded in `builtByVersion` (issue #120). One
// rebuild on the first update after an upgrade settles it, and covers
// every compile-time constant the build reads in the same stroke.
return rebuildFor(`built by SocratiCode ${record.builtByVersion}, running ${SOCRATICODE_VERSION}`);
}
if (record.settings !== graphSettingsHash(extraExtensions, pythonRoots)) {
return rebuildFor("graph configuration changed since the last build");
}
if (change.hasAdditions) {
return rebuildFor("files were added");
}
const root = path.resolve(projectRoot);
// Set lookups first: they settle the common case without touching the disk.
const needHashing = [];
for (const [relativePath, recordedHash] of Object.entries(record.files)) {
if (change.removed.has(relativePath))
return rebuildFor(`graph input removed: ${relativePath}`);
if (change.unreadable.has(relativePath)) {
return rebuildFor(`graph input no longer readable: ${relativePath}`);
}
const indexedHash = change.changed.get(relativePath) ?? change.knownHash(relativePath);
if (indexedHash === undefined) {
needHashing.push(relativePath);
continue;
}
if (indexedHash !== recordedHash)
return rebuildFor(`graph input changed: ${relativePath}`);
}
for (const relativePath of Object.keys(record.heads)) {
if (change.removed.has(relativePath))
return rebuildFor(`graph input removed: ${relativePath}`);
if (change.unreadable.has(relativePath)) {
return rebuildFor(`graph input no longer readable: ${relativePath}`);
}
}
// A presence-only input is one the build already could not read, so an index
// that still cannot read it says nothing new — only a change or a removal
// does, which keeps an unreadable file from forcing a rebuild every run.
const needStatting = Object.entries(record.presence);
for (const [relativePath] of needStatting) {
if (change.removed.has(relativePath) || change.changed.has(relativePath)) {
return rebuildFor(`graph input changed: ${relativePath}`);
}
}
const rebound = await inBatches(Object.entries(record.realpaths ?? {}), STAT_BATCH, async ([relativePath]) => {
try {
return hashContent(toForwardSlash(await fs.realpath(path.resolve(root, relativePath))));
}
catch (error) {
const code = error.code;
if (code !== "ENOENT" && code !== "ENOTDIR")
throw error;
return null;
}
}, ([relativePath, recordedTarget], target) => target !== recordedTarget
? rebuildFor(`module lookup binding changed: ${relativePath}`) : null);
if (rebound)
return rebound;
const sized = await inBatches(needStatting, STAT_BATCH, async ([relativePath]) => {
try {
return (await fs.stat(path.resolve(root, relativePath))).size;
}
catch {
return null;
}
}, ([relativePath, recordedSize], size) => {
if (size === null)
return rebuildFor(`graph input gone: ${relativePath}`);
// Size is what carried the file across the limit, in either direction.
if (recordedSize >= 0 && size !== recordedSize) {
return rebuildFor(`graph input changed size: ${relativePath}`);
}
return null;
});
if (sized)
return sized;
// A directory the walk could not list is watched the same way, but with the
// read it actually failed at: a subtree that becomes listable can add nodes
// while its own entry in the parent never changes.
const relisted = await inBatches(record.unreadableDirectories, LISTING_BATCH, async (relativePath) => {
try {
await fs.readdir(path.resolve(root, relativePath));
return true;
}
catch {
return false;
}
}, (relativePath, listable) => listable ? rebuildFor(`graph input directory became listable: ${relativePath}`) : null);
if (relisted)
return relisted;
// A file the build could not open is watched by whether that is still true:
// one that starts being readable starts contributing, and nothing else in
// the record would move when it does.
const recovered = await inBatches(record.unreadable, HASH_BATCH, async (relativePath) => {
try {
await fs.access(path.resolve(root, relativePath), fsConstants.R_OK);
return true;
}
catch {
return false;
}
}, (relativePath, readable) => readable ? rebuildFor(`graph input became readable: ${relativePath}`) : null);
if (recovered)
return recovered;
const headed = await inBatches(Object.keys(record.heads), HASH_BATCH, async (relativePath) => {
try {
// The same window discovery scored the file on, so the two answers
// stay comparable — see `ExtensionlessDetection.head`.
return await readFileHead(path.resolve(root, relativePath));
}
catch {
return null;
}
}, (relativePath, head) => {
if (head === null)
return rebuildFor(`graph input unreadable: ${relativePath}`);
if (hashContent(head) !== record.heads[relativePath]) {
return rebuildFor(`graph input changed: ${relativePath}`);
}
return null;
});
if (headed)
return headed;
const hashed = await inBatches(needHashing, HASH_BATCH, async (relativePath) => {
try {
return await fs.readFile(path.resolve(root, relativePath), "utf-8");
}
catch {
return null;
}
}, (relativePath, content) => {
if (content === null)
return rebuildFor(`graph input unreadable: ${relativePath}`);
if (hashContent(content) !== record.files[relativePath]) {
return rebuildFor(`graph input changed: ${relativePath}`);
}
return null;
});
if (hashed)
return hashed;
// Last: whether anything appeared or vanished where the walk looked. This is
// what catches a file the index never holds — a new nested `go.mod`, a new
// `project.godot`, a `.uid` sidecar — none of which any other bucket can
// speak for, since none of them existed when the record was written.
//
// Two tiers, because the entry names are free and the ignore filter is not.
const moved = [];
const listed = await inBatches(Object.keys(record.directories), LISTING_BATCH, async (relativePath) => {
try {
return await fs.readdir(path.resolve(root, relativePath), { withFileTypes: true });
}
catch {
return null;
}
}, (relativePath, entries) => {
if (entries === null)
return rebuildFor(`graph input directory gone: ${relativePath}`);
if (hashEntryNames(entries.map(entryName)) !== record.directories[relativePath].all) {
moved.push([relativePath, entries]);
}
return null;
});
if (listed)
return listed;
if (moved.length > 0) {
// Something is different in at least one directory. Only now is the filter
// worth building, and only these directories are worth asking about.
//
// Nothing is written back. An ignored entry that appeared after the last
// build leaves this cheap hash stale, so every later decision pays for one
// ignore-filter build (~0.6s on a 3,941-file repository) to reach this
// same answer, until the next real rebuild refreshes the record. That is
// the accepted cost of keeping the record immutable except when the graph
// itself is saved: the alternative is a second writer for a field the
// graph save owns, and a write that lands after a concurrent rebuild would
// replace a newer graph's inputs with an older graph's.
const ig = createIgnoreFilter(root);
for (const [relativePath, entries] of moved) {
const { listing } = scanDirectory(ig, root, path.resolve(root, relativePath), entries);
if (listing.kept !== record.directories[relativePath].kept) {
return rebuildFor(`files appeared or vanished in: ${relativePath}`);
}
}
}
return { rebuild: false, reason: "no graph input changed", graphExists: true };
}
//# sourceMappingURL=graph-inputs.js.map