socraticode
Version:
SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.
288 lines • 14.1 kB
TypeScript
import type { Dirent } from "node:fs";
import { type IgnoreFilter } from "./ignore.js";
/**
* 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 declare const GRAPH_INPUTS_VERSION = 1;
/**
* Every input the graph build actually consumed, persisted beside the graph.
*
* The point of recording it from the build rather than describing it in a
* predicate is that there is then no second answer to "does this file
* contribute to the code graph?" to drift from the first. A file reaches this
* record because the builder read it; a file the builder stops reading stops
* being recorded in the same commit.
*
* The buckets mirror the *kinds of read* the build makes, because each kind
* can only be watched the way it was made:
*
* | the build | recorded as | watched by |
* |---|---|---|
* | read a file whole | content hash | re-hashing it |
* | read a file's head to classify it | head hash | re-reading that head |
* | used a file without opening it | its size | a stat |
* | resolved an installed module lookup path | target hash or absence | resolving it again |
* | tried to open one and could not | its path | trying again |
* | listed a directory | its entry names | listing it again |
* | tried to list one and could not | its path | listing it again |
*/
export interface GraphInputRecord {
version: number;
/**
* The SocratiCode version that produced this record. Compared on its own
* rather than folded into {@link GraphInputRecord.settings} so that an
* upgrade's rebuild is legible in the log and reviewable as its own rule.
*/
builtByVersion: string;
/**
* Project-relative path → {@link hashContent} of the bytes the build used.
* Paths outside the project root (a `tsconfig` reached through `extends`,
* say) keep their `../` prefix and are resolved against the root again.
*/
files: Record<string, string>;
/**
* Extensionless files the build head-read to decide whether they were code,
* and turned away — path → hash of that head.
*
* Their bytes decided the shape of the file set, so they are inputs; but
* only the head was read, and a whole-file hash would not describe the
* decision. Size will not do either: `# hello!!` becoming `#!/bin/sh` flips
* the answer without moving a byte count.
*/
heads: Record<string, string>;
/**
* Inputs the build used without reading their bytes: a leaf node made from
* its path alone, a file too large for the graph to read, a file that could
* not be read at all. Recorded as the size the build saw, or `-1` where even
* that was unknown.
*/
presence: Record<string, number>;
/** Module lookup path → canonical target hash, or null when absent. */
realpaths?: Record<string, string | null>;
/**
* Files the build tried to open and could not — a manifest it found and
* failed to read, a `tsconfig.json` that exists but is unreadable, a source
* file that vanished mid-walk, an extensionless file whose head-read threw.
*
* Distinct from {@link GraphInputRecord.presence}, which is a file the build
* deliberately did not open: that one is watched by size, and an unreadable
* file has no size the build ever saw. The only question that matters for
* these is whether they can be read now, because a file that starts being
* readable starts contributing.
*/
unreadable: string[];
/**
* Directories the walk could not list. The subtree under one contributed
* nothing, so a directory that starts being listable can add nodes with its
* own entry in the parent unchanged — and there is no listing to compare,
* which is why these are separate from {@link GraphInputRecord.directories}
* and re-checked with a `readdir` rather than a readability test.
*/
unreadableDirectories: string[];
/**
* Every directory the discovery walk listed → what it saw there. The root is
* `"."`.
*
* This is what makes an addition detectable at all. The other buckets
* describe files that existed when the build ran, so nothing in them can
* speak for a path that did not — and the index cannot speak for one it does
* not hold, which covers `go.mod`, `project.godot`, `.uid` sidecars and,
* unless `INCLUDE_DOT_FILES` is set, every ignore file. A directory listing
* is the read the walk actually made, and it changes the moment any of them
* appears.
*/
directories: Record<string, DirectoryListing>;
/** Hash of every graph input that is not a file — see {@link graphSettingsHash}. */
settings: string;
/**
* Which parsers the build actually had — see {@link graphCapabilitiesHash}.
*
* Separate from {@link GraphInputRecord.builtByVersion} because it varies
* *within* a version: the optional `@ast-grep/lang-*` grammars and the
* GDScript native addon ship platform-specific prebuilds, so the same
* SocratiCode on a different host, runtime or architecture can extract
* different imports and symbols from byte-identical sources.
*/
capabilities: string;
}
/**
* What one directory held, hashed two ways.
*
* `all` is every entry name, ignored ones included; `kept` is only those the
* ignore filter admitted. Both, because they answer the same question at very
* different prices: comparing `all` needs nothing but a `readdir`, while
* `kept` needs the filter, and building one costs a synchronous walk of the
* tree plus an `ignore` test per entry — 2.1s of the 2.2s a check took on a
* 3,941-file repository, against 5ms for the listings themselves.
*
* So `all` is asked first and settles the common case. It cannot answer on its
* own: a `.DS_Store` appearing, or a coverage directory written and cleaned by
* a test run, moves it without moving anything the graph was built from, and a
* rebuild for each of those would leave the skip theoretical. `kept` is what
* decides, and it is only ever computed for a directory whose `all` moved.
*/
export interface DirectoryListing {
all: string;
kept: string;
}
/**
* 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 declare function graphCapabilitiesHash(input: {
loadedGrammars: readonly string[];
gdscript: boolean;
elixirTemplates: boolean;
}): string;
export declare function graphSettingsHash(extraExtensions: ReadonlySet<string>, pythonRoots?: readonly string[]): string;
/** What the ignore filter made of one directory's entries. */
export interface DirectoryScan {
/** The entries the filter admitted, in the order they were read. */
kept: Dirent[];
listing: DirectoryListing;
}
/**
* 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 declare function scanDirectory(ig: IgnoreFilter, projectPath: string, dir: string, entries: Dirent[]): DirectoryScan;
/**
* 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 declare class GraphInputsMovedDuringBuild extends Error {
/** Project-relative path → what disagreed about it. */
readonly conflicts: ReadonlyMap<string, string>;
constructor(conflicts: ReadonlyMap<string, string>);
}
/**
* Collects what a single graph build read. One per build, handed to every part
* of the build that opens a file.
*
* Repeated observations of one file are consistency-checked, not overwritten:
* the first stands, and a later one that contradicts it is a conflict rather
* than a replacement, because the build has by then already used the first.
*/
export interface GraphInputRecorder {
/** An input the build read whole, recorded by the bytes it actually used. */
read(absolutePath: string, content: string): void;
/** An input the build head-read to classify and did not take. */
head(absolutePath: string, head: string): void;
/**
* An input the build used without reading it. `size` is what the build saw,
* where it got that far.
*/
present(absolutePath: string, size?: number): void;
/** A module lookup's native binding. Optional for existing recorder callers. */
realpath?(absolutePath: string, canonicalPath: string | null): void;
/** An input the build tried to open and could not. */
unreadable(absolutePath: string): void;
/** A directory the walk tried to list and could not. */
unreadableDirectory(absolutePath: string): void;
/** A directory the walk listed, by what it saw there. */
directory(absolutePath: string, listing: DirectoryListing): void;
/**
* Seal the record. Safe to call once the build has finished reading.
*
* @throws {GraphInputsMovedDuringBuild} if any file was observed two ways.
*/
finish(extraExtensions: ReadonlySet<string>, capabilities: string, pythonRoots?: readonly string[]): GraphInputRecord;
}
export declare function createGraphInputRecorder(projectRoot: string): GraphInputRecorder;
/**
* 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 declare function parseGraphInputRecord(stored: unknown): GraphInputRecord | null;
/**
* What an incremental update did to the index, in the terms this decision
* needs. The index has already hashed everything it holds, so most recorded
* inputs are answered from that work rather than read again.
*/
export interface GraphChangeSummary {
/**
* Whether the update indexed a file it had never seen. Cheaper than the
* directory check below and answers the common case, so it is asked first —
* but it speaks only for files the index holds, which is why it is not the
* whole of the addition story.
*/
hasAdditions: boolean;
/** Paths whose content changed, mapped to the hash the index now holds. */
changed: ReadonlyMap<string, string>;
/** Paths the index no longer holds. */
removed: ReadonlySet<string>;
/**
* Paths the index tried to read this run and could not. The index keeps the
* hash it already had for those, so without this they would read here as
* unchanged — while a rebuild would drop them from the graph.
*/
unreadable: ReadonlySet<string>;
/**
* The hash the index currently holds for a path it did not report as
* changed, or undefined for a path it does not index at all (a `.gitignore`,
* a `go.mod`). Those are read from disk here.
*/
knownHash(relativePath: string): string | undefined;
}
export interface GraphRebuildDecision {
rebuild: boolean;
/** Why, in a few words, for the log line and the progress message. */
reason: string;
/**
* Whether there is a graph to rebuild at all.
*
* False only when the project has no persisted graph: rebuilding then is
* *building* one, which an update that indexed nothing has no business
* doing. Every other answer — including a graph whose record is missing,
* malformed, or could not be read — is true, because those are the cases
* that must rebuild once and populate however quiet the update was.
*/
graphExists: boolean;
}
/**
* 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 declare function decideGraphRebuild(projectRoot: string, stored: unknown, change: GraphChangeSummary, extraExtensions: ReadonlySet<string>, capabilities: string, pythonRoots?: readonly string[]): Promise<GraphRebuildDecision>;
//# sourceMappingURL=graph-inputs.d.ts.map