mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-06-02 06:23:43 +02:00
feat(config): support projectId in .socraticode.json for team-shared indexes (#53)
Adds an optional `projectId` field to `.socraticode.json` so teams can
commit a stable project identifier to the repo. Without this field the
project ID is derived from the SHA-256 of the absolute checkout path,
which means the same project resolves to a different Qdrant collection
on every machine, OS user, filesystem layout, or worktree. With it,
every checkout addresses the same `codebase_*`, `codegraph_*`, and
`context_*` collections regardless of where the working tree lives on
disk.
This is the path-independent, multi-project complement to the existing
`SOCRATICODE_PROJECT_ID` env var. The env var is process-scoped and
global to all projects in a host, so it does not scale to a developer
who works on several projects on one laptop. The file is per-project
and shared across teammates via git.
Resolution precedence (highest first):
1. `SOCRATICODE_PROJECT_ID` env var (per-machine override)
2. `projectId` in .socraticode.json (committed, team-wide)
3. SHA-256 prefix of the absolute path (existing default)
Both override paths trim whitespace, validate against `[a-zA-Z0-9_-]+`,
and throw on invalid characters so a misconfigured value cannot
silently route a project to the wrong (or empty) collection. Malformed
JSON, missing fields, wrong types, and empty/whitespace-only values
fall through to the next precedence level so the MCP server stays
resilient against hand-edited config files. Branch-aware mode is
suppressed for either explicit override since explicit identifiers
are stable by intent.
Also fixes a pre-existing bug in `resolveLinkedCollections`: linked
projects were resolved via `coreProjectId(linkedPath)` (path hash
only), so a linked project that pinned its own `projectId` in
`.socraticode.json` would silently miss its actual data during
cross-project search. Linked-project resolution now goes through a
new `effectiveBaseProjectId` helper that honors the committed value,
preserving symmetry: a project addresses the same Qdrant collection
whether it is the current root or a linked dependency. Dedup is
tightened to use the same effective base ID, so two paths pinning the
same shared identifier collapse to a single result.
The env var deliberately does not leak into linked-project collection
names. It is process-scoped and applying it as a single value to every
linked path would collapse them onto the env-var collection, silently
losing per-project isolation.
Tests: 16 new cases in tests/unit/config.test.ts, written TDD-style
(RED to GREEN). Coverage:
- `projectIdFromPath` (13): file resolution, ignores path
differences when file projectId is set, whitespace trimming,
throws on invalid characters, falls back to hash on
empty/whitespace/wrong-type/null/missing-file/malformed-JSON,
env-var precedence over file, branch-suffix suppression, and
coexistence with `linkedProjects` in the same file.
- `resolveLinkedCollections` (3): linked project's committed
projectId honored, dedup on shared committed projectId, env var
does not leak into linked-project collection names.
The new branch-aware-suppression test explicitly disables git
`commit.gpgsign` and `tag.gpgsign` in its throwaway-repo fixture so
the test is robust against the developer's global git config.
Backwards compatible: zero behaviour change for users who do not adopt
the new field. The `SocratiCodeConfig` interface gains an optional
field; existing `linkedProjects` parsing is functionally identical
(routed through the new shared `loadSocratiCodeConfig` helper).
Composes cleanly with the recently-added `QDRANT_COLLECTION_PREFIX`:
prefix + projectId combine into `<prefix>codebase_<projectId>` as
expected.
README and DEVELOPER documentation updated: new "Team-Shared Index
(committed `projectId`)" section in README between Git Worktrees and
Cross-Project Search, and the env-var table notes the new precedence.
DEVELOPER's "Project ID & Collection Naming" section now documents
the three-level precedence and explains why both override paths
suppress the branch-aware suffix.
Co-authored-by: airmonitor <tomasz.szuster@gmail.com>
This commit is contained in:
+11
-4
@@ -269,17 +269,24 @@ Defined in `src/services/docker.ts`: after starting the container, the server po
|
||||
|
||||
### Project ID & Collection Naming
|
||||
|
||||
Defined in `src/config.ts`:
|
||||
- **Project ID**: First 12 characters of SHA-256 hash of the absolute project path.
|
||||
Defined in `src/config.ts`. `projectIdFromPath()` resolves the project ID with the following precedence (highest first):
|
||||
|
||||
1. **`SOCRATICODE_PROJECT_ID` env var** — per-machine override; bypasses both file lookup and path hashing.
|
||||
2. **`projectId` field in `.socraticode.json`** — committed, team-wide stable identifier; survives different filesystem layouts and OS users.
|
||||
3. **First 12 characters of SHA-256 of the absolute project path** — default fallback.
|
||||
|
||||
In both override paths the value must match `[a-zA-Z0-9_-]+`; whitespace is trimmed; empty/whitespace-only values fall through to the next level. Invalid characters in an explicit override fail loud (throw) — silent fallback would hide misconfigurations that map a project to the wrong (or new) collection.
|
||||
|
||||
Collection names derived from the project ID:
|
||||
- **Code collection**: `codebase_{projectId}`
|
||||
- **Graph collection**: `codegraph_{projectId}`
|
||||
- **Context artifacts collection**: `context_{projectId}`
|
||||
|
||||
This means the same folder path always maps to the same collection, even across restarts.
|
||||
With the default (path-hash) ID, the same folder path always maps to the same collection across restarts. With either override, the mapping is stable across machines and checkouts.
|
||||
|
||||
#### Branch-aware mode
|
||||
|
||||
When `SOCRATICODE_BRANCH_AWARE=true`, the current git branch is detected via `git rev-parse --abbrev-ref HEAD` and appended to the project ID (e.g. `abc123def456__feat_my-feature`). Branch names are sanitized: non-alphanumeric characters (except `-`) become `_`, consecutive underscores collapse, leading/trailing underscores are stripped. Detached HEAD states fall back to the branchless ID. Ignored when `SOCRATICODE_PROJECT_ID` is set explicitly.
|
||||
When `SOCRATICODE_BRANCH_AWARE=true`, the current git branch is detected via `git rev-parse --abbrev-ref HEAD` and appended to the project ID (e.g. `abc123def456__feat_my-feature`). Branch names are sanitized: non-alphanumeric characters (except `-`) become `_`, consecutive underscores collapse, leading/trailing underscores are stripped. Detached HEAD states fall back to the branchless ID. Ignored when `SOCRATICODE_PROJECT_ID` is set explicitly or when `projectId` is set in `.socraticode.json` — explicit identifiers are treated as stable and not augmented per branch.
|
||||
|
||||
#### Linked projects
|
||||
|
||||
|
||||
@@ -768,6 +768,20 @@ With this config, agents running in `/repo/main`, `/repo/worktree-feat-a`, and `
|
||||
- Your AI agent reads actual file contents from its own worktree; the shared index is only used for discovery and navigation
|
||||
- When changes merge back to main, the file watcher re-indexes the changed files and the index converges
|
||||
|
||||
### Team-Shared Index (committed `projectId`)
|
||||
|
||||
The env-var approach above works per-machine. For a stable identifier that every teammate (and CI runner) picks up automatically, commit a `projectId` in `.socraticode.json` at the project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"projectId": "my-project"
|
||||
}
|
||||
```
|
||||
|
||||
Now any checkout of the repo — regardless of where it lives on disk or which user account owns it — addresses the same `codebase_my-project`, `codegraph_my-project`, and `context_my-project` Qdrant collections. This is the recommended setup for teams sharing a Qdrant instance: the index is built once and benefits everyone, even across different OS users and laptops with completely different filesystem layouts.
|
||||
|
||||
The value must match `[a-zA-Z0-9_-]+`; whitespace is trimmed, and a missing or empty value falls back to the path-hash default. The `SOCRATICODE_PROJECT_ID` env var, when set, takes precedence over this file — handy for ad-hoc per-machine overrides without touching the repo.
|
||||
|
||||
### Cross-Project Search (linked projects)
|
||||
|
||||
If you work across multiple related repositories or packages, you can search them all in a single query.
|
||||
@@ -1155,8 +1169,8 @@ The rest of this section documents the variables themselves. Pass them using whi
|
||||
| `MAX_FILE_SIZE_MB` | `5` | Maximum file size in MB. Files larger than this are skipped during indexing. Increase for repos with large generated or data files you want indexed. |
|
||||
| `SEARCH_DEFAULT_LIMIT` | `10` | Default number of results returned by `codebase_search` (1-50). Each result is a ranked code chunk with file path, line range, and content. Higher values give broader coverage but produce more output. Can still be overridden per-query via the `limit` tool parameter. |
|
||||
| `SEARCH_MIN_SCORE` | `0.10` | Minimum RRF (Reciprocal Rank Fusion) score threshold (0-1). Results below this score are filtered out. Helps remove low-relevance noise from search results. Set to `0` to disable filtering (returns all results up to `limit`). Can be overridden per-query via the `minScore` tool parameter. Works together with `limit`: results are first filtered by score, then capped at `limit`. |
|
||||
| `SOCRATICODE_PROJECT_ID` | *(none)* | Override the auto-generated project ID. When set, all paths resolve to the same Qdrant collections, allowing multiple directories (e.g. git worktrees of the same repo) to share a single index. Must match `[a-zA-Z0-9_-]+`. |
|
||||
| `SOCRATICODE_BRANCH_AWARE` | `false` | When `true`, append the current git branch name to the project ID, creating separate Qdrant collections per branch. Ignored when `SOCRATICODE_PROJECT_ID` is set. |
|
||||
| `SOCRATICODE_PROJECT_ID` | *(none)* | Override the auto-generated project ID. When set, all paths resolve to the same Qdrant collections, allowing multiple directories (e.g. git worktrees of the same repo) to share a single index. Must match `[a-zA-Z0-9_-]+`. Takes precedence over the `projectId` field in `.socraticode.json`. |
|
||||
| `SOCRATICODE_BRANCH_AWARE` | `false` | When `true`, append the current git branch name to the project ID, creating separate Qdrant collections per branch. Ignored when `SOCRATICODE_PROJECT_ID` is set or when `projectId` is set in `.socraticode.json`. |
|
||||
| `SOCRATICODE_LINKED_PROJECTS` | *(none)* | Comma-separated list of additional project paths to include in cross-project search. Merged with paths from `.socraticode.json`. Non-existent paths are silently skipped. |
|
||||
| `SOCRATICODE_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error` |
|
||||
| `SOCRATICODE_LOG_FILE` | *(none)* | Absolute path to a log file. When set, all log entries are appended to this file (a session separator is written on each server start). Useful for debugging when the MCP host doesn't surface log notifications. |
|
||||
|
||||
+127
-41
@@ -39,29 +39,65 @@ export function sanitizeBranchName(branch: string): string {
|
||||
.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: string, source: string): void {
|
||||
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: string): string | null {
|
||||
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.
|
||||
*
|
||||
* When `SOCRATICODE_PROJECT_ID` is set, that value is used directly instead
|
||||
* of hashing the path. This lets multiple directory trees (e.g. git
|
||||
* worktrees) share a single Qdrant index. The value must contain only
|
||||
* characters valid in a Qdrant collection name (`[a-zA-Z0-9_-]`).
|
||||
* 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), the current git branch name is appended to the hash, producing
|
||||
* a separate set of collections per branch.
|
||||
* 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: string): string {
|
||||
const explicit = process.env.SOCRATICODE_PROJECT_ID?.trim();
|
||||
if (explicit) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(explicit)) {
|
||||
throw new Error(
|
||||
`SOCRATICODE_PROJECT_ID must match [a-zA-Z0-9_-]+ but got: "${explicit}"`,
|
||||
);
|
||||
}
|
||||
return explicit;
|
||||
const envExplicit = process.env.SOCRATICODE_PROJECT_ID?.trim();
|
||||
if (envExplicit) {
|
||||
assertValidProjectId(envExplicit, "SOCRATICODE_PROJECT_ID");
|
||||
return envExplicit;
|
||||
}
|
||||
|
||||
const fileExplicit = readProjectIdFromConfigFile(folderPath);
|
||||
if (fileExplicit) {
|
||||
return fileExplicit;
|
||||
}
|
||||
|
||||
let id = coreProjectId(folderPath);
|
||||
|
||||
// Branch-aware mode: append sanitized branch name to isolate per-branch indexes
|
||||
@@ -80,14 +116,27 @@ export function projectIdFromPath(folderPath: string): string {
|
||||
|
||||
/**
|
||||
* Core project ID: SHA-256 hash of the resolved path, without branch suffix.
|
||||
* Used internally by resolveLinkedCollections so linked projects always
|
||||
* resolve to their base collection regardless of SOCRATICODE_BRANCH_AWARE.
|
||||
* Used as the default fallback when no explicit project ID is configured.
|
||||
*/
|
||||
function coreProjectId(folderPath: string): string {
|
||||
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: string): string {
|
||||
const fileId = readProjectIdFromConfigFile(folderPath);
|
||||
return fileId ?? coreProjectId(folderPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a Qdrant collection name for a project's code chunks.
|
||||
*
|
||||
@@ -135,14 +184,47 @@ export function symgraphIndexCollectionName(projectId: string): string {
|
||||
|
||||
// ── Linked projects ──────────────────────────────────────────────────────
|
||||
|
||||
/** Configuration file name for linked projects */
|
||||
/** Configuration file name shared by all `.socraticode.json` consumers. */
|
||||
const SOCRATICODE_CONFIG_FILE = ".socraticode.json";
|
||||
|
||||
/** Shape of .socraticode.json */
|
||||
/**
|
||||
* Shape of `.socraticode.json`.
|
||||
*
|
||||
* Fields are typed as their intended shape; runtime validators in the
|
||||
* consumers tolerate malformed values (wrong type, null, etc.) so the
|
||||
* MCP server stays resilient against hand-edited config files.
|
||||
*/
|
||||
interface SocratiCodeConfig {
|
||||
/**
|
||||
* Stable project identifier shared across machines/checkouts.
|
||||
* When set, overrides the path-hash default so every team member
|
||||
* addresses the same Qdrant collection regardless of where the
|
||||
* working tree lives on disk. The env var
|
||||
* `SOCRATICODE_PROJECT_ID` takes precedence over this field.
|
||||
*/
|
||||
projectId?: string;
|
||||
/** Paths (absolute or relative to this file) of related projects to search alongside this one. */
|
||||
linkedProjects?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: string): SocratiCodeConfig | null {
|
||||
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) as SocratiCodeConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load linked project paths from `.socraticode.json` and/or the
|
||||
* `SOCRATICODE_LINKED_PROJECTS` env var (comma-separated absolute or relative paths).
|
||||
@@ -154,24 +236,16 @@ export function loadLinkedProjects(projectPath: string): string[] {
|
||||
const paths = new Set<string>();
|
||||
|
||||
// 1. Read .socraticode.json
|
||||
const configPath = path.join(resolvedRoot, SOCRATICODE_CONFIG_FILE);
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
const config = JSON.parse(raw) as SocratiCodeConfig;
|
||||
if (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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed JSON or read error — skip silently
|
||||
}
|
||||
|
||||
// 2. Read env var (comma-separated)
|
||||
@@ -195,24 +269,36 @@ export function loadLinkedProjects(projectPath: string): string[] {
|
||||
* 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: string,
|
||||
): Array<{ name: string; label: string }> {
|
||||
const resolvedRoot = path.resolve(projectPath);
|
||||
const currentId = projectIdFromPath(resolvedRoot);
|
||||
const currentCoreId = coreProjectId(resolvedRoot);
|
||||
const seen = new Set<string>([currentId]);
|
||||
const collections: Array<{ name: string; label: string }> = [
|
||||
{ name: collectionName(currentId), label: path.basename(resolvedRoot) },
|
||||
];
|
||||
|
||||
const linked = loadLinkedProjects(resolvedRoot);
|
||||
for (const linkedPath of linked) {
|
||||
// Use base hash (no branch suffix) — linked projects are resolved by their
|
||||
// standard collection name regardless of SOCRATICODE_BRANCH_AWARE.
|
||||
const linkedId = coreProjectId(linkedPath);
|
||||
// Skip if same base project (e.g. worktrees sharing the same path hash)
|
||||
if (linkedId === currentCoreId) continue;
|
||||
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),
|
||||
|
||||
@@ -317,6 +317,88 @@ describe("config", () => {
|
||||
// And must NOT contain a branch suffix (double-underscore)
|
||||
expect(linkedNameWithBranch).not.toContain("__");
|
||||
});
|
||||
|
||||
it("honors a linked project's committed projectId", () => {
|
||||
// Symmetry guarantee: the same project must resolve to the same
|
||||
// Qdrant collection whether it's the current root or a linked
|
||||
// dependency from another project. Without this, cross-project
|
||||
// search would silently miss the linked project's data when that
|
||||
// project pins its id via .socraticode.json.
|
||||
fs.writeFileSync(
|
||||
path.join(linkedDir, ".socraticode.json"),
|
||||
JSON.stringify({ projectId: "linked-stable" }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, ".socraticode.json"),
|
||||
JSON.stringify({ linkedProjects: ["../linked-lib"] }),
|
||||
);
|
||||
|
||||
const collections = resolveLinkedCollections(projectDir);
|
||||
expect(collections).toHaveLength(2);
|
||||
expect(collections[1].name).toBe("codebase_linked-stable");
|
||||
});
|
||||
|
||||
it("dedups linked projects that share a committed projectId with the current project", () => {
|
||||
// Two physically distinct paths can declare the same shared
|
||||
// projectId — they point to the same Qdrant collection, so the
|
||||
// current project must not be queried twice (once as "self" and
|
||||
// once as "linked").
|
||||
fs.writeFileSync(
|
||||
path.join(linkedDir, ".socraticode.json"),
|
||||
JSON.stringify({ projectId: "shared-team-id" }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, ".socraticode.json"),
|
||||
JSON.stringify({ projectId: "shared-team-id", linkedProjects: ["../linked-lib"] }),
|
||||
);
|
||||
|
||||
const collections = resolveLinkedCollections(projectDir);
|
||||
expect(collections).toHaveLength(1);
|
||||
expect(collections[0].name).toBe("codebase_shared-team-id");
|
||||
});
|
||||
|
||||
it("does not wrongly dedup a linked project when env override diverges from file projectId", () => {
|
||||
// Regression for a subtle dedup misalignment: the dedup seed used
|
||||
// the file-only `effectiveBaseProjectId`, while the current
|
||||
// project's collection name comes from the env-aware
|
||||
// `projectIdFromPath`. When `SOCRATICODE_PROJECT_ID` is set on the
|
||||
// current project AND both projects pin the same `projectId` in
|
||||
// their `.socraticode.json`, the two computations disagreed and
|
||||
// the linked project was wrongly skipped — losing its data even
|
||||
// though it lives in a collection genuinely distinct from the
|
||||
// env-overridden current one.
|
||||
process.env.SOCRATICODE_PROJECT_ID = "env-override";
|
||||
fs.writeFileSync(
|
||||
path.join(linkedDir, ".socraticode.json"),
|
||||
JSON.stringify({ projectId: "shared-id" }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, ".socraticode.json"),
|
||||
JSON.stringify({ projectId: "shared-id", linkedProjects: ["../linked-lib"] }),
|
||||
);
|
||||
|
||||
const collections = resolveLinkedCollections(projectDir);
|
||||
expect(collections).toHaveLength(2);
|
||||
expect(collections[0].name).toBe("codebase_env-override");
|
||||
expect(collections[1].name).toBe("codebase_shared-id");
|
||||
});
|
||||
|
||||
it("does not leak SOCRATICODE_PROJECT_ID env var into linked-project collection names", () => {
|
||||
// The env var is process-scoped and applies only to the current
|
||||
// project. Without this guard, linked projects would all collapse
|
||||
// onto the env-var collection name — wrong and silently lossy.
|
||||
process.env.SOCRATICODE_PROJECT_ID = "current-only";
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, ".socraticode.json"),
|
||||
JSON.stringify({ linkedProjects: ["../linked-lib"] }),
|
||||
);
|
||||
|
||||
const collections = resolveLinkedCollections(projectDir);
|
||||
expect(collections).toHaveLength(2);
|
||||
expect(collections[0].name).toBe("codebase_current-only");
|
||||
expect(collections[1].name).toMatch(/^codebase_[0-9a-f]{12}$/);
|
||||
expect(collections[1].name).not.toBe("codebase_current-only");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Branch awareness ────────────────────────────────────────────────
|
||||
@@ -441,4 +523,127 @@ describe("config", () => {
|
||||
expect(id).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── projectId override via .socraticode.json ────────────────────────
|
||||
//
|
||||
// Allow teams to commit a stable `projectId` in `.socraticode.json` so
|
||||
// every machine that checks out the project addresses the same Qdrant
|
||||
// collection regardless of where the working tree lives on disk.
|
||||
|
||||
describe("projectIdFromPath with .socraticode.json projectId", () => {
|
||||
let tmpDir: string;
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-projid-"));
|
||||
projectDir = path.join(tmpDir, "my-project");
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
delete process.env.SOCRATICODE_PROJECT_ID;
|
||||
delete process.env.SOCRATICODE_BRANCH_AWARE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeConfig(config: unknown): void {
|
||||
fs.writeFileSync(path.join(projectDir, ".socraticode.json"), JSON.stringify(config));
|
||||
}
|
||||
|
||||
it("uses projectId from .socraticode.json when env var is not set", () => {
|
||||
writeConfig({ projectId: "team-shared-ios" });
|
||||
expect(projectIdFromPath(projectDir)).toBe("team-shared-ios");
|
||||
});
|
||||
|
||||
it("ignores path differences when file projectId is set", () => {
|
||||
// Two different projects on disk both pin to the same id — they should share collections.
|
||||
const otherDir = path.join(tmpDir, "another-project");
|
||||
fs.mkdirSync(otherDir, { recursive: true });
|
||||
writeConfig({ projectId: "shared-id" });
|
||||
fs.writeFileSync(
|
||||
path.join(otherDir, ".socraticode.json"),
|
||||
JSON.stringify({ projectId: "shared-id" }),
|
||||
);
|
||||
expect(projectIdFromPath(projectDir)).toBe(projectIdFromPath(otherDir));
|
||||
});
|
||||
|
||||
it("trims whitespace from file projectId", () => {
|
||||
writeConfig({ projectId: " trimmed-id " });
|
||||
expect(projectIdFromPath(projectDir)).toBe("trimmed-id");
|
||||
});
|
||||
|
||||
it("throws on invalid characters in file projectId", () => {
|
||||
writeConfig({ projectId: "invalid/name" });
|
||||
expect(() => projectIdFromPath(projectDir)).toThrow(
|
||||
/\.socraticode\.json.*projectId.*\[a-zA-Z0-9_-\]/,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to path hash when file projectId is empty string", () => {
|
||||
writeConfig({ projectId: "" });
|
||||
expect(projectIdFromPath(projectDir)).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it("falls back to path hash when file projectId is whitespace only", () => {
|
||||
writeConfig({ projectId: " " });
|
||||
expect(projectIdFromPath(projectDir)).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it("falls back to path hash when file projectId has wrong type", () => {
|
||||
writeConfig({ projectId: 12345 });
|
||||
expect(projectIdFromPath(projectDir)).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it("falls back to path hash when file projectId field is null", () => {
|
||||
writeConfig({ projectId: null });
|
||||
expect(projectIdFromPath(projectDir)).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it("SOCRATICODE_PROJECT_ID env var takes precedence over file projectId", () => {
|
||||
writeConfig({ projectId: "from-file" });
|
||||
process.env.SOCRATICODE_PROJECT_ID = "from-env";
|
||||
expect(projectIdFromPath(projectDir)).toBe("from-env");
|
||||
});
|
||||
|
||||
it("uses file projectId when .socraticode.json is absent → falls back to hash", () => {
|
||||
// No .socraticode.json written — current default behavior unchanged
|
||||
expect(projectIdFromPath(projectDir)).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it("falls back to hash when .socraticode.json is malformed JSON", () => {
|
||||
fs.writeFileSync(path.join(projectDir, ".socraticode.json"), "not valid json{{{");
|
||||
expect(projectIdFromPath(projectDir)).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it("does not append branch suffix when file projectId is set", () => {
|
||||
writeConfig({ projectId: "stable-id" });
|
||||
process.env.SOCRATICODE_BRANCH_AWARE = "true";
|
||||
// Initialize tmpDir as a git repo with a non-default branch to prove
|
||||
// that branch-aware mode is suppressed by an explicit projectId.
|
||||
// Disable gpg/ssh signing locally so the test is robust against the
|
||||
// user's global git config (commit.gpgsign=true is common).
|
||||
execFileSync("git", ["init", "-b", "feature-branch", projectDir]);
|
||||
execFileSync("git", ["config", "user.name", "test"], { cwd: projectDir });
|
||||
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: projectDir });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: projectDir });
|
||||
execFileSync("git", ["config", "tag.gpgsign", "false"], { cwd: projectDir });
|
||||
execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: projectDir });
|
||||
|
||||
const id = projectIdFromPath(projectDir);
|
||||
expect(id).toBe("stable-id");
|
||||
expect(id).not.toContain("__");
|
||||
});
|
||||
|
||||
it("coexists with linkedProjects field in the same file", () => {
|
||||
// Regression guard: adding projectId must not break linkedProjects parsing.
|
||||
const linked = path.join(tmpDir, "linked");
|
||||
fs.mkdirSync(linked, { recursive: true });
|
||||
writeConfig({ projectId: "shared-id", linkedProjects: ["../linked"] });
|
||||
expect(projectIdFromPath(projectDir)).toBe("shared-id");
|
||||
// loadLinkedProjects is still happy — round-trip via resolveLinkedCollections
|
||||
const collections = resolveLinkedCollections(projectDir);
|
||||
expect(collections).toHaveLength(2);
|
||||
expect(collections[0].name).toBe("codebase_shared-id");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user