diff --git a/CHANGELOG.md b/CHANGELOG.md
index 22af2a6874..cd86f37161 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index f404703ccf..ecba32d0eb 100644
--- a/README.md
+++ b/README.md
@@ -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/…`. |
diff --git a/__tests__/explore-quoted-prose.test.ts b/__tests__/explore-quoted-prose.test.ts
new file mode 100644
index 0000000000..c73ea56068
--- /dev/null
+++ b/__tests__/explore-quoted-prose.test.ts
@@ -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'), [
+ '',
+ ...Array.from({ length: 310 }, () => ' padding'),
+ ' Changes cannot be saved until verification finishes.
Account changes require a confirmed email address.
'); + 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('Changes cannot be saved until verification finishes.
'); + 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('Account changes require a confirmed email address.
'); + 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(); } + }); +}); diff --git a/__tests__/source-scan.test.ts b/__tests__/source-scan.test.ts index 819ca27b22..ac97d6778d 100644 --- a/__tests__/source-scan.test.ts +++ b/__tests__/source-scan.test.ts @@ -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 }; @@ -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 }, () => 'Proceed with\r\naccount verification.
').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([]); + }); +}); diff --git a/site/src/content/docs/reference/mcp-server.md b/site/src/content/docs/reference/mcp-server.md index ec9b1f7385..5dcb5ae719 100644 --- a/site/src/content/docs/reference/mcp-server.md +++ b/site/src/content/docs/reference/mcp-server.md @@ -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 diff --git a/src/codegraph.ts b/src/codegraph.ts index f32bee8825..29e6dc9805 100644 --- a/src/codegraph.ts +++ b/src/codegraph.ts @@ -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(); } diff --git a/src/db/queries.ts b/src/db/queries.ts index 98de150136..c7b9472f99 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -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). */ diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts index 2ab89979c0..a832804e62 100644 --- a/src/mcp/server-instructions.ts +++ b/src/mcp/server-instructions.ts @@ -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. diff --git a/src/mcp/source-scan.ts b/src/mcp/source-scan.ts index bb092e61eb..a91d727c06 100644 --- a/src/mcp/source-scan.ts +++ b/src/mcp/source-scan.ts @@ -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]*)`|(?(); + 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(`(? { + 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; @@ -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; @@ -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 }; diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index adda7337ee..79470024c8 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -52,7 +52,7 @@ import { statSync, } from 'fs'; import { createHash } from 'crypto'; -import { MAX_SCAN_TOTAL_BYTES, scanFits, scanIndexedSource } from './source-scan'; +import { MAX_SCAN_TOTAL_BYTES, QUOTED_SPAN, quotedProsePattern, scanFits, scanIndexedSource, scanQuotedProse } from './source-scan'; import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils'; import { guardLabel, guardsForFileSync, siteKey, supportsBranchGuards, warmBranchGuardGrammars } from '../graph/branch-guards'; import { findDynamicBoundaries, type BoundarySite } from '../graph/dynamic-boundary-report'; @@ -247,12 +247,6 @@ function filesDefiningSymbol(cg: CodeGraph, symbol: string): string[] { /** Time the not-found check may spend reading indexed source text. */ const UNMATCHED_SCAN_BUDGET_MS = 300; -/** - * One quoted span of a query: double quotes, backticks, or single quotes that - * sit away from a word, so the apostrophes in "don't" and "it's" are letters. - * Matched left to right, so each closing quote ends its own span. - */ -const QUOTED_SPAN = /"([^"\n]*)"|`([^`\n]*)`|(? 0) { - const holderFiles = new Set