UNPKG

socraticode

Version:

SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.

164 lines • 8.58 kB
// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited /** * I/O wrapper around the pure {@link detectExtensionlessExtension} detector. * * Kept as a leaf module (imports only the leaf modules constants + logger) so * the indexer, code graph, watcher, and incremental symbol-graph paths can all * consult content detection without introducing an import cycle. */ import { constants as fsConstants } from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; import { DETECT_HEAD_BYTES, detectExtensionlessExtension, indexExtensionlessEnabled, SPECIAL_FILES, } from "../constants.js"; import { logger } from "./logger.js"; /** * Read up to `maxBytes` bytes from the start of a file and decode as UTF-8. * Reads only the head (not the whole file) so scanning large extensionless * binaries/data files stays cheap. Opens non-blocking and throws for a * non-regular file (FIFO/socket/device) so a mistyped or swapped path can't * block the open; may also throw on open/read errors. */ export async function readFileHead(absolutePath, maxBytes = DETECT_HEAD_BYTES) { // Open non-blocking and reject non-regular fds. Opening a FIFO/device for read // can block indefinitely; an lstat-then-open guard upstream only narrows that // race (the path can still become a FIFO in between). O_NONBLOCK makes the open // return immediately, then fstat drops anything that is not a regular file // before we read. O_NONBLOCK has no effect on regular-file reads. const fh = await fsp.open(absolutePath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); try { if (!(await fh.stat()).isFile()) { throw Object.assign(new Error(`not a regular file: ${absolutePath}`), { code: "ENOTREG" }); } const buf = Buffer.alloc(maxBytes); const { bytesRead } = await fh.read(buf, 0, maxBytes, 0); // Normalised on the way out, so a head read from disk and the same window // taken from content already in memory are one representation rather than // two. They diverge otherwise: an invalid UTF-8 byte decodes to U+FFFD, // which re-encodes to three bytes, so the window taken from decoded // content ends earlier than the window taken from raw bytes. Comparing the // two then reports an unchanged file as changed, every time it is asked. return detectionHeadWindow(buf.subarray(0, bytesRead).toString("utf-8"), maxBytes); } finally { await fh.close(); } } /** * Detect the canonical extension of source text already held in memory, scoring * the same window {@link readFileHead} reads from disk: the first * {@link DETECT_HEAD_BYTES} **bytes** of UTF-8, not characters. * * The byte window is load-bearing, not incidental. `detectExtensionlessExtension` * counts pattern hits, so a larger window can change its answer; a character * slice of a file with multibyte text near the top would cover more content than * the disk head and could disagree about identical bytes. Re-encoding keeps the * two answers comparable — including at the boundary, where each decodes a * character split by the cut to U+FFFD. * * Content that was lossily decoded — invalid UTF-8 replaced by U+FFFD — re-encodes * to at least as many bytes as it came from, three per U+FFFD, so it scores a * window no longer than the raw file's. A decoded string cannot recover the bytes * it came from, so the in-memory and on-disk sides cannot be made to score the * same span of such a file; the disk-side resolver routes through this helper so * both settle on the narrower window instead of disagreeing. The window is * therefore about {@link DETECT_HEAD_BYTES} / 3 characters for wholly lossy * content, and a latin-1 file whose only code markers sit past that point reads as * "not code" wherever this window is scored. * * The leading character slice only bounds the allocation, keeping a whole-file * encode off this path: the first N characters always encode to at least N * bytes, so slicing to N characters cannot drop anything inside the N-byte * window. */ export function detectExtensionFromSource(source) { return detectExtensionlessExtension(detectionHeadWindow(source)); } /** * The canonical detection window for this content. * * The one place the window's end is decided, so that a head read from disk and * the same window taken from whole-file content are comparable — which they * are not if either side skips this, because a U+FFFD standing in for an * invalid byte costs three bytes of budget on the way back out and slides the * end of the window. * * Idempotent: the result is already at most `maxBytes`, so normalising it again * changes nothing, and {@link readFileHead} can return a normalised head * without altering what detection scores. */ export function detectionHeadWindow(source, maxBytes = DETECT_HEAD_BYTES) { return Buffer.from(source.slice(0, maxBytes), "utf-8") .subarray(0, maxBytes) .toString("utf-8"); } /** * Like {@link resolveExtensionlessExtension} but **throws** on a read/stat * failure instead of collapsing it to `null`, so a caller that must not conflate * "unreadable" with "not code" can tell them apart — e.g. the incremental * symbol-graph purge, which would otherwise drop a still-valid payload on a * transient I/O blip. Returns `null` only for a genuine non-match: detection * disabled, a {@link SPECIAL_FILES} name, a non-regular file, or content that is * not indexable code. */ export async function resolveExtensionlessExtensionStrict(absolutePath) { return (await resolveExtensionlessDetectionStrict(absolutePath)).extension; } /** * Like {@link resolveExtensionlessExtensionStrict}, but also reports the head * the detection ran on. Throws on a read/stat failure for the same reason the * strict variant does. */ export async function resolveExtensionlessDetectionStrict(absolutePath) { const nothingRead = { extension: null, head: null, unreadable: false }; if (!indexExtensionlessEnabled()) return nothingRead; // SPECIAL_FILES (Makefile, Dockerfile, …) are extensionless but handled by // name; never route them through content detection, so the graph paths stay // consistent with the index (getIndexableFiles filters them via isIndexableFile) // and a shell-recipe Makefile is not mis-graphed as a shell node. if (SPECIAL_FILES.has(path.basename(absolutePath))) return nothingRead; // Only a regular file can be head-read. glob({nodir:true}) still yields // FIFOs/sockets/devices, and opening a FIFO for read blocks until a writer // appears — which would wedge the whole scan. lstat and drop non-regular // files (the watcher's isIndexableFile guards the same way). const stats = await fsp.lstat(absolutePath); if (!stats.isFile()) return nothingRead; const head = await readFileHead(absolutePath); return { extension: detectExtensionFromSource(head), head, unreadable: false }; } /** * Detect the canonical extension of an extensionless file by head content, or * `null` when detection is disabled, the name is a {@link SPECIAL_FILES} entry, * the file is unreadable, or the content is not indexable code. Callers that * need graph-eligibility (grammar-bearing only) additionally check * `getAstGrepLang(result) !== null`. */ export async function resolveExtensionlessExtension(absolutePath) { return (await resolveExtensionlessDetection(absolutePath)).extension; } /** * {@link resolveExtensionlessExtension}, reporting the head the answer was * scored on so a caller can record what it consumed. A failure reads as * "nothing was read", which is what it was. */ export async function resolveExtensionlessDetection(absolutePath) { try { return await resolveExtensionlessDetectionStrict(absolutePath); } catch (err) { // ENOENT (file deleted/renamed between scan and read) is an expected skip. // A non-ENOENT fault (EACCES, EIO) means a possibly-code file we could not // read — surface it at debug so it is not silently confused with "not code". if (err?.code !== "ENOENT") { logger.debug("Could not read extensionless file head (skipping)", { absolutePath, error: err instanceof Error ? err.message : String(err), }); } return { extension: null, head: null, unreadable: true }; } } //# sourceMappingURL=extensionless.js.map