UNPKG

socraticode

Version:

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

384 lines • 16.8 kB
// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { QDRANT_COLLECTION_PREFIX, toForwardSlash } from "./constants.js"; import { logger } from "./services/logger.js"; // ── Branch detection ───────────────────────────────────────────────────── /** * Detect the current git branch for a project path. * Returns `null` if the path is not inside a git repository or detection fails. */ export function detectGitBranch(projectPath) { try { const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path.resolve(projectPath), encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], }).trim(); // "HEAD" is returned for detached HEAD state — treat as no branch return branch && branch !== "HEAD" ? branch : null; } catch { return null; } } /** * Sanitize a git branch name for use in Qdrant collection names. * Replaces characters outside `[a-zA-Z0-9_-]` with underscores, * collapses consecutive underscores, and strips leading/trailing underscores. */ export function sanitizeBranchName(branch) { return branch .replace(/[^a-zA-Z0-9_-]/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, ""); } /** Pattern of characters valid in a Qdrant collection name suffix. */ const PROJECT_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; /** Validate an explicitly-supplied projectId. Throws on bad characters. */ function assertValidProjectId(value, source) { if (!PROJECT_ID_PATTERN.test(value)) { throw new Error(`${source} must match [a-zA-Z0-9_-]+ but got: "${value}"`); } } /** * Read and validate `projectId` from `.socraticode.json`, if present. * * Returns the trimmed id when the file declares a usable string, `null` * otherwise (file missing, malformed JSON, field absent, wrong type, or * empty after trim). Throws when the field is a string with characters * outside the Qdrant-friendly set — explicit user intent fails loud. */ function readProjectIdFromConfigFile(folderPath) { const config = loadSocratiCodeConfig(folderPath); if (!config || typeof config.projectId !== "string") return null; const trimmed = config.projectId.trim(); if (!trimmed) return null; assertValidProjectId(trimmed, ".socraticode.json: projectId"); return trimmed; } /** * Generate a stable project ID from an absolute folder path. * Uses a short SHA-256 prefix so collection names stay Qdrant-friendly. * * Resolution order (highest precedence first): * 1. `SOCRATICODE_PROJECT_ID` env var — per-machine override. * 2. `projectId` in `.socraticode.json` — committed, shared across the * team so every checkout addresses the same Qdrant collection * regardless of where the working tree lives on disk. * 3. SHA-256 prefix of the resolved absolute path — default fallback. * * In both override paths the value must match `[a-zA-Z0-9_-]+`; invalid * characters throw. Whitespace is trimmed; empty/whitespace-only values * fall through to the next level. * * When `SOCRATICODE_BRANCH_AWARE` is `"true"` (and no explicit project ID * is set via env var or config file), the current git branch name is * appended to the hash, producing a separate set of collections per * branch. */ export function projectIdFromPath(folderPath, observedBranch) { const envExplicit = process.env.SOCRATICODE_PROJECT_ID?.trim(); if (envExplicit) { assertValidProjectId(envExplicit, "SOCRATICODE_PROJECT_ID"); warnBranchAwareIgnored(envExplicit, "SOCRATICODE_PROJECT_ID", folderPath); return envExplicit; } const fileExplicit = readProjectIdFromConfigFile(folderPath); if (fileExplicit) { warnBranchAwareIgnored(fileExplicit, ".socraticode.json projectId", folderPath); return fileExplicit; } let id = coreProjectId(folderPath); // Branch-aware mode: append sanitized branch name to isolate per-branch indexes if (process.env.SOCRATICODE_BRANCH_AWARE === "true") { const branch = observedBranch === undefined ? detectGitBranch(path.resolve(folderPath)) : observedBranch; if (branch) { const sanitized = sanitizeBranchName(branch); if (sanitized) { id = `${id}__${sanitized}`; } } } return id; } /** * What the user would actually have to do to get per-branch collections. * * Three states, not two. A branch existing is not enough: `sanitizeBranchName` * strips every character that cannot appear in a collection name, so a legal * branch such as `___` reduces to the empty string and `projectIdFromPath` * returns the unsuffixed id anyway. Advising removal of the explicit id there * would change the project's identity and still produce no per-branch index. */ function perBranchAdvice(branch) { if (!branch) { return "this project has no detectable git branch (not a repository, or a detached HEAD), so branch-aware mode would produce no suffix here even without an explicit id"; } const suffix = sanitizeBranchName(branch); if (!suffix) { return `the branch "${branch}" contains no characters usable in a collection name, so it would produce no suffix even without an explicit id — set a branch-specific SOCRATICODE_PROJECT_ID instead`; } return `remove the explicit project id so the path-derived id can carry the "${suffix}" suffix, or set a branch-specific SOCRATICODE_PROJECT_ID deliberately`; } /** * Projects already warned that their branch-aware setting is being ignored. * * `projectIdFromPath` runs on essentially every tool call, so without this the * warning would repeat for the life of the process. Keyed by the resolved id * rather than the path, because the whole point of an explicit id is that * several paths share one. */ const branchAwareIgnoredWarned = new Set(); /** * Warn once when `SOCRATICODE_BRANCH_AWARE=true` has no effect because an * explicit project id won. * * The precedence itself is deliberate and documented: an explicit id is a * stable identity and is not augmented per branch. Suffixing it would rename * the collections of every existing installation and make their indexes look * missing. What was wrong was doing it silently — the setting is accepted and * simply does nothing, so the only way to find out was to read this file. * * The branch is resolved here rather than by the caller because this runs once * per affected project: it costs one git invocation per project, not one per id * resolution. It is needed for the advice to be true — where no branch can be * detected, removing the explicit id would change the project's identity and * still produce no suffix. */ function warnBranchAwareIgnored(projectId, source, folderPath) { if (process.env.SOCRATICODE_BRANCH_AWARE !== "true") return; if (branchAwareIgnoredWarned.has(projectId)) return; branchAwareIgnoredWarned.add(projectId); const branch = detectGitBranch(folderPath); logger.warn("SOCRATICODE_BRANCH_AWARE is ignored for this project because an explicit project id takes precedence — every branch shares one index", { projectId, explicitIdFrom: source, branch: branch ?? "(none detected)", howToGetPerBranchIndexes: perBranchAdvice(branch), }); } /** * Core project ID: SHA-256 hash of the resolved path, without branch suffix. * Used as the default fallback when no explicit project ID is configured. */ export function coreProjectId(folderPath) { const normalized = path.resolve(folderPath); return createHash("sha256").update(normalized).digest("hex").slice(0, 12); } /** * Branch-suffix-free, env-var-free project ID for a given path. * * Resolution: `projectId` from `.socraticode.json` if present, else the * SHA-256 path hash. Used for linked projects (where `SOCRATICODE_PROJECT_ID` * is process-scoped and ambiguous when applied to a different project) and * for dedup keys in `resolveLinkedCollections` (where we need a stable * identity that doesn't drift across branches). */ function effectiveBaseProjectId(folderPath) { const fileId = readProjectIdFromConfigFile(folderPath); return fileId ?? coreProjectId(folderPath); } /** * Derive a Qdrant collection name for a project's code chunks. * * The optional `QDRANT_COLLECTION_PREFIX` env var is prepended verbatim to * isolate this SocratiCode instance's collections when sharing a Qdrant * server with other applications or other SocratiCode instances. Empty * prefix (the default) preserves the legacy `codebase_<id>` form exactly. */ export function collectionName(projectId) { return `${QDRANT_COLLECTION_PREFIX}codebase_${projectId}`; } /** * Derive a Qdrant collection name for a project's code graph. * See {@link collectionName} for the prefix semantics. */ export function graphCollectionName(projectId) { return `${QDRANT_COLLECTION_PREFIX}codegraph_${projectId}`; } /** * Derive a Qdrant collection name for a project's context artifacts. * See {@link collectionName} for the prefix semantics. */ export function contextCollectionName(projectId) { return `${QDRANT_COLLECTION_PREFIX}context_${projectId}`; } // ── Symbol graph collections ───────────────────────────────────────────── /** Top-level metadata point for a project's symbol graph. */ export function symgraphMetaCollectionName(projectId) { return `${QDRANT_COLLECTION_PREFIX}${projectId}_symgraph_meta`; } /** Per-file payloads for a project's symbol graph. */ export function symgraphFileCollectionName(projectId) { return `${QDRANT_COLLECTION_PREFIX}${projectId}_symgraph_file`; } /** Sharded indices (name index + reverse-call file index). */ export function symgraphIndexCollectionName(projectId) { return `${QDRANT_COLLECTION_PREFIX}${projectId}_symgraph_index`; } // ── Linked projects ────────────────────────────────────────────────────── /** Configuration file name shared by all `.socraticode.json` consumers. */ const SOCRATICODE_CONFIG_FILE = ".socraticode.json"; /** * Read and parse `.socraticode.json` from a project directory. * * Returns the parsed object or `null` when the file is missing, * unreadable, or contains malformed JSON. Per-field validation is the * caller's responsibility — this loader only handles I/O and parsing. */ function loadSocratiCodeConfig(projectPath) { const configPath = path.join(path.resolve(projectPath), SOCRATICODE_CONFIG_FILE); try { if (!fs.existsSync(configPath)) return null; const raw = fs.readFileSync(configPath, "utf-8"); return JSON.parse(raw); } catch { return null; } } /** Whether `candidate` is inside `root`, including `root` itself. */ function isWithinRoot(root, candidate) { const relative = path.relative(root, candidate); return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)); } /** * Load the effective Python import roots from `.socraticode.json`. * * Entries are interpreted relative to the project root, normalised to * forward-slash project-relative paths, deduplicated in declared order, and * kept only when they resolve to an existing directory inside the project. * Absolute paths, paths that escape the project (including through symlinks), * non-directories, missing paths, malformed values, and an unusable config * file are ignored. Returning an empty list preserves the legacy resolver * exactly. */ export function loadPythonRoots(projectPath) { const resolvedRoot = path.resolve(projectPath); const config = loadSocratiCodeConfig(resolvedRoot); if (!config || !Array.isArray(config.pythonRoots)) return []; let realRoot; try { realRoot = fs.realpathSync(resolvedRoot); } catch { return []; } const roots = []; const seen = new Set(); for (const entry of config.pythonRoots) { if (typeof entry !== "string") continue; const trimmed = entry.trim(); if (!trimmed || path.posix.isAbsolute(trimmed) || path.win32.isAbsolute(trimmed)) continue; const platformPath = trimmed.replace(/[\\/]+/g, path.sep); const candidate = path.resolve(resolvedRoot, platformPath); if (!isWithinRoot(resolvedRoot, candidate)) continue; let realCandidate; try { if (!fs.statSync(candidate).isDirectory()) continue; realCandidate = fs.realpathSync(candidate); } catch { continue; } if (!isWithinRoot(realRoot, realCandidate)) continue; const normalized = toForwardSlash(path.relative(realRoot, realCandidate)) || "."; if (seen.has(normalized)) continue; seen.add(normalized); roots.push(normalized); } return roots; } /** * Load linked project paths from `.socraticode.json` and/or the * `SOCRATICODE_LINKED_PROJECTS` env var (comma-separated absolute or relative paths). * * Returns resolved absolute paths. Invalid/missing paths are silently skipped. */ export function loadLinkedProjects(projectPath) { const resolvedRoot = path.resolve(projectPath); const paths = new Set(); // 1. Read .socraticode.json const config = loadSocratiCodeConfig(resolvedRoot); if (config && Array.isArray(config.linkedProjects)) { for (const p of config.linkedProjects) { if (typeof p === "string" && p.trim()) { const resolved = path.resolve(resolvedRoot, p.trim()); if (resolved !== resolvedRoot && fs.existsSync(resolved)) { paths.add(resolved); } } } } // 2. Read env var (comma-separated) const envLinked = process.env.SOCRATICODE_LINKED_PROJECTS?.trim(); if (envLinked) { for (const p of envLinked.split(",")) { const trimmed = p.trim(); if (trimmed) { const resolved = path.resolve(resolvedRoot, trimmed); if (resolved !== resolvedRoot && fs.existsSync(resolved)) { paths.add(resolved); } } } } return Array.from(paths); } /** * Resolve linked projects into Qdrant collection descriptors for multi-collection search. * Returns an array of { name, label } suitable for `searchMultipleCollections()`. * The current project is always first (highest priority for dedup). * * Linked-project IDs are resolved via `effectiveBaseProjectId`, which honors * each linked project's own `.socraticode.json` `projectId` field. This * preserves symmetry — a project addresses the same Qdrant collection whether * it is the current root or a linked dependency from another project. * * Dedup compares against the *current project's full ID* (env override → file * → path-hash, with optional branch suffix). This guarantees the dedup key * matches the actual collection name being added: a linked project is skipped * only when it would resolve to the same collection that the current project * already occupies. Seeding from `effectiveBaseProjectId(resolvedRoot)` would * misalign the seed when `SOCRATICODE_PROJECT_ID` is set, causing linked * projects whose file `projectId` happens to match the current project's file * `projectId` to be silently dropped even though their data lives in a * different collection than the env-overridden current one. */ export function resolveLinkedCollections(projectPath) { const resolvedRoot = path.resolve(projectPath); const currentId = projectIdFromPath(resolvedRoot); const seen = new Set([currentId]); const collections = [ { name: collectionName(currentId), label: path.basename(resolvedRoot) }, ]; for (const linkedPath of loadLinkedProjects(resolvedRoot)) { const linkedId = effectiveBaseProjectId(linkedPath); if (seen.has(linkedId)) continue; seen.add(linkedId); collections.push({ name: collectionName(linkedId), label: path.basename(linkedPath), }); } return collections; } //# sourceMappingURL=config.js.map