diff --git a/src/resources/extensions/sf/bootstrap/system-context.ts b/src/resources/extensions/sf/bootstrap/system-context.ts index 891eb9cc7..51db630d9 100644 --- a/src/resources/extensions/sf/bootstrap/system-context.ts +++ b/src/resources/extensions/sf/bootstrap/system-context.ts @@ -150,9 +150,10 @@ export async function buildBeforeAgentStartResult( } try { + const codebasePrefs = loadedPreferences?.preferences?.codebase; codeIntelligenceBlock = buildCodeIntelligenceContextBlock( process.cwd(), - loadedPreferences?.preferences?.codebase, + codebasePrefs, ); } catch (e) { logWarning("bootstrap", `code intelligence block failed: ${(e as Error).message}`); diff --git a/src/resources/extensions/sf/code-intelligence.ts b/src/resources/extensions/sf/code-intelligence.ts index 7279ae862..67317374c 100644 --- a/src/resources/extensions/sf/code-intelligence.ts +++ b/src/resources/extensions/sf/code-intelligence.ts @@ -1,18 +1,19 @@ /** * Optional code-intelligence backends for SF. * - * CODEBASE.md stays the durable baseline. Project RAG is an optional MCP - * accelerator for local hybrid vector + BM25 code retrieval. + * CODEBASE.md stays the durable baseline. Codebase indexers are optional + * accelerators for local code retrieval. */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { delimiter, join, resolve } from "node:path"; -import type { CodebaseMapPreferences } from "./preferences-types.js"; +import type { CodebaseIndexerBackendName, CodebaseMapPreferences } from "./preferences-types.js"; export const PROJECT_RAG_MCP_SERVER_NAME = "project-rag"; const PROJECT_RAG_BINARY_NAME = process.platform === "win32" ? "project-rag.exe" : "project-rag"; +const SIFT_BINARY_NAME = process.platform === "win32" ? "sift.exe" : "sift"; const PROJECT_RAG_SOURCE_CANDIDATES = [ "vendor/project-rag", @@ -37,7 +38,29 @@ interface McpConfigFile { [key: string]: unknown; } -export interface ProjectRagDetection { +export type CodebaseIndexerStatus = "disabled" | "configured" | "missing"; + +export interface CodebaseIndexerDetection { + backend: CodebaseIndexerBackendName; + status: CodebaseIndexerStatus; + reason: string; + serverName?: string; + configPath?: string; + command?: string; + binaryPath?: string; + sourceDir?: string; +} + +export interface CodebaseIndexerBackend { + name: CodebaseIndexerBackendName; + label: string; + detect(projectRoot: string, prefs?: CodebaseMapPreferences, env?: NodeJS.ProcessEnv): TDetection; + formatStatus(projectRoot: string, prefs?: CodebaseMapPreferences, env?: NodeJS.ProcessEnv): string; + buildContextLines(projectRoot: string, prefs?: CodebaseMapPreferences, env?: NodeJS.ProcessEnv): string[]; +} + +export interface ProjectRagDetection extends CodebaseIndexerDetection { + backend: "projectRag"; status: "disabled" | "configured" | "missing"; serverName?: string; configPath?: string; @@ -47,6 +70,14 @@ export interface ProjectRagDetection { reason: string; } +export interface SiftDetection extends CodebaseIndexerDetection { + backend: "sift"; + status: "disabled" | "configured" | "missing"; + command?: string; + binaryPath?: string; + reason: string; +} + export interface EnsureProjectRagMcpConfigResult { configPath: string; serverName: string; @@ -110,24 +141,26 @@ function commandExists(command: string | undefined, env: NodeJS.ProcessEnv = pro export function detectProjectRag( projectRoot: string, prefs?: CodebaseMapPreferences, + env: NodeJS.ProcessEnv = process.env, ): ProjectRagDetection { const mode = prefs?.project_rag ?? "auto"; if (mode === "off") { - return { status: "disabled", reason: "codebase.project_rag is off" }; + return { backend: "projectRag", status: "disabled", reason: "codebase.project_rag is off" }; } const configuredServer = prefs?.project_rag_server?.trim(); const normalizedRoot = normalizeProjectRoot(projectRoot); - const binaryPath = resolveProjectRagBinaryForProject(normalizedRoot, process.env) ?? undefined; - const sourceDir = findProjectRagSourceDir(normalizedRoot, process.env) ?? undefined; + const binaryPath = resolveProjectRagBinaryForProject(normalizedRoot, env) ?? undefined; + const sourceDir = findProjectRagSourceDir(normalizedRoot, env) ?? undefined; const entries = readMcpConfigEntries(normalizedRoot); const match = entries.find(({ name, config }) => configuredServer ? name === configuredServer : configLooksLikeProjectRag(name, config) ); if (match) { - const configuredCommandExists = commandExists(match.config.command); + const configuredCommandExists = commandExists(match.config.command, env); return { + backend: "projectRag", status: "configured", serverName: match.name, configPath: match.configPath, @@ -141,6 +174,7 @@ export function detectProjectRag( } return { + backend: "projectRag", status: "missing", binaryPath, sourceDir, @@ -151,7 +185,7 @@ export function detectProjectRag( } function lookupExecutable(command: string, env: NodeJS.ProcessEnv = process.env): string | null { - if (command.includes("/") && existsSync(command)) return command; + if (/[\\/]/.test(command) && existsSync(command)) return command; const pathValue = env.PATH ?? ""; for (const dir of pathValue.split(delimiter).filter(Boolean)) { const candidate = join(dir, command); @@ -166,6 +200,55 @@ export function resolveProjectRagBinary(env: NodeJS.ProcessEnv = process.env): s return lookupExecutable("project-rag", env); } +export function resolveSiftBinary(env: NodeJS.ProcessEnv = process.env): string | null { + const explicit = env.SIFT_PATH?.trim(); + if (explicit) return explicit; + return lookupExecutable(SIFT_BINARY_NAME, env) + ?? (SIFT_BINARY_NAME === "sift" ? null : lookupExecutable("sift", env)); +} + +export function detectSift( + _projectRoot: string, + prefs?: CodebaseMapPreferences, + env: NodeJS.ProcessEnv = process.env, +): SiftDetection { + if (prefs?.indexer_backend === "none") { + return { + backend: "sift", + status: "disabled", + reason: "codebase.indexer_backend is none", + }; + } + + const explicit = env.SIFT_PATH?.trim(); + const binaryPath = resolveSiftBinary(env) ?? undefined; + if (!binaryPath) { + return { + backend: "sift", + status: "missing", + reason: "sift binary not found on PATH; set SIFT_PATH or install rupurt/sift.", + }; + } + + if (explicit && !commandExists(explicit, env)) { + return { + backend: "sift", + status: "missing", + command: explicit, + binaryPath: explicit, + reason: "SIFT_PATH is set but does not resolve to an executable file.", + }; + } + + return { + backend: "sift", + status: "configured", + command: binaryPath, + binaryPath, + reason: explicit ? "sift binary resolved from SIFT_PATH" : "sift binary found on PATH", + }; +} + function projectRagBinaryFromSource(sourceDir: string): string | null { const candidate = join(sourceDir, "target", "release", PROJECT_RAG_BINARY_NAME); return existsSync(candidate) ? candidate : null; @@ -328,16 +411,13 @@ function formatToolPrefix(serverName: string): string { return `mcp__${serverName.replace(/[^A-Za-z0-9_]/g, "_")}__`; } -export function buildCodeIntelligenceContextBlock( +function buildProjectRagContextLines( projectRoot: string, prefs?: CodebaseMapPreferences, -): string { - const detection = detectProjectRag(projectRoot, prefs); - const lines = [ - "[PROJECT CODE INTELLIGENCE]", - "", - "- Durable baseline: use `.sf/CODEBASE.md` for structural orientation and persistent project knowledge.", - ]; + env: NodeJS.ProcessEnv = process.env, +): string[] { + const detection = detectProjectRag(projectRoot, prefs, env); + const lines: string[] = []; if (detection.status === "disabled") { lines.push("- Project RAG: disabled by `codebase.project_rag: off`."); @@ -364,11 +444,100 @@ export function buildCodeIntelligenceContextBlock( lines.push("- To enable later: build/install Brainwires/project-rag, then run `/sf codebase rag init` or set `SF_PROJECT_RAG_BIN` before initializing MCP config."); } + return lines; +} + +function buildSiftContextLines( + projectRoot: string, + prefs?: CodebaseMapPreferences, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const detection = detectSift(projectRoot, prefs, env); + const lines: string[] = []; + + if (detection.status === "disabled") { + lines.push("- Codebase indexer: disabled by `codebase.indexer_backend: none`."); + } else if (detection.status === "configured" && detection.binaryPath) { + lines.push(`- Sift: configured as local CLI \`${detection.binaryPath}\`.`); + lines.push( + "- Use Sift for broad code retrieval before manual file-by-file reading, " + + "especially conceptual queries, exact identifiers, approximate file/path intent, and synthesis-ready snippets.", + ); + lines.push( + "- Command shape: `sift search --json \"\"`. " + + "Use `--strategy page-index-hybrid` for the strongest direct-search preset, " + + "`--strategy path-hybrid` for path-heavy queries, and `sift search --agent \"\"` for bounded planner-driven exploration.", + ); + lines.push( + "- Sift uses a sector-aware cache in the platform cache directory, typically `~/.cache/sift`; " + + "if the CLI is missing or fails, continue with `.sf/CODEBASE.md`, `rg`, `lsp`, and scout.", + ); + } else { + lines.push("- Sift: not available. This is optional; continue with `.sf/CODEBASE.md`, `rg`, `lsp`, and scout."); + lines.push("- To enable later: install `rupurt/sift` on PATH or set `SIFT_PATH` to the sift binary."); + } + + return lines; +} + +function buildNoCodebaseIndexerContextLines(): string[] { + return [ + "- Codebase indexer: disabled by `codebase.indexer_backend: none`; continue with `.sf/CODEBASE.md`, `rg`, `lsp`, and scout.", + ]; +} + +export function resolveCodebaseIndexerBackendName( + prefs?: CodebaseMapPreferences, +): CodebaseIndexerBackendName { + return prefs?.indexer_backend ?? "projectRag"; +} + +export function getCodebaseIndexerBackend( + prefsOrName?: CodebaseMapPreferences | CodebaseIndexerBackendName, +): CodebaseIndexerBackend { + const name = typeof prefsOrName === "string" + ? prefsOrName + : resolveCodebaseIndexerBackendName(prefsOrName); + return CODEBASE_INDEXER_BACKENDS[name]; +} + +export function detectCodebaseIndexer( + projectRoot: string, + prefs?: CodebaseMapPreferences, + env: NodeJS.ProcessEnv = process.env, +): CodebaseIndexerDetection { + return getCodebaseIndexerBackend(prefs).detect(projectRoot, prefs, env); +} + +export function formatCodebaseIndexerStatus( + projectRoot: string, + prefs?: CodebaseMapPreferences, + env: NodeJS.ProcessEnv = process.env, +): string { + return getCodebaseIndexerBackend(prefs).formatStatus(projectRoot, prefs, env); +} + +export function buildCodeIntelligenceContextBlock( + projectRoot: string, + prefs?: CodebaseMapPreferences, + env: NodeJS.ProcessEnv = process.env, +): string { + const lines = [ + "[PROJECT CODE INTELLIGENCE]", + "", + "- Durable baseline: use `.sf/CODEBASE.md` for structural orientation and persistent project knowledge.", + ...getCodebaseIndexerBackend(prefs).buildContextLines(projectRoot, prefs, env), + ]; + return `\n\n${lines.join("\n")}`; } -export function formatProjectRagStatus(projectRoot: string, prefs?: CodebaseMapPreferences): string { - const detection = detectProjectRag(projectRoot, prefs); +export function formatProjectRagStatus( + projectRoot: string, + prefs?: CodebaseMapPreferences, + env: NodeJS.ProcessEnv = process.env, +): string { + const detection = detectProjectRag(projectRoot, prefs, env); const lines = ["Project RAG Status", ""]; lines.push(`Status: ${detection.status}`); lines.push(`Reason: ${detection.reason}`); @@ -378,7 +547,7 @@ export function formatProjectRagStatus(projectRoot: string, prefs?: CodebaseMapP if (detection.binaryPath) lines.push(`Binary: ${detection.binaryPath}`); if (detection.sourceDir) lines.push(`Source: ${detection.sourceDir}`); if (detection.status === "configured" && detection.command) { - lines.push(`Operational: ${commandExists(detection.command) ? "yes" : "no - configured command is missing"}`); + lines.push(`Operational: ${commandExists(detection.command, env) ? "yes" : "no - configured command is missing"}`); } else if (detection.binaryPath) { lines.push("Operational: no - binary exists but MCP config is missing; run /sf codebase rag init."); } else if (detection.sourceDir) { @@ -391,3 +560,72 @@ export function formatProjectRagStatus(projectRoot: string, prefs?: CodebaseMapP lines.push("When configured, agents should use index_codebase, query_codebase, search_by_filters, find_definition, find_references, and get_call_graph before manual file-by-file reading."); return lines.join("\n"); } + +export function formatSiftStatus( + projectRoot: string, + prefs?: CodebaseMapPreferences, + env: NodeJS.ProcessEnv = process.env, +): string { + const detection = detectSift(projectRoot, prefs, env); + const lines = ["Sift Status", ""]; + lines.push(`Status: ${detection.status}`); + lines.push(`Reason: ${detection.reason}`); + if (detection.command) lines.push(`Command: ${detection.command}`); + if (detection.binaryPath) lines.push(`Binary: ${detection.binaryPath}`); + if (detection.status === "configured" && detection.command) { + lines.push(`Operational: ${commandExists(detection.command, env) ? "yes" : "no - configured command is missing"}`); + } else { + lines.push("Operational: no - install rupurt/sift on PATH or set SIFT_PATH."); + } + lines.push(""); + lines.push("Sift is optional. SF falls back to CODEBASE.md, rg, lsp, and scout when it is unavailable."); + lines.push("When configured, agents should use `sift search --json \"\"`; `page-index-hybrid` is the strongest direct-search preset and `path-hybrid` is best for path-heavy queries."); + lines.push("Sift reuses a sector-aware cache in the platform cache directory, typically ~/.cache/sift."); + return lines.join("\n"); +} + +function formatNoCodebaseIndexerStatus(): string { + return [ + "Codebase Indexer Status", + "", + "Status: disabled", + "Reason: codebase.indexer_backend is none", + "Operational: no - optional codebase indexer disabled.", + "", + "SF will use CODEBASE.md, rg, lsp, and scout for codebase orientation.", + ].join("\n"); +} + +export const PROJECT_RAG_CODEBASE_INDEXER_BACKEND: CodebaseIndexerBackend = { + name: "projectRag", + label: "Project RAG", + detect: detectProjectRag, + formatStatus: formatProjectRagStatus, + buildContextLines: buildProjectRagContextLines, +}; + +export const SIFT_CODEBASE_INDEXER_BACKEND: CodebaseIndexerBackend = { + name: "sift", + label: "Sift", + detect: detectSift, + formatStatus: formatSiftStatus, + buildContextLines: buildSiftContextLines, +}; + +export const NO_CODEBASE_INDEXER_BACKEND: CodebaseIndexerBackend = { + name: "none", + label: "None", + detect: () => ({ + backend: "none", + status: "disabled", + reason: "codebase.indexer_backend is none", + }), + formatStatus: formatNoCodebaseIndexerStatus, + buildContextLines: buildNoCodebaseIndexerContextLines, +}; + +export const CODEBASE_INDEXER_BACKENDS: Record = { + projectRag: PROJECT_RAG_CODEBASE_INDEXER_BACKEND, + sift: SIFT_CODEBASE_INDEXER_BACKEND, + none: NO_CODEBASE_INDEXER_BACKEND, +}; diff --git a/src/resources/extensions/sf/commands-codebase.ts b/src/resources/extensions/sf/commands-codebase.ts index 5b743a0aa..0b71c0842 100644 --- a/src/resources/extensions/sf/commands-codebase.ts +++ b/src/resources/extensions/sf/commands-codebase.ts @@ -2,7 +2,7 @@ * SF Command — /sf codebase * * Generate and manage the codebase map (.sf/CODEBASE.md). - * Subcommands: generate, update, stats, rag, help + * Subcommands: generate, update, stats, indexer, rag, help */ import type { ExtensionAPI, ExtensionCommandContext } from "@singularity-forge/pi-coding-agent"; @@ -17,17 +17,18 @@ import { import { buildProjectRagBinary, ensureProjectRagMcpConfig, - formatProjectRagStatus, + formatCodebaseIndexerStatus, } from "./code-intelligence.js"; import { loadEffectiveSFPreferences } from "./preferences.js"; import type { CodebaseMapOptions } from "./codebase-generator.js"; const USAGE = - "Usage: /sf codebase [generate|update|stats|rag]\n\n" + + "Usage: /sf codebase [generate|update|stats|indexer|rag]\n\n" + " generate [--max-files N] [--collapse-threshold N] — Generate or regenerate CODEBASE.md\n" + " update [--max-files N] [--collapse-threshold N] — Refresh the CODEBASE.md cache immediately\n" + " stats — Show file count, coverage, and generation time\n" + - " rag [status|init|build] — Inspect, build, or configure optional project-rag MCP backend\n" + + " indexer [status] — Inspect selected optional codebase-indexer backend\n" + + " rag [status|init|build] — Inspect selected backend, or build/configure project-rag MCP\n" + " help — Show this help\n\n" + "With no subcommand, shows stats if a map exists or help if not.\n" + "SF also refreshes CODEBASE.md automatically before prompt injection and after completed units when tracked files change.\n\n" + @@ -36,8 +37,9 @@ const USAGE = " exclude_patterns: [\"docs/\", \"fixtures/\"]\n" + " max_files: 1000\n" + " collapse_threshold: 15\n" + - " project_rag: auto # auto | off | required\n" + - " project_rag_auto_index: true"; + " indexer_backend: projectRag # projectRag | sift | none\n" + + " project_rag: auto # auto | off | required\n" + + " project_rag_auto_index: true"; export async function handleCodebase( args: string, @@ -109,11 +111,22 @@ export async function handleCodebase( return; } + case "indexer": { + const action = (parts[1] ?? "status").toLowerCase(); + const prefs = loadEffectiveSFPreferences()?.preferences?.codebase; + if (action === "status") { + ctx.ui.notify(formatCodebaseIndexerStatus(basePath, prefs), "info"); + return; + } + ctx.ui.notify(`Unknown /sf codebase indexer action "${action}". Use status.`, "warning"); + return; + } + case "rag": { const action = (parts[1] ?? "status").toLowerCase(); const prefs = loadEffectiveSFPreferences()?.preferences?.codebase; if (action === "status") { - ctx.ui.notify(formatProjectRagStatus(basePath, prefs), "info"); + ctx.ui.notify(formatCodebaseIndexerStatus(basePath, prefs), "info"); return; } if (action === "init") { diff --git a/src/resources/extensions/sf/docs/preferences-reference.md b/src/resources/extensions/sf/docs/preferences-reference.md index d9b251487..fcd4781e6 100644 --- a/src/resources/extensions/sf/docs/preferences-reference.md +++ b/src/resources/extensions/sf/docs/preferences-reference.md @@ -163,13 +163,15 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `skip_reassess`: boolean — force-disable roadmap reassessment even if `reassess_after_slice` is enabled. Default: `false`. - `skip_slice_research`: boolean — skip per-slice research. Default: `false`. -- `codebase`: configures `.sf/CODEBASE.md` and the optional Project RAG code-intelligence backend. Keys: +- `codebase`: configures `.sf/CODEBASE.md` and the optional code-intelligence backend. Keys: - `exclude_patterns`: string[] — extra file or directory patterns to omit from CODEBASE.md. - `max_files`: number — maximum files to include in CODEBASE.md. Default: `500`. - `collapse_threshold`: number — files-per-directory threshold before collapsing a directory summary. Default: `20`. + - `indexer_backend`: `"projectRag"`, `"sift"`, or `"none"` — codebase-indexer backend used for prompt guidance and `/sf codebase indexer status`. Default: `"projectRag"`. - `project_rag`: `"auto"`, `"off"`, or `"required"` — use Brainwires/project-rag MCP search when configured. Default: `"auto"`. - `project_rag_server`: string — explicit MCP server name when the server cannot be detected from command or args. - `project_rag_auto_index`: boolean — whether agents should prefer indexing before querying a configured Project RAG backend. Default: `true`. + - `/sf codebase indexer status` reports the selected backend status. For `sift`, install `rupurt/sift` on `PATH` or set `SIFT_PATH`. - `/sf codebase rag status` reports whether the Rust backend is actually operational. - `/sf codebase rag init` writes a `.mcp.json` entry when a `project-rag` binary is available. - `/sf codebase rag build` builds vendored Brainwires/project-rag from `vendor/project-rag` (or `SF_PROJECT_RAG_SOURCE`) with `cargo build --release`, then writes the MCP config. The build defaults to `CARGO_BUILD_JOBS=2` so it does not saturate the workstation; override with `SF_PROJECT_RAG_BUILD_JOBS`. diff --git a/src/resources/extensions/sf/preferences-types.ts b/src/resources/extensions/sf/preferences-types.ts index 59d43023e..f8418bb0f 100644 --- a/src/resources/extensions/sf/preferences-types.ts +++ b/src/resources/extensions/sf/preferences-types.ts @@ -306,6 +306,8 @@ export interface ExperimentalPreferences { dispatch_rules?: DispatchExperimentPreferences; } +export type CodebaseIndexerBackendName = "projectRag" | "sift" | "none"; + /** Configuration for the codebase map generator (/sf codebase). */ export interface CodebaseMapPreferences { /** Additional directory/file patterns to exclude (e.g. ["docs/", "fixtures/"]). Merged with built-in defaults. */ @@ -314,6 +316,8 @@ export interface CodebaseMapPreferences { max_files?: number; /** Files-per-directory threshold before collapsing to a summary line. Default: 20. */ collapse_threshold?: number; + /** Optional codebase-indexer backend. Default: "projectRag" for backward compatibility. */ + indexer_backend?: CodebaseIndexerBackendName; /** Optional Brainwires/project-rag MCP backend. Default: "auto" (use if configured, never block if missing). */ project_rag?: "auto" | "off" | "required"; /** MCP server name for project-rag when it cannot be detected from command/args. */ diff --git a/src/resources/extensions/sf/preferences-validation.ts b/src/resources/extensions/sf/preferences-validation.ts index 731752a7b..90731a36e 100644 --- a/src/resources/extensions/sf/preferences-validation.ts +++ b/src/resources/extensions/sf/preferences-validation.ts @@ -1325,6 +1325,13 @@ export function validatePreferences(preferences: SFPreferences): { errors.push("codebase.collapse_threshold must be a positive integer"); } } + if (cb.indexer_backend !== undefined) { + if (cb.indexer_backend === "projectRag" || cb.indexer_backend === "sift" || cb.indexer_backend === "none") { + validCb.indexer_backend = cb.indexer_backend; + } else { + errors.push('codebase.indexer_backend must be one of "projectRag", "sift", or "none"'); + } + } if (cb.project_rag !== undefined) { if (cb.project_rag === "auto" || cb.project_rag === "off" || cb.project_rag === "required") { validCb.project_rag = cb.project_rag; @@ -1351,6 +1358,7 @@ export function validatePreferences(preferences: SFPreferences): { "exclude_patterns", "max_files", "collapse_threshold", + "indexer_backend", "project_rag", "project_rag_server", "project_rag_auto_index", diff --git a/src/resources/extensions/sf/preferences.ts b/src/resources/extensions/sf/preferences.ts index 51dc814f8..cdadaf3a5 100644 --- a/src/resources/extensions/sf/preferences.ts +++ b/src/resources/extensions/sf/preferences.ts @@ -52,6 +52,7 @@ export type { CmuxPreferences, UokTurnActionMode, UokPreferences, + CodebaseIndexerBackendName, CodebaseMapPreferences, SFPreferences, LoadedSFPreferences, diff --git a/src/resources/extensions/sf/tests/code-intelligence.test.ts b/src/resources/extensions/sf/tests/code-intelligence.test.ts index fa4d7259b..c46a8d300 100644 --- a/src/resources/extensions/sf/tests/code-intelligence.test.ts +++ b/src/resources/extensions/sf/tests/code-intelligence.test.ts @@ -7,12 +7,15 @@ import { tmpdir } from "node:os"; import { buildCodeIntelligenceContextBlock, detectProjectRag, + detectSift, ensureProjectRagMcpConfig, findProjectRagSourceDir, formatProjectRagStatus, + formatSiftStatus, PROJECT_RAG_MCP_SERVER_NAME, resolveProjectRagBuildJobs, resolveProjectRagBinaryForProject, + resolveSiftBinary, } from "../code-intelligence.ts"; function makeProject(): string { @@ -25,6 +28,14 @@ function cleanup(projectRoot: string): void { rmSync(projectRoot, { recursive: true, force: true }); } +function writeFakeSiftBinary(projectRoot: string): string { + const binDir = join(projectRoot, "bin"); + const binaryPath = join(binDir, process.platform === "win32" ? "sift.exe" : "sift"); + mkdirSync(binDir, { recursive: true }); + writeFileSync(binaryPath, "", "utf-8"); + return binaryPath; +} + test("detectProjectRag finds a project-rag server even when the MCP name is generic", () => { const projectRoot = makeProject(); try { @@ -192,3 +203,71 @@ test("buildCodeIntelligenceContextBlock injects project-rag usage guidance when cleanup(projectRoot); } }); + +test("resolveSiftBinary honors SIFT_PATH before PATH lookup", () => { + const projectRoot = makeProject(); + try { + const fakeSift = writeFakeSiftBinary(projectRoot); + + assert.equal(resolveSiftBinary({ SIFT_PATH: "/custom/sift", PATH: join(projectRoot, "bin") }), "/custom/sift"); + assert.equal(resolveSiftBinary({ PATH: join(projectRoot, "bin") }), fakeSift); + assert.equal(resolveSiftBinary({ PATH: "" }), null); + } finally { + cleanup(projectRoot); + } +}); + +test("detectSift finds mocked sift binary on PATH and honors disabled backend", () => { + const projectRoot = makeProject(); + try { + const fakeSift = writeFakeSiftBinary(projectRoot); + + const detection = detectSift(projectRoot, { indexer_backend: "sift" }, { PATH: join(projectRoot, "bin") }); + assert.equal(detection.status, "configured"); + assert.equal(detection.binaryPath, fakeSift); + assert.match(detection.reason, /PATH/); + + const disabled = detectSift(projectRoot, { indexer_backend: "none" }, { PATH: join(projectRoot, "bin") }); + assert.equal(disabled.status, "disabled"); + } finally { + cleanup(projectRoot); + } +}); + +test("formatSiftStatus reports configured and missing sift backends without running sift", () => { + const projectRoot = makeProject(); + try { + const fakeSift = writeFakeSiftBinary(projectRoot); + + const configured = formatSiftStatus(projectRoot, { indexer_backend: "sift" }, { PATH: join(projectRoot, "bin") }); + assert.match(configured, /Sift Status/); + assert.match(configured, /Status: configured/); + assert.match(configured, new RegExp(fakeSift.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(configured, /sift search --json/); + + const missing = formatSiftStatus(projectRoot, { indexer_backend: "sift" }, { PATH: "" }); + assert.match(missing, /Status: missing/); + assert.match(missing, /SIFT_PATH/); + } finally { + cleanup(projectRoot); + } +}); + +test("buildCodeIntelligenceContextBlock injects sift guidance when selected", () => { + const projectRoot = makeProject(); + try { + writeFakeSiftBinary(projectRoot); + + const block = buildCodeIntelligenceContextBlock( + projectRoot, + { indexer_backend: "sift" }, + { PATH: join(projectRoot, "bin") }, + ); + assert.match(block, /PROJECT CODE INTELLIGENCE/); + assert.match(block, /Sift: configured/); + assert.match(block, /sift search --json/); + assert.match(block, /page-index-hybrid/); + } finally { + cleanup(projectRoot); + } +}); diff --git a/src/resources/extensions/sf/tests/preferences.test.ts b/src/resources/extensions/sf/tests/preferences.test.ts index eea20a9e4..5bef6194f 100644 --- a/src/resources/extensions/sf/tests/preferences.test.ts +++ b/src/resources/extensions/sf/tests/preferences.test.ts @@ -651,6 +651,7 @@ test("codebase preferences validate and pass through correctly", () => { exclude_patterns: ["docs/", "fixtures/"], max_files: 1000, collapse_threshold: 15, + indexer_backend: "sift", project_rag: "auto", project_rag_server: "project", project_rag_auto_index: false, @@ -660,6 +661,7 @@ test("codebase preferences validate and pass through correctly", () => { assert.deepEqual(result.preferences.codebase?.exclude_patterns, ["docs/", "fixtures/"]); assert.equal(result.preferences.codebase?.max_files, 1000); assert.equal(result.preferences.codebase?.collapse_threshold, 15); + assert.equal(result.preferences.codebase?.indexer_backend, "sift"); assert.equal(result.preferences.codebase?.project_rag, "auto"); assert.equal(result.preferences.codebase?.project_rag_server, "project"); assert.equal(result.preferences.codebase?.project_rag_auto_index, false); @@ -671,6 +673,7 @@ test("codebase preferences reject invalid types", () => { exclude_patterns: "not-an-array" as any, max_files: -5, collapse_threshold: 0, + indexer_backend: "project-rag" as any, project_rag: "yes" as any, project_rag_server: "", project_rag_auto_index: "yes" as any, @@ -679,6 +682,7 @@ test("codebase preferences reject invalid types", () => { assert.ok(result.errors.some(e => e.includes("exclude_patterns must be an array"))); assert.ok(result.errors.some(e => e.includes("max_files must be a positive"))); assert.ok(result.errors.some(e => e.includes("collapse_threshold must be a positive"))); + assert.ok(result.errors.some(e => e.includes("indexer_backend must be one of"))); assert.ok(result.errors.some(e => e.includes("project_rag must be one of"))); assert.ok(result.errors.some(e => e.includes("project_rag_server must be a non-empty string"))); assert.ok(result.errors.some(e => e.includes("project_rag_auto_index must be a boolean"))); @@ -706,6 +710,7 @@ test("codebase preferences parse from markdown frontmatter", () => { ' - ".cache/"', " max_files: 800", " collapse_threshold: 10", + " indexer_backend: projectRag", " project_rag: required", " project_rag_server: project", " project_rag_auto_index: true", @@ -718,6 +723,7 @@ test("codebase preferences parse from markdown frontmatter", () => { assert.deepEqual(result.preferences.codebase?.exclude_patterns, ["docs/", ".cache/"]); assert.equal(result.preferences.codebase?.max_files, 800); assert.equal(result.preferences.codebase?.collapse_threshold, 10); + assert.equal(result.preferences.codebase?.indexer_backend, "projectRag"); assert.equal(result.preferences.codebase?.project_rag, "required"); assert.equal(result.preferences.codebase?.project_rag_server, "project"); assert.equal(result.preferences.codebase?.project_rag_auto_index, true);