Skip to content
Merged
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- `codegraph_explore` now finds quoted prose in script strings and template text through a capped source scan, ignoring case and punctuation without requiring a re-index.

- After a complete scan of indexed source without skipped files, `codegraph_explore` lists query names it cannot find, so an agent can catch a guessed name.

- RedwoodSDK registered routes now link to their page or API handlers, preserving prefixes and method tables while excluding middleware from page roots.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Compared with upstream `main` at `6560052` (v1.6.2). Each item here and in the d
| Markdown indexing | — | ✓ | Headings, sections, tables and links become graph nodes; a documentation question gets the matching section. |
| Missing names reported | — | ✓ | After a complete scan of indexed source without skipped files, `codegraph_explore` lists requested names it cannot find, helping an agent catch a guessed name. |
| Local callee source with file pins | — | ✓ | When a query names functions in pinned files, `codegraph_explore` prioritizes local callee bodies through two call or callback hops, within the file and output budgets. |
| Quoted prose source | — | ✓ | Quoted spans of three or more words find script strings and template text, ignoring case and punctuation, through a capped source scan. No re-index needed. |
| Requested test source | — | ✓ | Pinned JavaScript and TypeScript test files return matching `it`/`test` callbacks. Questions about tests include source from the nearest test callers of named symbols, within the file and output budgets. |
| Near-duplicate functions | — | ✓ | `codegraph_explore` and `codegraph_node` name the near-identical copies of a function, so a fix made in one copy is not forgotten in the others. |
| External HTTP endpoints | — | ✓ | JavaScript and TypeScript calls through `fetch`, axios, ky, got and similar clients appear as endpoint nodes such as `GET https://api.github.com/…`. |
Expand Down
105 changes: 105 additions & 0 deletions __tests__/explore-quoted-prose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { afterAll, beforeAll, describe, expect, it, vi } 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';

let dir: string;
let cg: CodeGraph;

beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-quoted-prose-'));
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"quoted-prose-fixture"}');
fs.writeFileSync(path.join(dir, 'notice.ts'), [
...Array.from({ length: 180 }, (_, i) => `export function helper${i}() { return ${i}; }`),
'export function buildNotice(ready: boolean) {',
' if (!ready) return "Shipping updates are temporarily unavailable.";',
' return "notice-body-end";',
'}',
].join('\n'));
fs.writeFileSync(path.join(dir, 'panel.vue'), [
'<template>',
...Array.from({ length: 310 }, () => ' <span>padding</span>'),
' <p>Changes cannot be saved until verification finishes.</p>',
'</template>',
'<script setup lang="ts">',
'const unrelatedFlag = true;',
'</script>',
].join('\n'));
fs.writeFileSync(path.join(dir, 'decoy.ts'),
'export function buildDecoy() { return "Preshipping updates are temporarily unavailable"; }');
fs.writeFileSync(path.join(dir, 'protocol.ts'),
'export function chooseProtocol() { return "Use protocol/2 for shipping updates"; }');
fs.writeFileSync(path.join(dir, 'protocol-decoy.ts'),
'export function chooseDecoyProtocol() { return "Use protocol for shipping updates"; }');
fs.writeFileSync(path.join(dir, 'aaa.properties'),
Array.from({ length: 16 }, (_, i) => `message${i}=Account changes require a confirmed email address.`).join('\n'));
fs.writeFileSync(path.join(dir, 'billing.twig'),
'<p>Account changes require a confirmed email address.</p>');
fs.writeFileSync(path.join(dir, 'restricted.properties'),
'message=Restricted account message must stay private.\n');
cg = CodeGraph.initSync(dir);
await cg.indexAll();
}, 60_000);

afterAll(() => {
cg?.destroy();
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});

async function explore(query: string, maxFiles?: number) {
const result = await new ToolHandler(cg).execute('codegraph_explore', { query, maxFiles });
expect(result.isError).not.toBe(true);
return result.content.map(c => 'text' in c ? c.text : '').join('\n');
}

describe('quoted prose source', () => {
it('returns the complete enclosing symbol despite case and punctuation differences', async () => {
const output = await explore('Where is "SHIPPING updates, are temporarily unavailable"?', 1);
expect(output).toContain('notice.ts');
expect(output).toContain('if (!ready) return "Shipping updates are temporarily unavailable.";');
expect(output).toContain('return "notice-body-end";');
expect(output).not.toContain('buildDecoy');
expect(output.length).toBeLessThanOrEqual(25_000);
});

it('returns unquoted template prose far from indexed declarations', async () => {
const output = await explore('Where is `Changes cannot be saved until verification finishes`?', 1);
expect(output).toContain('panel.vue');
expect(output).toContain('<p>Changes cannot be saved until verification finishes.</p>');
expect(output.length).toBeLessThanOrEqual(25_000);
});

it('admits indexed template source that has no graph nodes', async () => {
expect(cg.getFile('billing.twig')?.nodeCount).toBe(0);
expect(cg.getFile('aaa.properties')?.language).toBe('properties');
const output = await explore('Where is "Account changes require a confirmed email address"?', 1);
expect(output).toContain('billing.twig');
expect(output).toContain('<p>Account changes require a confirmed email address.</p>');
expect(output.length).toBeLessThanOrEqual(25_000);
});

it('preserves number words in copied prose before symbol normalization', async () => {
const output = await explore('Where is "Use protocol/2 for shipping updates"?', 1);
expect(output).toContain('protocol.ts');
expect(output).toContain('return "Use protocol/2 for shipping updates";');
expect(output).not.toContain('return "Use protocol for shipping updates";');
});

it('preserves config-value source redaction for prose matches', async () => {
expect(cg.getFile('restricted.properties')?.language).toBe('properties');
const output = await explore('Where is "Restricted account message must stay private"?');
expect(output).not.toContain('message=Restricted account message must stay private.');
});

it('does not scan source for an ordinary symbol query or a two-word quote', async () => {
const scanFiles = vi.spyOn(cg, 'getTextScanFiles');
try {
await explore('buildNotice');
await explore('buildNotice "shipping updates"');
await explore("buildNotice \"don't stop\"");
expect(scanFiles).not.toHaveBeenCalled();
} finally { scanFiles.mockRestore(); }
});
});
21 changes: 20 additions & 1 deletion __tests__/source-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execFileSync } from 'child_process';
import { scanIndexedSource } from '../src/mcp/source-scan';
import { quotedProsePattern, scanIndexedSource, scanQuotedProse } from '../src/mcp/source-scan';

let dir: string;
const LIMITS = { budgetMs: 10_000, maxFileBytes: 1024, maxTotalBytes: 4096 };
Expand Down Expand Up @@ -71,3 +71,22 @@ describe('scanIndexedSource', () => {
expect(words(['a.ts', 'gone.ts', '../outside.ts'])).toEqual({ complete: true, skipped: 2, seen: ['alpha'] });
});
});

describe('quoted prose scan limits', () => {
it('stops after sixteen matches and retains their source line coordinates', () => {
const text = Array.from({ length: 20 }, () => '<p>Proceed with\r\naccount verification.</p>').join('\r\n');
fs.writeFileSync(path.join(dir, 'messages.ts'), text);
const matches = scanQuotedProse(dir, [{ path: 'messages.ts', size: Buffer.byteLength(text) }],
quotedProsePattern('"Proceed with account verification"')!);
expect(matches).toHaveLength(16);
expect(matches[0]).toEqual({ file: 'messages.ts', start: 1, end: 2 });
expect(matches[15]).toEqual({ file: 'messages.ts', start: 31, end: 32 });
});

it('skips prose files over one MiB even when their indexed size is stale', () => {
const text = 'Proceed with account verification.\n' + 'x'.repeat(1024 * 1024);
fs.writeFileSync(path.join(dir, 'oversize.ts'), text);
expect(scanQuotedProse(dir, [{ path: 'oversize.ts', size: 1 }],
quotedProsePattern('"Proceed with account verification"')!)).toEqual([]);
});
});
4 changes: 4 additions & 0 deletions site/src/content/docs/reference/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ Exposing one strong code tool is deliberate. Measured agent behavior showed that

When a query names functions in pinned files, `codegraph_explore` prioritizes their local callee bodies through two call or callback hops. It selects at most sixteen local helpers per file, each at most 200 lines. File and output budgets still apply.

Quoted spans of three or more words also find current indexed code by word sequence, ignoring case and punctuation. Matches return an enclosing symbol or source around the matching lines, including script strings and unquoted template text. No re-index is needed.

The scan checks its 300 ms budget between files and stops at sixteen matches or 64 MiB read. Files over 1 MiB are skipped. Up to four spans of at most 300 characters each are considered. File and output budgets still apply. Edits since the last sync can leave indexed symbol extents behind the matching source.

When you ask about tests, `codegraph_explore` includes source from the nearest test callers within three caller hops. These links describe static callers, not measured runtime coverage. Pinned JavaScript and TypeScript test files prioritize matching `it`/`test` callbacks. File and output budgets still apply.

## The other tools
Expand Down
4 changes: 2 additions & 2 deletions src/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2294,9 +2294,9 @@ export class CodeGraph {

/**
* The files whose text a source scan reads: neither generated nor markdown,
* with the size recorded at index time.
* with the size and language recorded at index time.
*/
getTextScanFiles(): Array<{ path: string; size: number }> {
getTextScanFiles(): Array<{ path: string; size: number; language: string }> {
return this.queries.getTextScanFiles();
}

Expand Down
6 changes: 3 additions & 3 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,13 +595,13 @@ export class QueryBuilder {
}

/** Hand-written, non-markdown files with their indexed sizes, sorted by path. */
getTextScanFiles(): Array<{ path: string; size: number }> {
getTextScanFiles(): Array<{ path: string; size: number; language: string }> {
if (!this.stmts.getTextScanFiles) {
this.stmts.getTextScanFiles = this.db.prepare(
"SELECT path, size FROM files WHERE generated = 0 AND language <> 'markdown' ORDER BY path",
"SELECT path, size, language FROM files WHERE generated = 0 AND language <> 'markdown' ORDER BY path",
);
}
return this.stmts.getTextScanFiles.all() as Array<{ path: string; size: number }>;
return this.stmts.getTextScanFiles.all() as Array<{ path: string; size: number; language: string }>;
}

/** Whether any file binds `name` (an import, parameter, local or declaration). */
Expand Down
1 change: 1 addition & 0 deletions src/mcp/server-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ against dozens of greps and reads.
## Reading results

- When a query names functions in pinned files, local callee bodies are prioritized through two call or callback hops. At most sixteen local helpers per file are selected, each at most 200 lines; file and output budgets still apply.
- Quoted spans of three or more words also match current indexed code by word sequence, ignoring case and punctuation. This includes script strings and unquoted template text. The scan checks its 300 ms budget between files and stops at sixteen matches or 64 MiB read. It skips files over 1 MiB and considers up to four spans of at most 300 characters each. File and output budgets still apply; no re-index is needed.
- Questions about tests include source from the nearest test callers of named symbols, within three caller hops and the file/output budgets. These are static caller links, not measured runtime coverage. Naming a JavaScript or TypeScript test file prioritizes matching \`it\`/\`test\` callbacks.

- **The source codegraph returns is the file's current text** (files that changed since the last sync are flagged), so re-checking it with grep costs time and context without adding accuracy. Call edges from the parse are reliable; a hop marked as a name match (see Limitations) is the one to check.
Expand Down
54 changes: 48 additions & 6 deletions src/mcp/source-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,53 @@
*/
import { getKernel } from '../extraction/kernel/loader';

/** Quoted spans; apostrophes between letters stay inside a single-quoted span. */
export const QUOTED_SPAN = /"([^"\n]*)"|`([^`\n]*)`|(?<![\w$])'((?:[^'\n]|(?<=\w)'(?=\w))*)'(?![\w$])/g;

/** Match up to four copied phrases as whole word sequences, ignoring punctuation. */
export function quotedProsePattern(query: string): RegExp | null {
const phrases = new Set<string>();
for (const m of query.matchAll(QUOTED_SPAN)) {
const inner = (m[1] ?? m[2] ?? m[3] ?? '').trim();
if (inner.length > 300) continue;
if ((inner.match(/[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu) ?? []).length < 3) continue;
const words = inner.match(/[\p{L}\p{N}]+/gu) ?? [];
phrases.add(words.join('[^\\p{L}\\p{N}]+'));
if (phrases.size === 4) break;
}
return phrases.size === 0 ? null
: new RegExp(`(?<![\\p{L}\\p{N}])(?:${[...phrases].join('|')})(?![\\p{L}\\p{N}])`, 'giu');
}

/** Positive matches remain useful when a scan ends before reading every file. */
export function scanQuotedProse(
projectRoot: string,
files: readonly ScanFile[],
pattern: RegExp,
): Array<{ file: string; start: number; end: number }> {
const matches: Array<{ file: string; start: number; end: number }> = [];
let previousFile = '';
let cursor = 0;
let line = 1;
scanIndexedSource(projectRoot, files, pattern, {
budgetMs: 300,
maxFileBytes: 1024 * 1024,
maxTotalBytes: MAX_SCAN_TOTAL_BYTES,
}, (file, offset, match, source) => {
if (file !== previousFile) { previousFile = file; cursor = 0; line = 1; }
for (; cursor < offset; cursor++) if (source.charCodeAt(cursor) === 10) line++;
matches.push({ file, start: line, end: line + (match.match(/\n/g)?.length ?? 0) });
return matches.length === 16;
});
return matches;
}

/**
* Limits on what a warm scan reads inside a 300 ms budget, with room to spare.
* Limits on what a warm absence scan reads inside a 300 ms budget.
* Cost is about 12 microseconds per file plus 1 ms per MB: 13,609 TypeScript
* files (39.5 MB) took 175 ms, 2,174 C files (47.4 MB) took 55 ms. A larger
* project skips the scan, because a scan that runs out of time costs the whole
* budget and answers nothing.
* project skips an absence scan: incomplete coverage cannot prove a name absent.
* Positive prose matches do not require complete coverage.
*/
export const MAX_SCAN_FILES = 16_000;
export const MAX_SCAN_TOTAL_BYTES = 64 * 1024 * 1024;
Expand Down Expand Up @@ -57,14 +98,15 @@ export interface ScanResult {
* Calls `onMatch` for every match of the global `pattern` in `files`, in
* order, until it returns true or a limit runs out. Sizes are checked on the
* opened file, and the read stops one byte past that size, so a file that grew
* since indexing, or grows while it is read, cannot overrun them.
* since indexing, or grows while it is read, cannot overrun them. The callback
* receives the match text and the whole opened file's text for line coordinates.
*/
export function scanIndexedSource(
projectRoot: string,
files: readonly ScanFile[],
pattern: RegExp,
limits: ScanLimits,
onMatch: (filePath: string, offset: number, text: string) => boolean,
onMatch: (filePath: string, offset: number, match: string, source: string) => boolean,
): ScanResult {
if (!pattern.global) throw new Error('scanIndexedSource needs a global pattern');
const deadline = Date.now() + limits.budgetMs;
Expand Down Expand Up @@ -93,7 +135,7 @@ export function scanIndexedSource(
}
pattern.lastIndex = 0;
for (const m of text.matchAll(pattern)) {
if (onMatch(file.path, m.index ?? 0, m[0])) return { complete: true, skipped };
if (onMatch(file.path, m.index ?? 0, m[0], text)) return { complete: true, skipped };
}
}
return { complete: true, skipped };
Expand Down
Loading
Loading