Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,5 +318,5 @@ Reproducible evaluation is shipped as a CLI entrypoint backed by shared scoring
- **Symbol refs are not a call-graph.** `get_symbol_references` counts identifier-node occurrences in the AST (comments/strings excluded via Tree-sitter). It does not distinguish call sites from type annotations, variable assignments, or imports. Full call-site-specific analysis (`call_expression` nodes only) is a roadmap item.
- **Impact is 2-hop max.** `computeImpactCandidates` walks direct importers then their importers. Full BFS reachability is on the roadmap.
- **Planned for unpublished 2.4.0:** NestJS analyzer support, React and Next.js detection improvements, and reranker fixes. Dedicated Angular, React, and Next.js analyzers already ship in published 2.2.0. Other languages use the Generic analyzer for indexing and retrieval; available symbol extraction depends on parser support. File import resolution and multi-hop navigation currently support TypeScript and JavaScript. Python and Go import navigation are not supported.
- **The unreleased candidate defaults new indexes to model-free mode. Published 2.2.0 enables embeddings by default.** When semantic mode is enabled, its default model is `bge-small-en-v1.5` (512-token context). Granite (8192 context) is opt-in via `EMBEDDING_MODEL`. OpenAI is opt-in via `EMBEDDING_PROVIDER=openai` — sends code externally.
- **The unreleased candidate defaults new indexes to model-free mode. Published 2.2.0 enables embeddings by default.** When semantic mode is enabled, its default model is `bge-small-en-v1.5` (512-token context). Granite (8192 context) is opt-in via `EMBEDDING_MODEL`. OpenAI is opt-in via `EMBEDDING_PROVIDER=openai` with `OPENAI_API_KEY`; use `OPENAI_BASE_URL` for OpenAI-compatible endpoints (Ollama, LiteLLM, proxies) instead of `https://api.openai.com/v1`.
- **Patterns are file-level frequency counts.** Not semantic clustering. Rising/Declining trend is derived from git commit recency for files using each pattern, not from usage semantics.
7 changes: 6 additions & 1 deletion src/embeddings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ export async function getEmbeddingProvider(
): Promise<EmbeddingProvider> {
assertEmbeddingRuntimeAvailable(config);
const mergedConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
const providerKey = `${mergedConfig.provider}:${mergedConfig.model}`;
const providerKey = [
mergedConfig.provider,
mergedConfig.model,
mergedConfig.apiEndpoint ?? '',
mergedConfig.apiKey ?? ''
].join(':');

if (cachedProvider && cachedProviderType === providerKey) {
return cachedProvider;
Expand Down
13 changes: 12 additions & 1 deletion src/embeddings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,21 @@ export function parseEmbeddingProviderName(
// better conceptual search at the cost of 5-10x slower indexing and higher RAM usage
export const DEFAULT_MODEL = process.env.EMBEDDING_MODEL || 'Xenova/bge-small-en-v1.5';

const DEFAULT_OPENAI_API_ENDPOINT = 'https://api.openai.com/v1';

function resolveOpenAiApiEndpoint(): string {
const fromEnv = process.env.OPENAI_BASE_URL?.trim();
if (!fromEnv) {
return DEFAULT_OPENAI_API_ENDPOINT;
}
return fromEnv.replace(/\/+$/, '');
}

export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = {
provider: parseEmbeddingProviderName(process.env.EMBEDDING_PROVIDER) ?? 'transformers',
model: DEFAULT_MODEL,
batchSize: 32,
maxRetries: 3,
apiKey: process.env.OPENAI_API_KEY
apiKey: process.env.OPENAI_API_KEY,
apiEndpoint: resolveOpenAiApiEndpoint()
};
30 changes: 30 additions & 0 deletions tests/cli-entrypoint-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,36 @@ describe('CLI entrypoint runtime', () => {
await rm(projectRoot, { recursive: true, force: true });
}
}, 70_000);
it('dispatches reindex when invoked through an npm-style symlink entrypoint', async () => {
const projectRoot = await mkdtemp(join(tmpdir(), 'cbc-cli-reindex-entrypoint-'));
const tempDirectory = join(tmpdir(), `cbc-cli-bin-${Date.now()}`);
const linkedEntrypoint = join(tempDirectory, 'codebase-context');

await writeFile(join(projectRoot, 'example.ts'), 'export const value = 1;\n');
await mkdir(tempDirectory, { recursive: true });
symlinkSync(entrypoint, linkedEntrypoint, 'file');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Windows tests fail

If a Windows contributor lacks permission to create symlinks, symlinkSync throws before this test reaches its cleanup block. The suite fails and leaves both temporary directories behind. Use the same Windows skip guard as the symlink test immediately below, and clean up both directories when setup fails.


try {
const result = spawnSync(
process.execPath,
['--import', import.meta.resolve('tsx'), linkedEntrypoint, 'reindex', '--no-embeddings'],
{
cwd: projectRoot,
env: { ...process.env },
encoding: 'utf8',
timeout: 120_000
}
);

expect(result.status, result.stderr).toBe(0);
expect(result.stderr).toContain('Complete:');
expect(result.stderr).not.toContain('@modelcontextprotocol/sdk/server/stdio.js');
} finally {
rmSync(tempDirectory, { recursive: true, force: true });
await rm(projectRoot, { recursive: true, force: true });
}
}, 120_000);

it('dispatches CLI help when invoked through a symlink entrypoint', (context) => {
const tempDirectory = mkdtempSync(join(tmpdir(), 'cbc-cli-entrypoint-'));
const linkedEntrypoint = join(tempDirectory, 'codebase-context.ts');
Expand Down
20 changes: 20 additions & 0 deletions tests/embedding-config-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

describe('DEFAULT_EMBEDDING_CONFIG environment', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});

it('uses OPENAI_BASE_URL when set', async () => {
vi.stubEnv('OPENAI_BASE_URL', 'http://127.0.0.1:11434/v1/');
const { DEFAULT_EMBEDDING_CONFIG } = await import('../src/embeddings/types.js');
expect(DEFAULT_EMBEDDING_CONFIG.apiEndpoint).toBe('http://127.0.0.1:11434/v1');
});

it('defaults to the official OpenAI endpoint when OPENAI_BASE_URL is unset', async () => {
vi.unstubAllEnvs();
const { DEFAULT_EMBEDDING_CONFIG } = await import('../src/embeddings/types.js');
Comment on lines +16 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Watch mode falsely fails

vi.unstubAllEnvs() restores the original environment; it does not unset OPENAI_BASE_URL. If a contributor has that variable set, test:watch gets a false failure because it bypasses the normal test wrapper’s environment filter. Explicitly unset the variable with vi.stubEnv('OPENAI_BASE_URL', undefined) before importing the defaults.

Suggested change
vi.unstubAllEnvs();
const { DEFAULT_EMBEDDING_CONFIG } = await import('../src/embeddings/types.js');
vi.stubEnv('OPENAI_BASE_URL', undefined);
const { DEFAULT_EMBEDDING_CONFIG } = await import('../src/embeddings/types.js');

expect(DEFAULT_EMBEDDING_CONFIG.apiEndpoint).toBe('https://api.openai.com/v1');
});
});
39 changes: 39 additions & 0 deletions tests/embedding-provider-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

describe('getEmbeddingProvider cache key', () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock('../src/embeddings/openai.js');
});

it('rebuilds the OpenAI provider when apiEndpoint changes', async () => {
const endpoints: string[] = [];
vi.doMock('../src/embeddings/openai.js', () => ({
OpenAIEmbeddingProvider: class {
constructor(_model: string, _apiKey?: string, apiEndpoint?: string) {
endpoints.push(apiEndpoint ?? '');
}

async initialize(): Promise<void> {
return undefined;
}
}
}));

const { getEmbeddingProvider } = await import('../src/embeddings/index.js');
await getEmbeddingProvider({
provider: 'openai',
model: 'text-embedding-3-small',
apiKey: 'sk-test',
apiEndpoint: 'http://127.0.0.1:11434/v1'
});
await getEmbeddingProvider({
provider: 'openai',
model: 'text-embedding-3-small',
apiKey: 'sk-test',
apiEndpoint: 'http://127.0.0.1:8080/v1'
});

expect(endpoints).toEqual(['http://127.0.0.1:11434/v1', 'http://127.0.0.1:8080/v1']);
});
});
19 changes: 19 additions & 0 deletions tests/openai-embeddings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ describe('OpenAIEmbeddingProvider', () => {
expect(result).toEqual([]);
});

it('embedBatch() posts to a custom apiEndpoint base URL', async () => {
const mockFetch = vi.fn().mockReturnValue(
makeFetchResponse({
data: [{ embedding: [0.1] }]
})
);
vi.stubGlobal('fetch', mockFetch);

const provider = new OpenAIEmbeddingProvider(
'text-embedding-3-small',
'sk-test',
'http://127.0.0.1:11434/v1'
);
await provider.embedBatch(['hello']);

const [url] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe('http://127.0.0.1:11434/v1/embeddings');
});

it('embedBatch() sends correct Authorization header, model, and encoding_format', async () => {
const mockFetch = vi.fn().mockReturnValue(
makeFetchResponse({
Expand Down
Loading