Files
socraticode/tests/unit/config.test.ts
T
Tomasz Szuster 2c4d55ca50 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>
2026-05-06 15:05:43 +01:00

650 lines
26 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { collectionName, contextCollectionName, detectGitBranch, graphCollectionName, loadLinkedProjects, projectIdFromPath, resolveLinkedCollections, sanitizeBranchName } from "../../src/config.js";
describe("config", () => {
// Clean up env overrides between tests
const originalEnv = process.env.SOCRATICODE_PROJECT_ID;
const originalBranchAware = process.env.SOCRATICODE_BRANCH_AWARE;
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.SOCRATICODE_PROJECT_ID;
} else {
process.env.SOCRATICODE_PROJECT_ID = originalEnv;
}
if (originalBranchAware === undefined) {
delete process.env.SOCRATICODE_BRANCH_AWARE;
} else {
process.env.SOCRATICODE_BRANCH_AWARE = originalBranchAware;
}
});
describe("projectIdFromPath", () => {
it("returns a 12-character hex string", () => {
const id = projectIdFromPath("/some/project/path");
expect(id).toMatch(/^[0-9a-f]{12}$/);
});
it("returns the same ID for the same path", () => {
const id1 = projectIdFromPath("/some/project/path");
const id2 = projectIdFromPath("/some/project/path");
expect(id1).toBe(id2);
});
it("returns different IDs for different paths", () => {
const id1 = projectIdFromPath("/project/alpha");
const id2 = projectIdFromPath("/project/beta");
expect(id1).not.toBe(id2);
});
it("normalizes paths (resolves relative components)", () => {
const id1 = projectIdFromPath("/some/project/./path");
const id2 = projectIdFromPath("/some/project/path");
expect(id1).toBe(id2);
});
it("normalizes paths with parent directory references", () => {
const id1 = projectIdFromPath("/some/project/sub/../path");
const id2 = projectIdFromPath("/some/project/path");
expect(id1).toBe(id2);
});
it("handles paths with trailing slashes consistently", () => {
// path.resolve strips trailing slashes, so these should differ
// (or be the same depending on OS behavior)
const id1 = projectIdFromPath("/some/project");
const id2 = projectIdFromPath("/some/project/");
// path.resolve normalizes trailing slash, so they should match
expect(id1).toBe(id2);
});
it("uses SOCRATICODE_PROJECT_ID when set", () => {
process.env.SOCRATICODE_PROJECT_ID = "my-shared-project";
const id = projectIdFromPath("/some/project/path");
expect(id).toBe("my-shared-project");
});
it("ignores path differences when SOCRATICODE_PROJECT_ID is set", () => {
process.env.SOCRATICODE_PROJECT_ID = "shared";
const id1 = projectIdFromPath("/worktree/a");
const id2 = projectIdFromPath("/worktree/b");
expect(id1).toBe(id2);
});
it("trims whitespace from SOCRATICODE_PROJECT_ID", () => {
process.env.SOCRATICODE_PROJECT_ID = " my-project ";
expect(projectIdFromPath("/any/path")).toBe("my-project");
});
it("throws on invalid SOCRATICODE_PROJECT_ID characters", () => {
process.env.SOCRATICODE_PROJECT_ID = "invalid/name";
expect(() => projectIdFromPath("/any/path")).toThrow(
/SOCRATICODE_PROJECT_ID must match/,
);
});
it("falls back to hash when SOCRATICODE_PROJECT_ID is empty", () => {
process.env.SOCRATICODE_PROJECT_ID = " ";
const id = projectIdFromPath("/some/project/path");
expect(id).toMatch(/^[0-9a-f]{12}$/);
});
});
describe("collectionName", () => {
it("prefixes with codebase_", () => {
expect(collectionName("abc123def456")).toBe("codebase_abc123def456");
});
it("handles empty string", () => {
expect(collectionName("")).toBe("codebase_");
});
});
describe("graphCollectionName", () => {
it("prefixes with codegraph_", () => {
expect(graphCollectionName("abc123def456")).toBe("codegraph_abc123def456");
});
it("handles empty string", () => {
expect(graphCollectionName("")).toBe("codegraph_");
});
});
describe("contextCollectionName", () => {
it("prefixes with context_", () => {
expect(contextCollectionName("abc123def456")).toBe("context_abc123def456");
});
it("handles empty string", () => {
expect(contextCollectionName("")).toBe("context_");
});
});
describe("round-trip: path → projectId → collection names", () => {
it("produces valid Qdrant-friendly collection names", () => {
const projectId = projectIdFromPath("/Users/test/my-project");
const coll = collectionName(projectId);
const graphColl = graphCollectionName(projectId);
// Qdrant collection names must match: ^[a-zA-Z0-9_-]+$
expect(coll).toMatch(/^[a-zA-Z0-9_-]+$/);
expect(graphColl).toMatch(/^[a-zA-Z0-9_-]+$/);
expect(coll).toMatch(/^codebase_[0-9a-f]{12}$/);
expect(graphColl).toMatch(/^codegraph_[0-9a-f]{12}$/);
});
it("produces a valid context collection name", () => {
const projectId = projectIdFromPath("/Users/test/my-project");
const contextColl = contextCollectionName(projectId);
expect(contextColl).toMatch(/^[a-zA-Z0-9_-]+$/);
expect(contextColl).toMatch(/^context_[0-9a-f]{12}$/);
});
});
// ── Linked projects ─────────────────────────────────────────────────
describe("loadLinkedProjects", () => {
let tmpDir: string;
let projectDir: string;
let linkedDirA: string;
let linkedDirB: string;
const originalLinkedEnv = process.env.SOCRATICODE_LINKED_PROJECTS;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-test-"));
projectDir = path.join(tmpDir, "my-project");
linkedDirA = path.join(tmpDir, "linked-a");
linkedDirB = path.join(tmpDir, "linked-b");
fs.mkdirSync(projectDir, { recursive: true });
fs.mkdirSync(linkedDirA, { recursive: true });
fs.mkdirSync(linkedDirB, { recursive: true });
delete process.env.SOCRATICODE_LINKED_PROJECTS;
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
if (originalLinkedEnv === undefined) {
delete process.env.SOCRATICODE_LINKED_PROJECTS;
} else {
process.env.SOCRATICODE_LINKED_PROJECTS = originalLinkedEnv;
}
});
it("returns empty array when no .socraticode.json exists", () => {
expect(loadLinkedProjects(projectDir)).toEqual([]);
});
it("reads linked projects from .socraticode.json", () => {
fs.writeFileSync(
path.join(projectDir, ".socraticode.json"),
JSON.stringify({ linkedProjects: ["../linked-a", "../linked-b"] }),
);
const result = loadLinkedProjects(projectDir);
expect(result).toHaveLength(2);
expect(result).toContain(linkedDirA);
expect(result).toContain(linkedDirB);
});
it("skips non-existent linked paths", () => {
fs.writeFileSync(
path.join(projectDir, ".socraticode.json"),
JSON.stringify({ linkedProjects: ["../linked-a", "../does-not-exist"] }),
);
const result = loadLinkedProjects(projectDir);
expect(result).toHaveLength(1);
expect(result).toContain(linkedDirA);
});
it("skips self-references", () => {
fs.writeFileSync(
path.join(projectDir, ".socraticode.json"),
JSON.stringify({ linkedProjects: [".", "../my-project"] }),
);
expect(loadLinkedProjects(projectDir)).toEqual([]);
});
it("reads from SOCRATICODE_LINKED_PROJECTS env var", () => {
process.env.SOCRATICODE_LINKED_PROJECTS = `${linkedDirA},${linkedDirB}`;
const result = loadLinkedProjects(projectDir);
expect(result).toHaveLength(2);
expect(result).toContain(linkedDirA);
expect(result).toContain(linkedDirB);
});
it("merges config file and env var without duplicates", () => {
fs.writeFileSync(
path.join(projectDir, ".socraticode.json"),
JSON.stringify({ linkedProjects: ["../linked-a"] }),
);
process.env.SOCRATICODE_LINKED_PROJECTS = `${linkedDirA},${linkedDirB}`;
const result = loadLinkedProjects(projectDir);
// linked-a appears in both sources but should be deduplicated
expect(result).toHaveLength(2);
expect(result).toContain(linkedDirA);
expect(result).toContain(linkedDirB);
});
it("handles malformed .socraticode.json gracefully", () => {
fs.writeFileSync(path.join(projectDir, ".socraticode.json"), "not valid json{{{");
expect(loadLinkedProjects(projectDir)).toEqual([]);
});
it("handles .socraticode.json with missing linkedProjects field", () => {
fs.writeFileSync(
path.join(projectDir, ".socraticode.json"),
JSON.stringify({ someOtherField: true }),
);
expect(loadLinkedProjects(projectDir)).toEqual([]);
});
});
describe("resolveLinkedCollections", () => {
let tmpDir: string;
let projectDir: string;
let linkedDir: string;
const savedLinkedEnv = process.env.SOCRATICODE_LINKED_PROJECTS;
const savedProjectIdEnv = process.env.SOCRATICODE_PROJECT_ID;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-test-"));
projectDir = path.join(tmpDir, "main-project");
linkedDir = path.join(tmpDir, "linked-lib");
fs.mkdirSync(projectDir, { recursive: true });
fs.mkdirSync(linkedDir, { recursive: true });
delete process.env.SOCRATICODE_LINKED_PROJECTS;
delete process.env.SOCRATICODE_PROJECT_ID;
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
if (savedLinkedEnv === undefined) {
delete process.env.SOCRATICODE_LINKED_PROJECTS;
} else {
process.env.SOCRATICODE_LINKED_PROJECTS = savedLinkedEnv;
}
if (savedProjectIdEnv === undefined) {
delete process.env.SOCRATICODE_PROJECT_ID;
} else {
process.env.SOCRATICODE_PROJECT_ID = savedProjectIdEnv;
}
});
it("returns only current project when no links configured", () => {
const collections = resolveLinkedCollections(projectDir);
expect(collections).toHaveLength(1);
expect(collections[0].label).toBe("main-project");
expect(collections[0].name).toMatch(/^codebase_/);
});
it("returns current + linked collections with correct labels", () => {
fs.writeFileSync(
path.join(projectDir, ".socraticode.json"),
JSON.stringify({ linkedProjects: ["../linked-lib"] }),
);
const collections = resolveLinkedCollections(projectDir);
expect(collections).toHaveLength(2);
// Current project is first (highest priority)
expect(collections[0].label).toBe("main-project");
expect(collections[1].label).toBe("linked-lib");
// Different collection names
expect(collections[0].name).not.toBe(collections[1].name);
});
it("linked collections use base hash without branch suffix when SOCRATICODE_BRANCH_AWARE is true", () => {
process.env.SOCRATICODE_BRANCH_AWARE = "true";
fs.writeFileSync(
path.join(projectDir, ".socraticode.json"),
JSON.stringify({ linkedProjects: ["../linked-lib"] }),
);
// Get linked collection name with branch-aware on
const withBranch = resolveLinkedCollections(projectDir);
expect(withBranch).toHaveLength(2);
const linkedNameWithBranch = withBranch[1].name;
// Get linked collection name with branch-aware off
delete process.env.SOCRATICODE_BRANCH_AWARE;
const withoutBranch = resolveLinkedCollections(projectDir);
const linkedNameWithoutBranch = withoutBranch[1].name;
// Linked project collection name must be identical regardless of SOCRATICODE_BRANCH_AWARE
expect(linkedNameWithBranch).toBe(linkedNameWithoutBranch);
// 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 ────────────────────────────────────────────────
describe("sanitizeBranchName", () => {
it("passes through simple branch names", () => {
expect(sanitizeBranchName("main")).toBe("main");
expect(sanitizeBranchName("develop")).toBe("develop");
});
it("replaces slashes with underscores", () => {
expect(sanitizeBranchName("feat/my-feature")).toBe("feat_my-feature");
});
it("handles deeply nested branch names", () => {
expect(sanitizeBranchName("feature/JIRA-123/some-work")).toBe(
"feature_JIRA-123_some-work",
);
});
it("collapses consecutive underscores", () => {
expect(sanitizeBranchName("feat//double")).toBe("feat_double");
});
it("strips leading and trailing underscores", () => {
expect(sanitizeBranchName("/leading")).toBe("leading");
expect(sanitizeBranchName("trailing/")).toBe("trailing");
});
it("preserves hyphens", () => {
expect(sanitizeBranchName("my-branch-name")).toBe("my-branch-name");
});
it("replaces special characters", () => {
expect(sanitizeBranchName("feat@v2.0")).toBe("feat_v2_0");
});
it("returns empty string when all characters are invalid", () => {
expect(sanitizeBranchName("///")).toBe("");
expect(sanitizeBranchName("@@@")).toBe("");
expect(sanitizeBranchName("...")).toBe("");
});
});
/** Create a temporary git repo with a named branch and initial commit. */
function initTempRepo(tmpDir: string, branch: string): void {
execFileSync("git", ["init", "-b", branch, tmpDir]);
execFileSync("git", ["config", "user.name", "test"], { cwd: tmpDir });
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: tmpDir });
execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: tmpDir });
}
describe("detectGitBranch", () => {
it("detects a branch in a git repo", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-git-"));
try {
initTempRepo(tmpDir, "test-branch");
const branch = detectGitBranch(tmpDir);
expect(branch).toBe("test-branch");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("returns null for non-git directories", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-nogit-"));
try {
expect(detectGitBranch(tmpDir)).toBeNull();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
describe("projectIdFromPath with SOCRATICODE_BRANCH_AWARE", () => {
it("does not include branch suffix by default", () => {
const id = projectIdFromPath("/some/project/path");
expect(id).toMatch(/^[0-9a-f]{12}$/);
expect(id).not.toContain("__");
});
it("appends branch suffix when SOCRATICODE_BRANCH_AWARE=true", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-braware-"));
try {
initTempRepo(tmpDir, "my-feature");
process.env.SOCRATICODE_BRANCH_AWARE = "true";
const id = projectIdFromPath(tmpDir);
expect(id).toContain("__");
const parts = id.split("__");
expect(parts[0]).toMatch(/^[0-9a-f]{12}$/);
expect(parts[1]).toBe("my-feature");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("produces valid Qdrant collection names with branch suffix", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-braware-"));
try {
initTempRepo(tmpDir, "feat/some-branch");
process.env.SOCRATICODE_BRANCH_AWARE = "true";
const id = projectIdFromPath(tmpDir);
const coll = collectionName(id);
// Must be valid Qdrant name: [a-zA-Z0-9_-]+
expect(coll).toMatch(/^[a-zA-Z0-9_-]+$/);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("does not append branch when SOCRATICODE_PROJECT_ID is set", () => {
process.env.SOCRATICODE_BRANCH_AWARE = "true";
process.env.SOCRATICODE_PROJECT_ID = "explicit-id";
const id = projectIdFromPath(process.cwd());
expect(id).toBe("explicit-id");
expect(id).not.toContain("__");
});
it("does not append branch when SOCRATICODE_BRANCH_AWARE is not true", () => {
process.env.SOCRATICODE_BRANCH_AWARE = "false";
const id = projectIdFromPath(process.cwd());
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");
});
});
});