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
52 changes: 52 additions & 0 deletions .github/workflows/contained-source.yml
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
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

- 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.

- Analog default file pages now link to their component classes, preserving its directory layouts, dotted paths and dynamic segments when the platform plugin and file router are registered.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Compared with upstream `main` at `6560052` (v1.6.2). Each item here and in the d
|---|:-:|:-:|---|
| Session search (`codegraph sessions`, `codegraph_sessions`) | — | ✓ | Searches this project's earlier Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin and Grok transcripts and its git commit messages, so an agent can find what a past session decided. `codegraph_explore` also names the sessions that mentioned the symbols it returns. `"sessions": false` in `codegraph.json` turns it off. |
| 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. |
| 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/…`. |
| Change questions | — | ✓ | "What did my changes touch?" or `main..HEAD` is answered from the diff: the changed functions, their callers and their tests. |
Expand Down
154 changes: 154 additions & 0 deletions __tests__/explore-unmatched-names.test.ts
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);
}
});
});
40 changes: 38 additions & 2 deletions __tests__/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as path from 'path';
import * as os from 'os';
import { FileLock, validateProjectPath, validatePathWithinRoot } from '../src/utils';
import CodeGraph from '../src/index';
import { requireKernel } from '../src/extraction/kernel/loader';
import { ToolHandler, tools } from '../src/mcp/tools';
import { scanDirectory, isSourceFile } from '../src/extraction';
import { DatabaseConnection, getDatabasePath } from '../src/db';
Expand Down Expand Up @@ -217,9 +218,13 @@ describe('Symlink escape prevention (#527)', () => {
fs.rmSync(outside, { recursive: true, force: true });
});

// Symlink creation needs privileges on Windows; skip gracefully if it fails.
// Legacy checks allow missing Windows symlink privileges. Native reader
// checks require link creation, so platform CI cannot skip their assertions.
const link = (linkPath: string, target: string): boolean => {
try { fs.symlinkSync(target, linkPath); return true; } catch { return false; }
try {
fs.symlinkSync(target, linkPath, fs.statSync(target).isDirectory() ? 'dir' : 'file');
return true;
} catch { return false; }
};

it('allows a real file inside the root (and realpaths consistently)', () => {
Expand Down Expand Up @@ -249,6 +254,37 @@ describe('Symlink escape prevention (#527)', () => {
expect(validatePathWithinRoot(root, 'src/inlink.ts')).not.toBeNull();
});

// The batch form a source scan uses checks the file it opened, so each
// escape route must be caught there too, including a swap after a first read.
function containedReader(rootPath: string): (relative: string) => boolean {
const Reader = requireKernel().ContainedSourceReader;
expect(Reader).toBeTypeOf('function');
const reader = new Reader!(rootPath);
return (relative) => reader.read(relative, 4096, 4096).content != null;
}

it('native source reader agrees: in-root files read, every escape is refused', () => {
const read = containedReader(root);
expect(read('src/in.ts')).toBe(true);
expect(read(`../${path.basename(outside)}/pkg/secret.txt`)).toBe(false);
expect(read('src/missing.ts')).toBe(false);
expect(link(path.join(root, 'escape'), path.join(outside, 'pkg', 'secret.txt'))).toBe(true);
expect(read('escape')).toBe(false);
expect(link(path.join(root, 'escapedir'), path.join(outside, 'pkg'))).toBe(true);
expect(read('escapedir/secret.txt')).toBe(false);
expect(link(path.join(root, 'src', 'inlink.ts'), path.join(root, 'src', 'in.ts'))).toBe(true);
expect(read('src/inlink.ts')).toBe(true);
});

it('native source reader refuses a directory moved out of the root and linked back in', () => {
const read = containedReader(root);
expect(read('src/in.ts')).toBe(true);
// The same directory, same inode, now reached through a symlink.
fs.renameSync(path.join(root, 'src'), path.join(outside, 'moved-src'));
expect(link(path.join(root, 'src'), path.join(outside, 'moved-src'))).toBe(true);
expect(read('src/in.ts')).toBe(false);
});

// The INDEXING read path opts into following in-root symlinks the directory
// walk already descended into — discovery and the reader must agree, or files
// reached via an in-root symlink-to-outside fail to index (#935). The lexical
Expand Down
73 changes: 73 additions & 0 deletions __tests__/source-scan.test.ts
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'] });
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('counts a missing or escaping path as skipped', () => {
expect(words(['a.ts', 'gone.ts', '../outside.ts'])).toEqual({ complete: true, skipped: 2, seen: ['alpha'] });
});
});
Loading
Loading