feat(code-intelligence): add sift indexer backend alongside project-rag

Generalize the code-intelligence hook to support multiple indexer
backends, with sift (rupurt/sift) as a new option next to the existing
project-rag MCP server. Backend is selected via CodebaseMapPreferences.

- code-intelligence.ts: new abstraction + sift backend (detect, resolve,
  status, context-block contribution)
- preferences-types.ts: codebaseIndexer field (project-rag | sift | none)
- preferences-validation.ts: validate the new field
- bootstrap/system-context.ts, commands-codebase.ts: dispatch on backend
- tests/code-intelligence.test.ts: sift detection/resolution/status tests
  (19 pass, 0 fail)

project-rag path unchanged and continues to work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-28 05:05:26 +02:00
parent 0606983d97
commit 8e827147c9
9 changed files with 381 additions and 29 deletions

View file

@ -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}`);

View file

@ -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<TDetection extends CodebaseIndexerDetection = CodebaseIndexerDetection> {
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 <path> \"<query>\"`. " +
"Use `--strategy page-index-hybrid` for the strongest direct-search preset, " +
"`--strategy path-hybrid` for path-heavy queries, and `sift search <path> --agent \"<task>\"` 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 <path> \"<query>\"`; `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<ProjectRagDetection> = {
name: "projectRag",
label: "Project RAG",
detect: detectProjectRag,
formatStatus: formatProjectRagStatus,
buildContextLines: buildProjectRagContextLines,
};
export const SIFT_CODEBASE_INDEXER_BACKEND: CodebaseIndexerBackend<SiftDetection> = {
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<CodebaseIndexerBackendName, CodebaseIndexerBackend> = {
projectRag: PROJECT_RAG_CODEBASE_INDEXER_BACKEND,
sift: SIFT_CODEBASE_INDEXER_BACKEND,
none: NO_CODEBASE_INDEXER_BACKEND,
};

View file

@ -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") {

View file

@ -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`.

View file

@ -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. */

View file

@ -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",

View file

@ -52,6 +52,7 @@ export type {
CmuxPreferences,
UokTurnActionMode,
UokPreferences,
CodebaseIndexerBackendName,
CodebaseMapPreferences,
SFPreferences,
LoadedSFPreferences,

View file

@ -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);
}
});

View file

@ -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);