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