Skip to content
Draft
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
179 changes: 179 additions & 0 deletions __tests__/source-strings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Source string index tests
*
* Exact code-like string literals should be queryable even when the string is
* not a symbol name in the caller repo.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';

describe('source string index', () => {
let testDir: string;
let cg: CodeGraph;

beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-source-strings-'));
const srcDir = path.join(testDir, 'src');
fs.mkdirSync(srcDir);

fs.writeFileSync(
path.join(srcDir, 'client.ts'),
`export async function sendPayload(payload: unknown): Promise<Response> {
return fetch('/live-scoring/append-event', {
method: 'POST',
body: JSON.stringify(payload),
});
}

export async function saveRecord(createItem: (collection: string) => Promise<unknown>): Promise<unknown> {
return createItem('game_matches');
}

export function genericText(): string {
return 'plain sentence that should not be indexed';
}

export function dynamicRoute(id: string): string {
return \`/live-scoring/\${id}\`;
}
`
);

fs.writeFileSync(
path.join(srcDir, 'bridge.ts'),
`export function postBridgeEvent(postMessage: (event: string) => void): void {
postMessage('unity.score.updated');
}
`
);

cg = CodeGraph.initSync(testDir, {
config: {
include: ['**/*.ts'],
exclude: [],
},
});
await cg.indexAll();
});

afterEach(() => {
cg?.destroy();
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});

it('indexes exact code-like string literals with file line and enclosing symbol', () => {
const routeHits = cg.searchSourceStrings('/live-scoring/append-event');

expect(routeHits).toHaveLength(1);
expect(routeHits[0]).toMatchObject({
literal: '/live-scoring/append-event',
filePath: 'src/client.ts',
line: 2,
nodeName: 'sendPayload',
nodeKind: 'function',
});

const collectionHits = cg.searchSourceStrings('game_matches');
expect(collectionHits).toHaveLength(1);
expect(collectionHits[0]).toMatchObject({
literal: 'game_matches',
filePath: 'src/client.ts',
line: 9,
nodeName: 'saveRecord',
nodeKind: 'function',
});
});

it('does not index plain prose strings', () => {
expect(cg.searchSourceStrings('plain sentence that should not be indexed')).toHaveLength(0);
});

it('does not index dynamic template strings as exact literals', () => {
expect(cg.searchSourceStrings('/live-scoring/${id}')).toHaveLength(0);
});

it('supports FTS term lookup without weakening exact literal lookup', () => {
const ftsHits = cg.searchSourceStrings('live scoring append');
expect(ftsHits[0]).toMatchObject({
literal: '/live-scoring/append-event',
nodeName: 'sendPayload',
});

expect(cg.searchSourceStrings('/live-scoring/append')).toHaveLength(0);
});

it('uses source-string hits as search and context entry points', async () => {
const searchHits = cg.searchNodes('/live-scoring/append-event', { limit: 5 });
expect(searchHits[0]?.node.name).toBe('sendPayload');
expect(searchHits[0]?.sourceString).toMatchObject({
literal: '/live-scoring/append-event',
line: 2,
});

const context = await cg.findRelevantContext('game_matches', {
searchLimit: 3,
traversalDepth: 0,
});
const rootNames = context.roots.map((id) => context.nodes.get(id)?.name);
expect(rootNames).toContain('saveRecord');
});

it('surfaces exact source-string sites through the MCP search and explore paths', async () => {
const handler = new ToolHandler(cg);

const search = await handler.execute('codegraph_search', {
query: '/live-scoring/append-event',
limit: 5,
});
expect(search.content[0]?.text).toContain('sendPayload');
expect(search.content[0]?.text).toContain('source string `/live-scoring/append-event` at src/client.ts:2');

const explore = await handler.execute('codegraph_explore', {
query: 'unity.score.updated',
maxFiles: 3,
});
expect(explore.content[0]?.text).toContain('postBridgeEvent');
expect(explore.content[0]?.text).toContain("postMessage('unity.score.updated')");
});

it('replaces source-string rows when files change during sync', async () => {
const clientPath = path.join(testDir, 'src', 'client.ts');
fs.writeFileSync(
clientPath,
`export async function sendPayload(payload: unknown): Promise<Response> {
return fetch('/live-scoring/v2/append-event', {
method: 'POST',
body: JSON.stringify(payload),
});
}
`
);

await cg.sync();

expect(cg.searchSourceStrings('/live-scoring/append-event')).toHaveLength(0);
const replacementHits = cg.searchSourceStrings('/live-scoring/v2/append-event');
expect(replacementHits).toHaveLength(1);
expect(replacementHits[0]).toMatchObject({
filePath: 'src/client.ts',
line: 2,
nodeName: 'sendPayload',
});
});

it('clears source-string rows with the graph data', () => {
expect(cg.searchSourceStrings('/live-scoring/append-event')).toHaveLength(1);

cg.clear();

expect(cg.searchSourceStrings('/live-scoring/append-event')).toHaveLength(0);
expect(cg.searchNodes('/live-scoring/append-event')).toHaveLength(0);
});
});
9 changes: 7 additions & 2 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,7 +909,7 @@ program
*/
program
.command('query <search>')
.description('Search for symbols in the codebase')
.description('Search for symbols or code-like source strings in the codebase')
.option('-p, --path <path>', 'Project path')
.option('-l, --limit <number>', 'Maximum results', '10')
.option('-k, --kind <kind>', 'Filter by node kind (function, class, etc.)')
Expand Down Expand Up @@ -952,7 +952,9 @@ program

for (const result of results) {
const node = result.node;
const location = `${node.filePath}:${node.startLine}`;
const location = result.sourceString
? `${result.sourceString.filePath}:${result.sourceString.line}`
: `${node.filePath}:${node.startLine}`;
const score = chalk.dim(`(${(result.score * 100).toFixed(0)}%)`);

console.log(
Expand All @@ -961,6 +963,9 @@ program
' ' + score
);
console.log(chalk.dim(` ${location}`));
if (result.sourceString) {
console.log(chalk.dim(` source string: ${result.sourceString.literal}`));
}
if (node.signature) {
console.log(chalk.dim(` ${node.signature}`));
}
Expand Down
25 changes: 25 additions & 0 deletions src/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,19 @@ export class ContextBuilder {
logDebug('Text search failed', { query, error: String(error) });
}

let sourceStringResults: SearchResult[] = [];
try {
sourceStringResults = /[-_./:@]/.test(query)
? this.queries.searchSourceStringNodes(query, {
limit: opts.searchLimit * 2,
kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined,
})
: [];
logDebug('Source string search results', { count: sourceStringResults.length });
} catch (error) {
logDebug('Source string search failed', { query, error: String(error) });
}

// Step 4: Merge results, taking the max score when duplicates appear
// across search channels. Exact matches may have lower scores than FTS
// results for the same node — use the best score from any channel.
Expand All @@ -607,6 +620,18 @@ export class ContextBuilder {
const existing = resultById.get(result.node.id);
if (existing) {
existing.score = Math.max(existing.score, result.score);
existing.sourceString = existing.sourceString ?? result.sourceString;
} else {
resultById.set(result.node.id, result);
searchResults.push(result);
}
}

for (const result of sourceStringResults) {
const existing = resultById.get(result.node.id);
if (existing) {
existing.score = Math.max(existing.score, result.score);
existing.sourceString = existing.sourceString ?? result.sourceString;
} else {
resultById.set(result.node.id, result);
searchResults.push(result);
Expand Down
49 changes: 48 additions & 1 deletion src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter';
/**
* Current schema version
*/
export const CURRENT_SCHEMA_VERSION = 5;
export const CURRENT_SCHEMA_VERSION = 6;

/**
* Migration definition
Expand Down Expand Up @@ -75,6 +75,53 @@ const migrations: Migration[] = [
`);
},
},
{
version: 6,
description: 'Add source_strings side-table and FTS index for code-like string literals',
up: (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS source_strings (
id TEXT PRIMARY KEY,
literal TEXT NOT NULL,
file_path TEXT NOT NULL,
line INTEGER NOT NULL,
col INTEGER NOT NULL,
language TEXT NOT NULL,
node_id TEXT,
node_name TEXT,
node_kind TEXT
);
CREATE INDEX IF NOT EXISTS idx_source_strings_literal ON source_strings(literal);
CREATE INDEX IF NOT EXISTS idx_source_strings_file_path ON source_strings(file_path);
CREATE INDEX IF NOT EXISTS idx_source_strings_node_id ON source_strings(node_id);

CREATE VIRTUAL TABLE IF NOT EXISTS source_strings_fts USING fts5(
literal,
file_path UNINDEXED,
node_name UNINDEXED,
content='source_strings',
content_rowid='rowid'
);

CREATE TRIGGER IF NOT EXISTS source_strings_ai AFTER INSERT ON source_strings BEGIN
INSERT INTO source_strings_fts(rowid, literal, file_path, node_name)
VALUES (NEW.rowid, NEW.literal, NEW.file_path, NEW.node_name);
END;

CREATE TRIGGER IF NOT EXISTS source_strings_ad AFTER DELETE ON source_strings BEGIN
INSERT INTO source_strings_fts(source_strings_fts, rowid, literal, file_path, node_name)
VALUES ('delete', OLD.rowid, OLD.literal, OLD.file_path, OLD.node_name);
END;

CREATE TRIGGER IF NOT EXISTS source_strings_au AFTER UPDATE ON source_strings BEGIN
INSERT INTO source_strings_fts(source_strings_fts, rowid, literal, file_path, node_name)
VALUES ('delete', OLD.rowid, OLD.literal, OLD.file_path, OLD.node_name);
INSERT INTO source_strings_fts(rowid, literal, file_path, node_name)
VALUES (NEW.rowid, NEW.literal, NEW.file_path, NEW.node_name);
END;
`);
},
},
];

/**
Expand Down
Loading