forked from giancarloerra/SocratiCode
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathconfig.ts
More file actions
215 lines (191 loc) · 7.41 KB
/
Copy pathconfig.ts
File metadata and controls
215 lines (191 loc) · 7.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
// 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";
// ── 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: string): string | null {
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: string): string {
return branch
.replace(/[^a-zA-Z0-9_-]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "");
}
/**
* 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_-]`).
*
* 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.
*/
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;
}
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 = detectGitBranch(path.resolve(folderPath));
if (branch) {
const sanitized = sanitizeBranchName(branch);
if (sanitized) {
id = `${id}__${sanitized}`;
}
}
}
return id;
}
/**
* 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.
*/
function coreProjectId(folderPath: string): string {
const normalized = path.resolve(folderPath);
return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
}
/**
* Derive a Qdrant collection name for a project's code chunks.
*/
export function collectionName(projectId: string): string {
return `codebase_${projectId}`;
}
/**
* Derive a Qdrant collection name for a project's code graph.
*/
export function graphCollectionName(projectId: string): string {
return `codegraph_${projectId}`;
}
/**
* Derive a Qdrant collection name for a project's context artifacts.
*/
export function contextCollectionName(projectId: string): string {
return `context_${projectId}`;
}
// ── Symbol graph collections ─────────────────────────────────────────────
/** Top-level metadata point for a project's symbol graph. */
export function symgraphMetaCollectionName(projectId: string): string {
return `${projectId}_symgraph_meta`;
}
/** Per-file payloads for a project's symbol graph. */
export function symgraphFileCollectionName(projectId: string): string {
return `${projectId}_symgraph_file`;
}
/** Sharded indices (name index + reverse-call file index). */
export function symgraphIndexCollectionName(projectId: string): string {
return `${projectId}_symgraph_index`;
}
// ── Linked projects ──────────────────────────────────────────────────────
/** Configuration file name for linked projects */
const SOCRATICODE_CONFIG_FILE = ".socraticode.json";
/** Shape of .socraticode.json */
interface SocratiCodeConfig {
linkedProjects?: string[];
}
/**
* 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: string): string[] {
const resolvedRoot = path.resolve(projectPath);
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);
}
}
}
}
}
} catch {
// Malformed JSON or read error — skip silently
}
// 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).
*/
export function resolveLinkedCollections(
projectPath: string,
): Array<{ name: string; label: string }> {
const resolvedRoot = path.resolve(projectPath);
const currentId = projectIdFromPath(resolvedRoot);
const currentCoreId = coreProjectId(resolvedRoot);
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;
collections.push({
name: collectionName(linkedId),
label: path.basename(linkedPath),
});
}
return collections;
}