Skip to content
Closed
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
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,80 @@ add a negation — `!vendor/`. The defaults apply uniformly, so committing a
dependency or build directory doesn't force it into the graph; the `.gitignore`
negation is the explicit opt-in.

## Contributing

Set up a local checkout from your fork:

```bash
git clone git@github.com:<your-user>/codegraph.git
cd codegraph
git remote add upstream https://github.com/colbymchenry/codegraph.git
npm install
npm run build
```

To point the global `codegraph` command at your local build while developing:

```bash
npm link
codegraph --version
```

To switch back to the published package:

```bash
npm unlink -g @colbymchenry/codegraph
npm i -g @colbymchenry/codegraph@latest
```

You can also run the local build without changing the global command:

```bash
npm run cli -- status
```

Useful development commands:

```bash
npm run dev # TypeScript watch build
npm test # full test suite
npm test -- <file> # focused test file
npm run test:watch # Vitest watch mode
```

### macOS Source Builds and FTS5

The self-contained release and npm package use CodeGraph's bundled runtime, so
end users do not need to manage SQLite or FTS5. Contributors running directly
from source use their local Node runtime instead. On macOS, some official Node
22.x and 23.x builds include `node:sqlite` without FTS5 enabled, which makes
indexing fail with `no such module: fts5`.

Use Node 24.x for local development on macOS if you see that error:

```bash
nvm install 24
nvm use 24
npm install
npm run build
```

Quick FTS5 check before indexing:

```bash
node - <<'NODE'
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(':memory:');
db.exec('CREATE VIRTUAL TABLE cg_fts_check USING fts5(value)');
db.close();
console.log('FTS5 available');
NODE
```

PR hygiene: do not bump `package.json` versions unless you are preparing a
release, and do not update `package-lock.json` unless your change modifies
dependencies.

## Supported Platforms

Every release ships a self-contained build (bundled Node runtime — nothing to
Expand Down
39 changes: 39 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function cleanupTempDir(dir: string): void {
describe('Language Detection', () => {
it('should detect TypeScript files', () => {
expect(detectLanguage('src/index.ts')).toBe('typescript');
expect(detectLanguage('entry/src/main/ets/pages/Index.ets')).toBe('typescript');
expect(detectLanguage('components/Button.tsx')).toBe('tsx');
});

Expand Down Expand Up @@ -3382,6 +3383,44 @@ describe('Directory Exclusion', () => {
expect(files.every((f) => !f.includes('.git'))).toBe(true);
});

it('should index git-visible files under non-ASCII directories (issue #541)', async () => {
const { execFileSync } = await import('child_process');
const git = (...args: string[]) =>
execFileSync('git', args, { cwd: tempDir, stdio: 'pipe' });

git('init', '-q');
fs.mkdirSync(path.join(tempDir, 'src', 'english'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'src', '中文目录'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, 'src', 'english', 'Foo.cs'),
'namespace Demo;\npublic class Foo { public void Bar() {} }\n',
);
fs.writeFileSync(
path.join(tempDir, 'src', '中文目录', 'Baz.cs'),
'namespace Demo;\npublic class Baz { public void Qux() {} }\n',
);
git('add', '-A');

const scanned = scanDirectory(tempDir).sort();
expect(scanned).toEqual(['src/english/Foo.cs', 'src/中文目录/Baz.cs']);

const cg = CodeGraph.initSync(tempDir);
try {
const result = await cg.indexAll();
expect(result.filesIndexed).toBe(2);
expect(cg.getFiles().map((f) => f.path).sort()).toEqual(scanned);

fs.writeFileSync(
path.join(tempDir, 'src', '中文目录', 'Baz.cs'),
'namespace Demo;\npublic class Baz { public void Qux() {} public void Zap() {} }\n',
);
const changed = cg.getChangedFiles();
expect([...changed.added, ...changed.modified]).toContain('src/中文目录/Baz.cs');
} finally {
cg.close();
}
});

it('should return forward-slash paths on all platforms', () => {
const srcDir = path.join(tempDir, 'src', 'components');
fs.mkdirSync(srcDir, { recursive: true });
Expand Down
8 changes: 8 additions & 0 deletions __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,14 @@ describe('Installer targets — partial-state idempotency', () => {
expect(cfg.mcpServers.codegraph).toBeDefined();
});

it('claude: auto-allow includes codegraph_files (#565)', () => {
const claude = getTarget('claude')!;
claude.install('local', { autoAllow: true });

const settings = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.claude', 'settings.json'), 'utf-8'));
expect(settings.permissions.allow).toContain('mcp__codegraph__codegraph_files');
});

it('claude: install does NOT create a CLAUDE.md instructions file (#529)', () => {
const claude = getTarget('claude')!;
const result = claude.install('local', { autoAllow: false });
Expand Down
17 changes: 17 additions & 0 deletions __tests__/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as os from 'os';
import {
writeMcpConfig,
} from '../src/installer/config-writer';
import { atomicWriteFileSync } from '../src/installer/targets/shared';

function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-installer-test-'));
Expand Down Expand Up @@ -99,6 +100,22 @@ describe('Installer Config Writer', () => {
expect(content.mcpServers.codegraph).toBeDefined();
expect(content.mcpServers.other).toBeDefined();
expect(content.customField).toBe('preserved');
expect(fs.existsSync(mcpJson + '.backup')).toBe(true);
const backup = JSON.parse(fs.readFileSync(mcpJson + '.backup', 'utf-8'));
expect(backup.mcpServers.codegraph).toBeUndefined();
expect(backup.mcpServers.other).toBeDefined();
});

it('should create numbered backups instead of overwriting an existing backup', () => {
const file = path.join(tempDir, 'settings.json');
fs.writeFileSync(file, '{"version":1}\n');

atomicWriteFileSync(file, '{"version":2}\n');
atomicWriteFileSync(file, '{"version":3}\n');

expect(fs.readFileSync(file, 'utf-8')).toContain('"version":3');
expect(fs.readFileSync(file + '.backup', 'utf-8')).toContain('"version":1');
expect(fs.readFileSync(file + '.backup.1', 'utf-8')).toContain('"version":2');
});
});
});
91 changes: 90 additions & 1 deletion __tests__/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { FileLock, validateProjectPath } from '../src/utils';
import { FileLock, validatePathWithinRoot, validateProjectPath } from '../src/utils';
import CodeGraph from '../src/index';
import { ToolHandler, tools } from '../src/mcp/tools';
import { scanDirectory, isSourceFile } from '../src/extraction';
Expand Down Expand Up @@ -174,6 +174,72 @@ describe('Path Traversal Prevention', () => {
const code = await cg.getCode('does-not-exist');
expect(code).toBeNull();
});

it('should reject symlinked files that resolve outside the project root', () => {
const outsideDir = createTempDir();
try {
const outsideFile = path.join(outsideDir, 'secret.ts');
fs.writeFileSync(outsideFile, 'export const secret = "outside";\n');

const linkPath = path.join(testDir, 'src', 'secret.ts');
try {
fs.symlinkSync(outsideFile, linkPath, 'file');
} catch {
return;
}

expect(validatePathWithinRoot(testDir, 'src/secret.ts')).toBeNull();
} finally {
cleanupTempDir(outsideDir);
}
});

it('should reject paths beneath symlinked directories outside the project root', () => {
const outsideDir = createTempDir();
try {
const linkPath = path.join(testDir, 'src', 'external');
try {
fs.symlinkSync(outsideDir, linkPath, 'dir');
} catch {
return;
}

expect(validatePathWithinRoot(testDir, 'src/external/new-file.ts')).toBeNull();
} finally {
cleanupTempDir(outsideDir);
}
});

it('should allow symlinked files that resolve inside the project root', () => {
const linkPath = path.join(testDir, 'src', 'hello-link.ts');
try {
fs.symlinkSync(path.join(testDir, 'src', 'hello.ts'), linkPath, 'file');
} catch {
return;
}

expect(validatePathWithinRoot(testDir, 'src/hello-link.ts')).toBe(linkPath);
});

it('should not index symlinked files that resolve outside the project root', async () => {
const outsideDir = createTempDir();
try {
const outsideFile = path.join(outsideDir, 'secret.ts');
fs.writeFileSync(outsideFile, 'export const secret = "outside";\n');

try {
fs.symlinkSync(outsideFile, path.join(testDir, 'src', 'secret.ts'), 'file');
} catch {
return;
}

const result = await cg.indexAll();
expect(result.success).toBe(true);
expect(cg.getFiles().map((file) => file.path)).not.toContain('src/secret.ts');
} finally {
cleanupTempDir(outsideDir);
}
});
});

describe('validateProjectPath — sensitive directory blocking', () => {
Expand Down Expand Up @@ -373,6 +439,7 @@ describe('Source file detection (isSourceFile)', () => {
expect(isSourceFile('src/index.ts')).toBe(true);
expect(isSourceFile('src/deep/nested/file.ts')).toBe(true);
expect(isSourceFile('src/component.tsx')).toBe(true);
expect(isSourceFile('entry/src/main/ets/pages/Index.ets')).toBe(true);
expect(isSourceFile('lib/util.js')).toBe(true);
expect(isSourceFile('src/main.py')).toBe(true);
});
Expand Down Expand Up @@ -550,6 +617,28 @@ describe('Symlink Cycle Detection', () => {
const files = scanDirectory(tempDir);
expect(files).toContain('src/valid.ts');
});

it('should skip symlinks that resolve outside the root', () => {
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir);
fs.writeFileSync(path.join(srcDir, 'valid.ts'), 'export const valid = true;\n');

const outsideDir = createTempDir();
try {
fs.writeFileSync(path.join(outsideDir, 'secret.ts'), 'export const secret = true;\n');
try {
fs.symlinkSync(outsideDir, path.join(srcDir, 'external'), 'dir');
} catch {
return;
}

const files = scanDirectory(tempDir);
expect(files).toContain('src/valid.ts');
expect(files).not.toContain('src/external/secret.ts');
} finally {
cleanupTempDir(outsideDir);
}
});
});

describe('Session marker symlink resistance', () => {
Expand Down
5 changes: 5 additions & 0 deletions src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
*/
export const EXTENSION_MAP: Record<string, Language> = {
'.ts': 'typescript',
// ArkTS (`.ets`) is a TypeScript superset used by HarmonyOS/OpenHarmony.
// The TypeScript grammar handles its common syntax well enough for first-pass
// indexing, and keeps extension selection aligned with custom .ets=typescript
// workarounds users were already applying.
'.ets': 'typescript',
'.tsx': 'tsx',
'.js': 'javascript',
'.mjs': 'javascript',
Expand Down
Loading