Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
27 changes: 8 additions & 19 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,23 @@

## [Unreleased]

## [Unshipped - Phase 09] - High-Signal Search + Decision Card

Cleaned up the edit decision card and sharpened search ranking for exact-name queries.

### Added

- **Definition-first ranking (SEARCH-01)**: For exact-name queries (PascalCase/camelCase), the file that *defines* a symbol now ranks above files that merely use it. Symbol-level dedup ensures multiple methods from the same class don't clog the top slots.
- **Smart snippets with scope headers (SEARCH-02)**: When `includeSnippets: true`, code chunks from symbol-aware analysis include a scope comment header (`// ClassName.methodName`) before the snippet, giving structural context without extra disk reads.
- **Clean decision card (PREF-01-04)**: The preflight response for `intent="edit"|"refactor"|"migrate"` is now a decision card: `ready`, `nextAction` (if not ready), `warnings`, `patterns` (do/avoid capped at 3), `bestExample` (top golden file), `impact` (caller coverage + top files), and `whatWouldHelp`. Internal fields like `evidenceLock`, `riskLevel`, `confidence` are no longer exposed.
- **Impact coverage gating (PREF-02)**: When result files have known callers (from import graph), the card shows caller coverage: "X/Y callers in results". Low coverage (< 40% with > 3 total callers) triggers an epistemic stress alert.
- **whatWouldHelp recommendations (PREF-03)**: When `ready=false`, concrete next steps appear: search more specifically, call `get_team_patterns`, search for uncovered callers, or check memories. Each is actionable in 1-2 sentences.
- **Definition-first ranking**: Exact-name searches now show the file that *defines* a symbol before files that use it. For example, searching `parseConfig` shows the function definition first, then callers.
- **Scope headers in code snippets**: When requesting snippets (`includeSnippets: true`), each code block now starts with a comment like `// UserService.login()` so agents know where the code lives without extra file reads.
- **Edit decision card**: When searching with `intent="edit"`, `intent="refactor"`, or `intent="migrate"`, results now include a decision card telling you whether there's enough evidence to proceed safely. The card shows: whether you're ready (`ready: true/false`), what to do next if not (`nextAction`), relevant team patterns to follow, a top example file, how many callers appear in results (`impact.coverage`), and what searches would help close gaps (`whatWouldHelp`).
- **Caller coverage tracking**: The decision card shows how many of a symbol's callers are in your search results. Low coverage (less than 40% when there are lots of callers) triggers an alert so you know to search more before editing.

### Changed

- **Preflight shape**: `{ ready, reason?, ... }` → `{ ready, nextAction?, warnings?, patterns?, bestExample?, impact?, whatWouldHelp? }`. `reason` renamed to `nextAction` for clarity. No breaking changes to `ready` (stays top-level).
- **Preflight response shape**: Renamed `reason` to `nextAction` for clarity. Removed internal fields (`evidenceLock`, `riskLevel`, `confidence`) so the output is stable and doesn't change shape unexpectedly.

### Fixed

- Agents no longer parse unstable internal fields. Preflight output is stable by design.
- Snippets now include scope context, reducing ambiguity for symbol-heavy edits.

## [Unreleased]
- Null-pointer crash in GenericAnalyzer when chunk content is undefined.
- Tree-sitter symbol extraction now treats node offsets as UTF-8 byte ranges and evicts cached parsers on failures/timeouts.

### Added
### More improvements (Phases 06–08)

- **Index versioning (Phase 06)**: Index artifacts are versioned via `index-meta.json`. Mixed-version indexes are never served; version mismatches or corruption trigger automatic rebuild.
- **Crash-safe rebuilds (Phase 06)**: Full rebuilds write to `.staging/` and swap atomically only on success. Failed rebuilds don't corrupt the active index.
Expand All @@ -39,10 +32,6 @@ Cleaned up the edit decision card and sharpened search ranking for exact-name qu
- Second frozen eval fixture plus an in-repo controlled TypeScript codebase for fully-offline eval runs.
- Regression tests covering Tree-sitter Unicode slicing, parser cleanup/reset behavior, and large/generated file skipping.

### Fixed

- Tree-sitter symbol extraction now treats node offsets as UTF-8 byte ranges and evicts cached parsers on failures/timeouts.

## [1.6.2] - 2026-02-17

Stripped it down for token efficiency, moved CLI code out of the protocol layer, and cleared structural debt.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Here's what codebase-context does:

**Remembers across sessions** - Decisions, failures, workarounds that look wrong but exist for a reason - the battle scars that aren't in the comments. Recorded once, surfaced automatically so the agent doesn't "clean up" something you spent a week getting right. Conventional git commits (`refactor:`, `migrate:`, `fix:`) auto-extract into memory with zero effort. Stale memories decay and get flagged instead of blindly trusted.

**Checks before editing** - A preflight card with risk level, patterns to use and avoid, failure warnings, and a `readyToEdit` evidence check. Catches the "confidently wrong" problem: when code, team memories, and patterns contradict each other, it tells the agent to ask instead of guess. If evidence is thin or contradictory, it says so.
**Checks before editing** - Before editing something, you get a decision card showing whether there's enough evidence to proceed. If a symbol has four callers and only two appear in your search results, the card shows that coverage gap. If coverage is low, `whatWouldHelp` lists the specific searches to run before you touch anything. When code, team memories, and patterns contradict each other, it tells you to look deeper instead of guessing.

One tool call returns all of it. Local-first - your code never leaves your machine.

Expand Down
8 changes: 7 additions & 1 deletion src/analyzers/generic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,10 @@ export class GenericAnalyzer implements FrameworkAnalyzer {
const fileName = path.basename(chunk.filePath);
const { language, componentType, content } = chunk;

if (!content) {
return `${language} ${componentType || 'code'} in ${fileName}`;
}

// Try to extract meaningful information
const firstComment = this.extractFirstComment(content);
if (firstComment) {
Expand Down Expand Up @@ -526,7 +530,9 @@ export class GenericAnalyzer implements FrameworkAnalyzer {
return `${language} code in ${fileName}: ${firstLine ? firstLine.trim().slice(0, 60) + '...' : 'code definition'}`;
}

private extractFirstComment(content: string): string {
private extractFirstComment(content: string | null | undefined): string {
if (!content) return '';

// Try JSDoc style
const jsdocMatch = content.match(/\/\*\*\s*\n?\s*\*\s*(.+?)(?:\n|\*\/)/);
if (jsdocMatch) return jsdocMatch[1].trim();
Expand Down
311 changes: 311 additions & 0 deletions tests/search-decision-card.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import { CodebaseIndexer } from '../src/core/indexer.js';

describe('Search Decision Card (Edit Intent)', () => {
let tempRoot: string | null = null;

beforeEach(async () => {
vi.resetModules();
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'search-decision-card-test-'));
process.env.CODEBASE_ROOT = tempRoot;
process.argv[2] = tempRoot;

// Create mock codebase with patterns and relationships
const srcDir = path.join(tempRoot, 'src');
await fs.mkdir(srcDir, { recursive: true });

// Main service
await fs.writeFile(
path.join(srcDir, 'auth.service.ts'),
`
/**
* Authentication service for token management
*/
export class AuthService {
getToken(): string {
return 'token';
}

refreshToken(): void {
// Refresh token logic
}

validateToken(token: string): boolean {
return token.length > 0;
}
}
`
);

// Dependent file 1
await fs.writeFile(
path.join(srcDir, 'api.interceptor.ts'),
`
import { AuthService } from './auth.service';

export class ApiInterceptor {
constructor(private auth: AuthService) {}

intercept() {
const token = this.auth.getToken();
return token;
}
}
`
);

// Dependent file 2
await fs.writeFile(
path.join(srcDir, 'user.service.ts'),
`
import { AuthService } from './auth.service';

export class UserService {
constructor(private auth: AuthService) {}

getCurrentUser() {
return this.auth.validateToken('token');
}
}
`
);

// Dependent file 3
await fs.writeFile(
path.join(srcDir, 'profile.service.ts'),
`
import { AuthService } from './auth.service';

export class ProfileService {
constructor(private auth: AuthService) {}

loadProfile() {
if (this.auth.validateToken('token')) {
return { name: 'User' };
}
}
}
`
);

// Index the project
const indexer = new CodebaseIndexer({
rootPath: tempRoot,
config: { skipEmbedding: true }
});
await indexer.index();
});

afterEach(async () => {
if (tempRoot) {
await fs.rm(tempRoot, { recursive: true, force: true });
tempRoot = null;
}
delete process.env.CODEBASE_ROOT;
});

it('intent="edit" with multiple results returns full decision card with ready field', async () => {
if (!tempRoot) throw new Error('tempRoot not initialized');

const { server } = await import('../src/index.js');
const handler = (server as any)._requestHandlers.get('tools/call');

const response = await handler({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'search_codebase',
arguments: {
query: 'getToken',
intent: 'edit'
}
}
});

expect(response.content).toBeDefined();
expect(response.content.length).toBeGreaterThan(0);
const content = response.content[0];
expect(content.type).toBe('text');

const parsed = JSON.parse(content.text);
expect(parsed.results).toBeDefined();
expect(parsed.results.length).toBeGreaterThan(0);

const preflight = parsed.preflight;
expect(preflight).toBeDefined();
expect(preflight.ready).toBeDefined();
expect(typeof preflight.ready).toBe('boolean');
});

it('decision card has all expected fields when returned', async () => {
if (!tempRoot) throw new Error('tempRoot not initialized');

const { server } = await import('../src/index.js');
const handler = (server as any)._requestHandlers.get('tools/call');

const response = await handler({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'search_codebase',
arguments: {
query: 'AuthService',
intent: 'edit'
}
}
});

const content = response.content[0];
const parsed = JSON.parse(content.text);
const preflight = parsed.preflight;

// preflight should have ready as minimum
expect(preflight.ready).toBeDefined();
expect(typeof preflight.ready).toBe('boolean');

// Optional fields can be present
if (preflight.nextAction) {
expect(typeof preflight.nextAction).toBe('string');
}
if (preflight.patterns) {
expect(typeof preflight.patterns).toBe('object');
}
if (preflight.warnings) {
expect(Array.isArray(preflight.warnings)).toBe(true);
}
if (preflight.bestExample) {
expect(typeof preflight.bestExample).toBe('string');
}
if (preflight.impact) {
expect(typeof preflight.impact).toBe('object');
}
if (preflight.whatWouldHelp) {
expect(Array.isArray(preflight.whatWouldHelp)).toBe(true);
}
});

it('intent="explore" returns lightweight preflight', async () => {
if (!tempRoot) throw new Error('tempRoot not initialized');

const { server } = await import('../src/index.js');
const handler = (server as any)._requestHandlers.get('tools/call');

const response = await handler({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'search_codebase',
arguments: {
query: 'AuthService',
intent: 'explore'
}
}
});

const content = response.content[0];
const parsed = JSON.parse(content.text);
const preflight = parsed.preflight;

// For explore intent, preflight should be lite: { ready, reason? }
if (preflight) {
expect(preflight.ready).toBeDefined();
expect(typeof preflight.ready).toBe('boolean');
// Should NOT have full decision card fields for explore
}
});

it('includes snippet field when includeSnippets=true', async () => {
if (!tempRoot) throw new Error('tempRoot not initialized');

const { server } = await import('../src/index.js');
const handler = (server as any)._requestHandlers.get('tools/call');

const response = await handler({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'search_codebase',
arguments: {
query: 'getToken',
includeSnippets: true
}
}
});

const content = response.content[0];
const parsed = JSON.parse(content.text);

expect(parsed.results).toBeDefined();
expect(parsed.results.length).toBeGreaterThan(0);

// At least some results should have a snippet
const withSnippets = parsed.results.filter((r: any) => r.snippet);
expect(withSnippets.length).toBeGreaterThan(0);
});

it('does not include snippet field when includeSnippets=false', async () => {
if (!tempRoot) throw new Error('tempRoot not initialized');

const { server } = await import('../src/index.js');
const handler = (server as any)._requestHandlers.get('tools/call');

const response = await handler({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'search_codebase',
arguments: {
query: 'getToken',
includeSnippets: false
}
}
});

const content = response.content[0];
const parsed = JSON.parse(content.text);

expect(parsed.results).toBeDefined();
// All results should not have snippet field
parsed.results.forEach((r: any) => {
expect(r.snippet).toBeUndefined();
});
});

it('scope header starts snippet when includeSnippets=true', async () => {
if (!tempRoot) throw new Error('tempRoot not initialized');

const { server } = await import('../src/index.js');
const handler = (server as any)._requestHandlers.get('tools/call');

const response = await handler({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'search_codebase',
arguments: {
query: 'getToken',
includeSnippets: true
}
}
});

const content = response.content[0];
const parsed = JSON.parse(content.text);

const withSnippet = parsed.results.find((r: any) => r.snippet);
if (withSnippet && withSnippet.snippet) {
// Scope header should be a comment line
const firstLine = withSnippet.snippet.split('\n')[0].trim();
expect(firstLine).toMatch(/^\/\//);
}
});
});
Loading
Loading