UNPKG

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
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