Repository navigation
feat(explore): report missing query names with contained source checks #389
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
634619c
feat(explore): list query names that exist nowhere in the project
bompus 82d487b
fix(explore): prove absence before listing a name as not found
bompus 3f8a70e
fix(explore): check the opened file, bound reads, and read apostrophe…
bompus 908b42c
fix(explore): verify source containment through native handles
bompus a0a42b1
ci(explore): validate containment without external actions
bompus a2f933b
docs(explore): qualify when missing names are reported
bompus 3db7fd8
docs(mcp): scope missing-name search advice to indexed code
bompus File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| name: Contained source reads | ||
| on: | ||
| pull_request: | ||
| branches: [fork/consolidated] | ||
| paths: | ||
| - 'codegraph-kernel/src/contained_source.rs' | ||
| - 'codegraph-kernel/src/lib.rs' | ||
| - 'src/extraction/kernel/loader.ts' | ||
| - 'src/mcp/source-scan.ts' | ||
| - '__tests__/source-scan.test.ts' | ||
| - '__tests__/security.test.ts' | ||
| - '__tests__/explore-unmatched-names.test.ts' | ||
| - '.github/workflows/contained-source.yml' | ||
| permissions: | ||
| contents: read | ||
| jobs: | ||
| source-reads: | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| os: [ubuntu-latest, windows-latest, macos-latest] | ||
| runs-on: ${{ matrix.os }} | ||
| timeout-minutes: 20 | ||
| env: | ||
| CARGO_BUILD_JOBS: 2 | ||
| CARGO_PROFILE_RELEASE_DEBUG: 0 | ||
| steps: | ||
| - name: Check out the tested commit | ||
| shell: bash | ||
| run: | | ||
| git init . | ||
| git config core.autocrlf false | ||
| git remote add origin "https://github.com/$GITHUB_REPOSITORY.git" | ||
| git fetch --no-tags --depth=1 origin "$GITHUB_SHA" | ||
| git checkout --detach FETCH_HEAD | ||
| - name: Use the runner's Node 24 installation | ||
| shell: pwsh | ||
| run: | | ||
| $versions = Get-ChildItem "$env:RUNNER_TOOL_CACHE/node" -Directory | ||
| $version = $versions | Where-Object Name -like '24.*' | Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 | ||
| if (!$version) { throw 'Node 24 is missing from the runner tool cache' } | ||
| $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() | ||
| $nodeDir = Join-Path $version.FullName $arch | ||
| if (!$IsWindows) { $nodeDir = Join-Path $nodeDir 'bin' } | ||
| $nodeExe = if ($IsWindows) { 'node.exe' } else { 'node' } | ||
| if (!(Test-Path (Join-Path $nodeDir $nodeExe))) { throw 'Node 24 executable is missing' } | ||
| Add-Content $env:GITHUB_PATH $nodeDir | ||
| - run: node -e "if (process.versions.node.split('.')[0] !== '24') process.exit(1)" | ||
| - run: npm ci --ignore-scripts | ||
| - run: npm run build:kernel | ||
| - run: cargo test --manifest-path codegraph-kernel/Cargo.toml --release contained_source::tests -- --test-threads=1 | ||
| - run: npx vitest run __tests__/source-scan.test.ts __tests__/security.test.ts __tests__/explore-unmatched-names.test.ts -t "scanIndexedSource|native source reader|unmatched names" --maxWorkers=2 --minWorkers=1 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| /** | ||
| * Explore lists the symbol-shaped names it could not find. A query that mixes | ||
| * real names with a guessed one (`finalHistoryReconciled`) used to come back | ||
| * with the real names' code and no word about the guess, so the agent read | ||
| * on as if the returned code held it. The summary now names the misses, and | ||
| * stays quiet for words that only look precise (a sentence-initial `How`), | ||
| * quoted literals, pinned files, camel infixes that seed by definer, and names | ||
| * that appear in source without a node of their own (imports, parameters, | ||
| * object keys, module stems). | ||
| */ | ||
| import { describe, it, expect, beforeAll, afterAll } from 'vitest'; | ||
| import * as fs from 'fs'; | ||
| import * as path from 'path'; | ||
| import * as os from 'os'; | ||
| import CodeGraph from '../src/index'; | ||
| import { ToolHandler } from '../src/mcp/tools'; | ||
|
|
||
| const FILES: Record<string, string> = { | ||
| 'src/snapshot.ts': [ | ||
| 'export function verifySnapshot(rows: string[]): boolean {', | ||
| ' const key = getSnapshotKey(rows);', | ||
| " return key.length > 0 && localStorage.getItem('draft.pick_state') !== null;", | ||
| '}', | ||
| '', | ||
| 'export function getSnapshotKey(rows: string[]): string {', | ||
| " return rows.join(':');", | ||
| '}', | ||
| '', | ||
| ].join('\n'), | ||
| 'src/draftStoreState.ts': [ | ||
| "import { spawnSync } from 'child_process';", | ||
| '', | ||
| 'export function runDraft(projectPath: string): number {', | ||
| " const events = { DRAFT_STATE_UPDATED: 'updated', maxBuffer: 1024 };", | ||
| " const out = spawnSync('draft', [projectPath], { maxBuffer: events.maxBuffer });", | ||
| ' process.exitCode = out.status ?? 1;', | ||
| ' return out.status ?? 1;', | ||
| '}', | ||
| '', | ||
| ].join('\n'), | ||
| // A stem whose extension the explore token cleanup does not strip. | ||
| 'config/draft_rules.yaml': 'a: 1\n', | ||
| // Prose that names the guess: docs are not code, so this must not hide it. | ||
| 'docs/notes.md': '# Notes\n\nWe planned `finalHistoryReconciled` but never wrote it.\n', | ||
| 'src/pickLedger.ts': [ | ||
| "import { verifySnapshot } from './snapshot';", | ||
| '', | ||
| 'export function recordPick(rows: string[]): void {', | ||
| ' if (!verifySnapshot(rows)) throw new Error("bad snapshot");', | ||
| '}', | ||
| '', | ||
| ].join('\n'), | ||
| }; | ||
|
|
||
| let dir: string; | ||
| let cg: CodeGraph; | ||
|
|
||
| async function explore(query: string): Promise<string> { | ||
| const res = await new ToolHandler(cg).execute('codegraph_explore', { query }); | ||
| return res.content?.[0]?.text ?? ''; | ||
| } | ||
|
|
||
| beforeAll(async () => { | ||
| dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-unmatched-')); | ||
| for (const [rel, text] of Object.entries(FILES)) { | ||
| fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); | ||
| fs.writeFileSync(path.join(dir, rel), text); | ||
| } | ||
| cg = CodeGraph.initSync(dir); | ||
| await cg.indexAll(); | ||
| }, 180_000); | ||
|
|
||
| afterAll(() => { | ||
| cg?.destroy(); | ||
| if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe('unmatched names in the explore summary', () => { | ||
| it('names a guessed symbol beside the real one it still answers', async () => { | ||
| const out = await explore('verifySnapshot finalHistoryReconciled'); | ||
| expect(out).toContain('**`src/snapshot.ts`'); | ||
| expect(out).toContain('Not found in the index: `finalHistoryReconciled`.'); | ||
| }); | ||
|
|
||
| // An apostrophe is not a quote: the guess in a plain sentence is still a | ||
| // guess, and a double-quoted phrase stays a phrase around one. | ||
| it('reads apostrophes as part of words, not as quotes', async () => { | ||
| const out = await explore("verifySnapshot don't call finalHistoryReconciled if it's gone"); | ||
| expect(out).toContain('Not found in the index: `finalHistoryReconciled`.'); | ||
| for (const query of [ | ||
| 'verifySnapshot "we don\'t call finalHistoryReconciled here"', | ||
| "verifySnapshot 'we don't call finalHistoryReconciled here'", | ||
| ]) { | ||
| expect(await explore(query), query).not.toContain('Not found in the index'); | ||
| } | ||
| }); | ||
|
|
||
| // Quotes pair left to right, and a word quoted once but also written bare | ||
| // is still the agent's own guess. | ||
| it('reads the words between and outside quoted spans as unquoted', async () => { | ||
| for (const query of [ | ||
| 'verifySnapshot "draft" finalHistoryReconciled "state"', | ||
| 'verifySnapshot "we skip finalHistoryReconciled here" then finalHistoryReconciled', | ||
| 'verifySnapshot "we skip finalHistoryReconciled here" then "finalHistoryReconciled"', | ||
| ]) { | ||
| expect(await explore(query), query).toContain('Not found in the index: `finalHistoryReconciled`.'); | ||
| } | ||
| }); | ||
|
|
||
| // A quoted lone name is a name the agent expects in code, unlike a quoted | ||
| // literal that some file holds. | ||
| it('lists a quoted name that neither the graph nor any literal holds', async () => { | ||
| expect(await explore('verifySnapshot "finalHistoryReconciled"')).toContain('Not found in the index: `finalHistoryReconciled`.'); | ||
| }); | ||
|
|
||
| it('lists qualified and snake_case guesses too', async () => { | ||
| const out = await explore('verifySnapshot Ledger.replayAll draft_state_guard'); | ||
| expect(out).toContain('`Ledger.replayAll`'); | ||
| expect(out).toContain('`draft_state_guard`'); | ||
| }); | ||
|
|
||
| it('stays quiet for prose, quoted literals, pinned files and camel infixes', async () => { | ||
| for (const query of [ | ||
| 'How does verifySnapshot work', | ||
| 'verifySnapshot "draft.pick_state"', | ||
| 'pickLedger.ts recordPick', | ||
| 'verifySnapshot snapshotKey', | ||
| 'verifySnapshot "snapshotKey"', | ||
| // Real code the node table does not hold: an import, a parameter, a | ||
| // module stem, object keys, a global member, a version string. | ||
| 'runDraft spawnSync projectPath', | ||
| 'runDraft draftStoreState', | ||
| 'runDraft DRAFT_STATE_UPDATED maxBuffer', | ||
| 'runDraft process.exitCode', | ||
| 'runDraft v1.0.352', | ||
| 'verifySnapshot draft_rules', | ||
| 'verifySnapshot "some finalHistoryReconciled phrase"', | ||
| ]) { | ||
| expect(await explore(query), query).not.toContain('Not found in the index'); | ||
| } | ||
| }); | ||
|
|
||
| // A file that cannot be read leaves the absence unproven: the guess may be | ||
| // an object key in exactly that file. | ||
| it.runIf(process.platform !== 'win32' && process.getuid?.() !== 0)('lists nothing when a source file cannot be read', async () => { | ||
| const file = path.join(dir, 'src/draftStoreState.ts'); | ||
| fs.chmodSync(file, 0o000); | ||
| try { | ||
| expect(await explore('verifySnapshot finalHistoryReconciled')).not.toContain('Not found in the index'); | ||
| } finally { | ||
| fs.chmodSync(file, 0o644); | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| /** | ||
| * The source scan's limits are checked on the file it opened, so the answer | ||
| * "this name appears nowhere" is given only after every file was read whole. | ||
| */ | ||
| import { describe, it, expect, beforeAll, afterAll } from 'vitest'; | ||
| 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'; | ||
|
|
||
| let dir: string; | ||
| const LIMITS = { budgetMs: 10_000, maxFileBytes: 1024, maxTotalBytes: 4096 }; | ||
|
|
||
| beforeAll(() => { | ||
| dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-source-scan-')); | ||
| fs.writeFileSync(path.join(dir, 'a.ts'), 'const alpha = 1;\n'); | ||
| fs.writeFileSync(path.join(dir, 'b.ts'), 'const beta = 2;\n'); | ||
| fs.writeFileSync(path.join(dir, 'big.ts'), `const gamma = '${'x'.repeat(2000)}';\n`); | ||
| }); | ||
|
|
||
| afterAll(() => { | ||
| if (dir) fs.rmSync(dir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| function words(files: string[], limits = LIMITS) { | ||
| const seen: string[] = []; | ||
| // Index-time sizes are stale on purpose: the scan must use the opened file's. | ||
| const result = scanIndexedSource(dir, files.map((p) => ({ path: p, size: 1 })), /\b(?:alpha|beta|gamma)\b/g, limits, (_f, _o, text) => { | ||
| seen.push(text); | ||
| return false; | ||
| }); | ||
| return { ...result, seen }; | ||
| } | ||
|
|
||
| describe('scanIndexedSource', () => { | ||
| it('reads every file and reports a complete scan', () => { | ||
| expect(words(['a.ts', 'b.ts'])).toEqual({ complete: true, skipped: 0, seen: ['alpha', 'beta'] }); | ||
| }); | ||
|
|
||
| it('skips a file over the per-file limit, which leaves absence unproven', () => { | ||
| expect(words(['a.ts', 'big.ts'])).toEqual({ complete: true, skipped: 1, seen: ['alpha'] }); | ||
| }); | ||
|
|
||
| it('stops incomplete when the bytes read would pass the total limit', () => { | ||
| const limits = { ...LIMITS, maxFileBytes: 4096, maxTotalBytes: 1024 }; | ||
| expect(words(['a.ts', 'big.ts', 'b.ts'], limits)).toEqual({ complete: false, skipped: 0, seen: ['alpha'] }); | ||
| }); | ||
|
|
||
| // A FIFO with no writer would block a plain open forever. | ||
| it.runIf(process.platform !== 'win32')('skips a FIFO without waiting for a writer', () => { | ||
| execFileSync('mkfifo', [path.join(dir, 'pipe.ts')]); | ||
| // A child deadline makes a blocking-open regression fail without hanging | ||
| // the test runner's own event loop. | ||
| const script = ` | ||
| const { scanIndexedSource } = require(${JSON.stringify(path.resolve(__dirname, '../dist/mcp/source-scan.js'))}); | ||
| const seen = []; | ||
| const result = scanIndexedSource(${JSON.stringify(dir)}, | ||
| ['a.ts', 'pipe.ts', 'b.ts'].map(path => ({ path, size: 1 })), | ||
| /alpha|beta/g, ${JSON.stringify(LIMITS)}, | ||
| (_f, _o, word) => { seen.push(word); return false; }); | ||
| console.log(JSON.stringify({ ...result, seen })); | ||
| `; | ||
| const result = execFileSync(process.execPath, ['-e', script], { | ||
| encoding: 'utf8', timeout: 3000, killSignal: 'SIGKILL', | ||
| }); | ||
| expect(JSON.parse(result)).toEqual({ complete: true, skipped: 1, seen: ['alpha', 'beta'] }); | ||
| }); | ||
|
|
||
| it('counts a missing or escaping path as skipped', () => { | ||
| expect(words(['a.ts', 'gone.ts', '../outside.ts'])).toEqual({ complete: true, skipped: 2, seen: ['alpha'] }); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.