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
13 changes: 10 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ codebase({ action: "reindex", mode: "full", scope: "/path/to/project" })

Configuration does not index the project. Reindexing parses structure first and finishes embeddings. With no provider or provider key set, local `nomic-ai/nomic-embed-text-v1.5` embeddings are the default. The first use downloads approximately 132 MiB and reports progress.

## Tool Reference (5 tool groups, 24 actions)
## Tool Reference (5 tool groups, 25 actions)

### 1. `search` — Find code and knowledge

Expand Down Expand Up @@ -89,13 +89,18 @@ knowledge({ action: "resolve_entities" })
| Action | Use When | Required Params |
|--------|----------|-----------------|
| `configure` | Set up or change active projects | `projectAction` |
| `reindex` | Refresh the index | (none, defaults to incremental) |
| `reindex` | Refresh the index | (none, defaults to incremental; optional `historySince`, `historyMaxCommits`) |
| `status` | Check indexing progress | (none) |
| `stats` | Graph node/edge counts | (none) |
| `source` | Read source code | `path` |
| `ping` | Test connectivity | (none) |
| `profile` | Get a fast static and dynamic project snapshot | (none) |

Widen the persisted git history window during reindexing when deeper history is needed:
```
codebase({ action: "reindex", mode: "full", scope: "/path/to/project", historySince: "2024-01-01T00:00:00Z", historyMaxCommits: 20000 })
```

### 4. `analyze`: Bounded repository analysis

Use purpose-built static and history analysis instead of hand-writing Cypher.
Expand All @@ -108,6 +113,7 @@ Use purpose-built static and history analysis instead of hand-writing Cypher.
| `dead_code` | Need unreferenced export candidates | `projectPath` |
| `hotspots` | Need frequently changed files ranked by current complexity or degree | `projectPath` |
| `change_coupling` | Need file pairs that change together | `projectPath` |
| `ownership` | Need per-file authorship contributors ranked from indexed git history | `projectPath` |

**Examples:**
```
Expand All @@ -117,9 +123,10 @@ analyze({ action: "call_hierarchy", id: "sym:v1:<64 lowercase hex characters>",
analyze({ action: "dead_code", projectPath: "/path/to/project", limit: 100 })
analyze({ action: "hotspots", projectPath: "/path/to/project", since: "2026-01-01", scoreBy: "complexity", limit: 100 })
analyze({ action: "change_coupling", projectPath: "/path/to/project", since: "2026-01-01", minSupport: 2, limit: 100 })
analyze({ action: "ownership", projectPath: "/path/to/project", since: "2026-01-01", pathPrefix: "src", limit: 50 })
```

Every result carries display-ready caveat strings and truncation metadata from the analysis layer. Hotspots and change coupling also report `historyCoverage`, including the observed commit count and date range. Impact, call hierarchy, import cycles, and unreferenced exports are static evidence, not proof of runtime behavior. Dead-code results are candidates and must never drive automated deletion. Git-backed results cover indexed history only.
Every result carries display-ready caveat strings and truncation metadata from the analysis layer. Hotspots, change coupling, and ownership also report `historyCoverage`, including the observed commit count and date range. Ownership is inferred from authorship in indexed git history, not from CODEOWNERS, review activity, expertise, or current team assignment. Impact, call hierarchy, import cycles, and unreferenced exports are static evidence, not proof of runtime behavior. Dead-code results are candidates and must never drive automated deletion. Git-backed results cover indexed history only. Indexed history is bounded by the persisted history window (365 days and 10000 commits by default); widen it by reindexing with an earlier `historySince`.

### 5. `query`: Raw Cypher (power users)

Expand Down
44 changes: 44 additions & 0 deletions e2e/parse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,50 @@ test.describe('Parse API', () => {
expect(data.error).toBeDefined();
});

test('should reject invalid history window inputs', async ({ request }) => {
const response = await request.post(`${API_URL}/api/parse/project`, {
data: {
path: SAMPLE_PROJECT_PATH,
historySince: '2026-02-30',
historyMaxCommits: 0,
},
});

expect(response.status()).toBe(400);
await expect(response.json()).resolves.toEqual({
error: 'historySince must be a valid ISO 8601 date or timestamp',
});
});

for (const historySince of [
'2026-02-30T00:00:00Z',
'2026-04-31T12:00:00Z',
'2025-02-29T00:00:00Z',
]) {
test(`should reject impossible history timestamp ${historySince}`, async ({ request }) => {
const response = await request.post(`${API_URL}/api/parse/project`, {
data: { path: SAMPLE_PROJECT_PATH, historySince },
});

expect(response.status()).toBe(400);
await expect(response.json()).resolves.toEqual({
error: 'historySince must be a valid ISO 8601 date or timestamp',
});
});
}

test('should accept an explicit history window', async ({ request }) => {
const response = await request.post(`${API_URL}/api/parse/project`, {
data: {
path: SAMPLE_PROJECT_PATH,
historySince: '2025-01-01T00:00:00Z',
historyMaxCommits: 2500,
},
});

expect(response.ok()).toBeTruthy();
});

test('should return graph statistics after parsing', async ({ request }) => {
// First parse the project
await request.post(`${API_URL}/api/parse/project`, {
Expand Down
142 changes: 142 additions & 0 deletions packages/api/src/__tests__/analysis-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
getUnreferencedExports: vi.fn(),
getHotspots: vi.fn(),
getChangeCoupling: vi.fn(),
getOwnership: vi.fn(),
}));

vi.mock('@codegraph/core', () => {
Expand Down Expand Up @@ -45,6 +46,7 @@ describe('analysis routes', () => {
mocks.getUnreferencedExports,
mocks.getHotspots,
mocks.getChangeCoupling,
mocks.getOwnership,
]) {
method.mockResolvedValue({
caveats: ['Static analysis is incomplete.'],
Expand Down Expand Up @@ -160,6 +162,7 @@ describe('analysis routes', () => {
'/api/analysis/dead-code',
'/api/analysis/hotspots',
'/api/analysis/change-coupling',
'/api/analysis/ownership',
])('requires projectId for project-wide route %s', async (path) => {
const result = await request(path);

Expand All @@ -179,6 +182,15 @@ describe('analysis routes', () => {
expect(mocks.getImportCycles).not.toHaveBeenCalled();
});

it('returns 404 for ownership when the project cannot be resolved', async () => {
mocks.resolveProjectRootPath.mockResolvedValue(undefined);

const result = await request('/api/analysis/ownership?projectId=missing');

expect(result).toEqual({ status: 404, body: { error: 'Project not found' } });
expect(mocks.getOwnership).not.toHaveBeenCalled();
});

it.each(['1', '26', '1.5', 'NaN', 'Infinity'])(
'rejects import cycle maxDepth=%s with 400',
async (maxDepth) => {
Expand Down Expand Up @@ -245,6 +257,35 @@ describe('analysis routes', () => {
},
);

it.each([
'2026-02-30T00:00:00Z',
'2026-04-31T12:00:00Z',
'2025-02-29T00:00:00Z',
])('rejects impossible ownership since=%s with 400', async (since) => {
const result = await request(
`/api/analysis/ownership?projectId=project&since=${encodeURIComponent(since)}`,
);

expect(result).toEqual({
status: 400,
body: { error: 'since must be a valid ISO 8601 date or timestamp' },
});
expect(mocks.resolveProjectRootPath).not.toHaveBeenCalled();
expect(mocks.getOwnership).not.toHaveBeenCalled();
});

it('accepts a valid ownership leap-day timestamp', async () => {
const result = await request(
'/api/analysis/ownership?projectId=project&since=2024-02-29T00%3A00%3A00Z',
);

expect(result.status).toBe(200);
expect(mocks.getOwnership).toHaveBeenCalledWith({
rootPath: '/repo/project',
since: '2024-02-29T00:00:00Z',
});
});

it('rejects an invalid hotspot scoreBy with 400', async () => {
const result = await request('/api/analysis/hotspots?projectId=project&scoreBy=magic');

Expand Down Expand Up @@ -323,6 +364,107 @@ describe('analysis routes', () => {
});
});

it('maps ownership filters and passes through coverage and caveats', async () => {
mocks.getOwnership.mockResolvedValue({
input: {
rootPath: '/repo/project',
since: '2026-01-01T00:00:00.000Z',
pathPrefix: '/repo/project/src/features',
limit: 12,
},
projectRoot: '/repo/project',
items: [],
truncated: false,
unknownIdentityCommitCount: 0,
historyCoverage: {
commitCount: 8,
earliestCommitDate: '2026-01-01T00:00:00.000Z',
latestCommitDate: '2026-08-01T00:00:00.000Z',
totalCommitCount: 8,
historySince: null,
historyMaxCommits: 200,
historyWindowSize: 200,
historyTruncated: false,
historyComplete: true,
},
caveats: ['Ownership is inferred from authorship.'],
});

const result = await request(
'/api/analysis/ownership?projectId=project&since=2026-01-01&pathPrefix=src%5Cfeatures&limit=12',
);

expect(result.status).toBe(200);
expect(mocks.getOwnership).toHaveBeenCalledWith({
rootPath: '/repo/project',
since: '2026-01-01',
pathPrefix: 'src/features',
limit: 12,
});
expect(result.body.historyCoverage).toMatchObject({
historyMaxCommits: 200,
historyWindowSize: 200,
historyComplete: true,
});
expect(result.body.caveats).toEqual(['Ownership is inferred from authorship.']);
});

it.each(['yesterday', '2026-13-40', '2026-08-21T25:00:00Z'])(
'rejects ownership since=%s before service access',
async (since) => {
const result = await request(
`/api/analysis/ownership?projectId=project&since=${encodeURIComponent(since)}`,
);

expect(result).toEqual({
status: 400,
body: { error: 'since must be a valid ISO 8601 date or timestamp' },
});
expect(mocks.resolveProjectRootPath).not.toHaveBeenCalled();
expect(mocks.getOwnership).not.toHaveBeenCalled();
},
);

it.each([
['/absolute', 'pathPrefix must be project-relative'],
['src/../secret', 'pathPrefix must not contain .. traversal segments'],
['C:\\secret', 'pathPrefix must be project-relative'],
])('rejects ownership pathPrefix %s with 400', async (pathPrefix, error) => {
const result = await request(
`/api/analysis/ownership?projectId=project&pathPrefix=${encodeURIComponent(pathPrefix)}`,
);

expect(result).toEqual({ status: 400, body: { error } });
expect(mocks.getOwnership).not.toHaveBeenCalled();
});

it.each(['0', '1.5', '501', 'NaN', 'Infinity'])(
'rejects ownership limit=%s with 400',
async (limit) => {
const result = await request(`/api/analysis/ownership?projectId=project&limit=${limit}`);

expect(result).toEqual({
status: 400,
body: { error: 'limit must be an integer between 1 and 500' },
});
expect(mocks.resolveProjectRootPath).not.toHaveBeenCalled();
expect(mocks.getOwnership).not.toHaveBeenCalled();
},
);

it('sanitizes ownership service errors at the REST boundary', async () => {
mocks.getOwnership.mockRejectedValue(
new Error('MATCH (secret) token=abc123 failed at /private/repo'),
);

const result = await request('/api/analysis/ownership?projectId=project');

expect(result.status).toBe(500);
expect(result.body).toEqual({ error: 'Failed to analyze ownership.' });
expect(JSON.stringify(result.body)).not.toContain('MATCH');
expect(JSON.stringify(result.body)).not.toContain('abc123');
});

it('sanitizes service errors at the REST boundary', async () => {
mocks.getBlastRadius.mockRejectedValue(
new Error('MATCH (secret) token=abc123 failed at /private/repo'),
Expand Down
92 changes: 92 additions & 0 deletions packages/api/src/__tests__/parse-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
indexProject: vi.fn(),
}));

vi.mock('@codegraph/core', () => ({
indexProject: mocks.indexProject,
}));

import { parseRoutes } from '../routes/parse';

async function post(body: Record<string, unknown>): Promise<{ status: number; body: Record<string, unknown> }> {
const response = await parseRoutes.request('/api/parse/project', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
return { status: response.status, body: await response.json() as Record<string, unknown> };
}

describe('POST /api/parse/project history window', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.indexProject.mockResolvedValue({
success: true,
projectId: 'project',
projectName: 'repo',
stats: { files: 1, entities: 1, edges: 0, errors: 0, durationMs: 1 },
errorMessages: [],
});
});

it('rejects a missing path before indexing', async () => {
expect(await post({})).toEqual({ status: 400, body: { error: 'path field is required' } });
expect(mocks.indexProject).not.toHaveBeenCalled();
});

it.each([
'2026-02-30',
'2026-02-30T00:00:00Z',
'2026-04-31T12:00:00Z',
'2025-02-29T00:00:00Z',
'2026-01-01T00:00:00',
'not-a-date',
])(
'rejects invalid historySince %s before indexing',
async (historySince) => {
const result = await post({ path: '/repo', historySince });
expect(result).toEqual({
status: 400,
body: { error: 'historySince must be a valid ISO 8601 date or timestamp' },
});
expect(mocks.indexProject).not.toHaveBeenCalled();
},
);

it.each([0, -1, 1.5, 100_001, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid historyMaxCommits %s before indexing',
async (historyMaxCommits) => {
const result = await post({ path: '/repo', historyMaxCommits });
expect(result).toEqual({
status: 400,
body: { error: 'historyMaxCommits must be a safe integer between 1 and 100000' },
});
expect(mocks.indexProject).not.toHaveBeenCalled();
},
);

it('forwards the exact optional history window', async () => {
const result = await post({
path: '/repo',
historySince: '2025-01-01T00:00:00Z',
historyMaxCommits: 2500,
});

expect(result.status).toBe(200);
expect(mocks.indexProject).toHaveBeenCalledWith('/repo', {
historySince: '2025-01-01T00:00:00Z',
historyMaxCommits: 2500,
});
});

it('accepts a valid leap-day history timestamp', async () => {
const result = await post({ path: '/repo', historySince: '2024-02-29T00:00:00Z' });

expect(result.status).toBe(200);
expect(mocks.indexProject).toHaveBeenCalledWith('/repo', {
historySince: '2024-02-29T00:00:00Z',
});
});
});
Loading
Loading